From f203ca88de7c0d21027d7a1bed74a0111d97d642 Mon Sep 17 00:00:00 2001 From: airano-ir Date: Sat, 25 Apr 2026 16:25:58 +0200 Subject: [PATCH] feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 68 + CLAUDE.md | 4 +- Dockerfile | 3 +- Dockerfile.mirror | 123 + Dockerfile.slim | 113 + README.md | 87 +- core/api_keys.py | 65 +- core/capability_probe.py | 485 +++ core/companion_audit.py | 325 ++ core/dashboard/routes.py | 337 +- core/database.py | 211 +- core/encryption.py | 29 + core/media_audit.py | 67 + core/media_error_codes.py | 91 + core/site_api.py | 224 +- core/templates/dashboard/connect.html | 12 +- core/templates/dashboard/keys/list.html | 12 +- core/templates/dashboard/service.html | 51 + .../dashboard/sites/_capability_badge.html | 173 + core/templates/dashboard/sites/manage.html | 566 +++- core/tool_access.py | 257 +- core/tool_rate_limiter.py | 122 + core/upload_sessions.py | 474 +++ core/user_endpoints.py | 70 +- docker-compose.coolify.mirror.yaml | 84 + docker-compose.coolify.slim.yaml | 94 + docker-compose.coolify.yaml | 9 + docs/getting-started.md | 2 +- docs/media-error-codes.md | 97 + docs/prompts/F16-gitea-public-release.md | 90 - docs/prompts/next-session-coolify-f17.md | 95 - docs/prompts/next-session-coolify-test.md | 77 - docs/prompts/next-session-f17-phase3.md | 115 - docs/prompts/next-session-f7-tool-access.md | 136 - docs/prompts/next-session-f7b-ui.md | 98 - docs/prompts/next-session.md | 93 - plugins/ai_image/__init__.py | 33 + plugins/ai_image/providers/__init__.py | 1 + plugins/ai_image/providers/base.py | 72 + plugins/ai_image/providers/openai.py | 184 ++ plugins/ai_image/providers/openrouter.py | 474 +++ plugins/ai_image/providers/replicate.py | 204 ++ plugins/ai_image/providers/stability.py | 154 + plugins/ai_image/registry.py | 36 + plugins/base.py | 34 + plugins/gitea/client.py | 269 +- plugins/gitea/handlers/repositories.py | 373 +++ plugins/gitea/plugin.py | 60 + plugins/n8n/client.py | 180 +- plugins/n8n/handlers/credentials.py | 20 +- plugins/n8n/handlers/executions.py | 34 +- plugins/n8n/handlers/projects.py | 26 +- plugins/n8n/handlers/system.py | 21 +- plugins/n8n/handlers/tags.py | 22 +- plugins/n8n/handlers/users.py | 20 +- plugins/n8n/handlers/variables.py | 22 +- plugins/n8n/handlers/workflows.py | 84 +- plugins/n8n/plugin.py | 68 +- plugins/woocommerce/plugin.py | 198 +- plugins/wordpress/client.py | 133 +- plugins/wordpress/handlers/__init__.py | 72 + plugins/wordpress/handlers/_companion_hint.py | 65 + plugins/wordpress/handlers/_media_core.py | 407 +++ plugins/wordpress/handlers/_media_optimize.py | 196 ++ plugins/wordpress/handlers/_media_security.py | 240 ++ plugins/wordpress/handlers/ai_media.py | 462 +++ plugins/wordpress/handlers/audit_hook.py | 275 ++ plugins/wordpress/handlers/bulk_meta.py | 179 ++ plugins/wordpress/handlers/cache_purge.py | 109 + plugins/wordpress/handlers/capabilities.py | 221 ++ plugins/wordpress/handlers/export.py | 210 ++ plugins/wordpress/handlers/media.py | 435 ++- plugins/wordpress/handlers/media_attach.py | 397 +++ plugins/wordpress/handlers/media_bulk.py | 250 ++ plugins/wordpress/handlers/media_chunked.py | 322 ++ plugins/wordpress/handlers/media_probe.py | 229 ++ plugins/wordpress/handlers/posts.py | 79 +- plugins/wordpress/handlers/products.py | 19 +- .../handlers/regenerate_thumbnails.py | 220 ++ plugins/wordpress/handlers/seo.py | 29 +- plugins/wordpress/handlers/site_health.py | 101 + plugins/wordpress/handlers/transient_flush.py | 184 ++ plugins/wordpress/plugin.py | 183 +- pyproject.toml | 4 +- requirements.txt | 4 + server.py | 66 + tests/plugins/wordpress/test_ai_media.py | 387 +++ .../wordpress/test_ai_media_idempotency.py | 188 ++ tests/plugins/wordpress/test_audit_hook.py | 224 ++ tests/plugins/wordpress/test_bulk_meta.py | 197 ++ tests/plugins/wordpress/test_cache_purge.py | 128 + tests/plugins/wordpress/test_capabilities.py | 239 ++ .../wordpress/test_client_fast_fail.py | 173 + .../wordpress/test_companion_install_hint.py | 158 + tests/plugins/wordpress/test_export.py | 263 ++ .../plugins/wordpress/test_get_post_fields.py | 153 + tests/plugins/wordpress/test_media_attach.py | 217 ++ tests/plugins/wordpress/test_media_audit.py | 161 + tests/plugins/wordpress/test_media_bulk.py | 216 ++ tests/plugins/wordpress/test_media_chunked.py | 264 ++ .../wordpress/test_media_chunked_status.py | 129 + .../wordpress/test_media_companion_upload.py | 235 ++ .../wordpress/test_media_error_taxonomy.py | 120 + .../plugins/wordpress/test_media_optimize.py | 180 ++ .../wordpress/test_media_post_parent.py | 96 + tests/plugins/wordpress/test_media_probe.py | 177 + tests/plugins/wordpress/test_media_upload.py | 362 +++ .../test_openrouter_model_discovery.py | 192 ++ .../wordpress/test_openrouter_pricing.py | 157 + .../wordpress/test_openrouter_provider.py | 314 ++ .../wordpress/test_regenerate_thumbnails.py | 222 ++ tests/plugins/wordpress/test_seo_roundtrip.py | 122 + tests/plugins/wordpress/test_site_health.py | 176 + .../plugins/wordpress/test_tool_rate_limit.py | 98 + .../plugins/wordpress/test_transient_flush.py | 193 ++ .../wordpress/test_upload_and_attach.py | 437 +++ tests/test_api_keys_bcrypt_upgrade.py | 200 ++ tests/test_capability_badge_integration.py | 249 ++ tests/test_capability_badge_partial.py | 86 + tests/test_capability_probe.py | 367 +++ tests/test_capability_probe_adapters.py | 235 ++ tests/test_capability_probe_read_tier.py | 123 + tests/test_capability_tier_fit.py | 232 ++ tests/test_companion_audit.py | 404 +++ tests/test_dashboard_ux_hints.py | 104 + tests/test_database.py | 97 + tests/test_gitea_f17_ergonomics.py | 307 ++ tests/test_gitea_plugin.py | 9 +- tests/test_site_provider_keys.py | 249 ++ tests/test_tool_access_visibility.py | 117 + tests/test_user_endpoints_tool_filter.py | 108 + tests/test_woocommerce_plugin.py | 11 +- wordpress-plugin/airano-mcp-bridge.zip | Bin 0 -> 33515 bytes wordpress-plugin/airano-mcp-bridge/README.md | 193 ++ .../airano-mcp-bridge/airano-mcp-bridge.php | 2861 +++++++++++++++++ wordpress-plugin/airano-mcp-bridge/readme.txt | 180 ++ wordpress-plugin/airano-mcp-seo-bridge.zip | Bin 9338 -> 0 bytes .../airano-mcp-seo-bridge/README.md | 309 -- .../airano-mcp-seo-bridge/readme.txt | 82 - .../airano-mcp-seo-bridge/seo-api-bridge.php | 715 ---- 140 files changed, 23802 insertions(+), 2253 deletions(-) create mode 100644 Dockerfile.mirror create mode 100644 Dockerfile.slim create mode 100644 core/capability_probe.py create mode 100644 core/companion_audit.py create mode 100644 core/media_audit.py create mode 100644 core/media_error_codes.py create mode 100644 core/templates/dashboard/sites/_capability_badge.html create mode 100644 core/tool_rate_limiter.py create mode 100644 core/upload_sessions.py create mode 100644 docker-compose.coolify.mirror.yaml create mode 100644 docker-compose.coolify.slim.yaml create mode 100644 docs/media-error-codes.md delete mode 100644 docs/prompts/F16-gitea-public-release.md delete mode 100644 docs/prompts/next-session-coolify-f17.md delete mode 100644 docs/prompts/next-session-coolify-test.md delete mode 100644 docs/prompts/next-session-f17-phase3.md delete mode 100644 docs/prompts/next-session-f7-tool-access.md delete mode 100644 docs/prompts/next-session-f7b-ui.md delete mode 100644 docs/prompts/next-session.md create mode 100644 plugins/ai_image/__init__.py create mode 100644 plugins/ai_image/providers/__init__.py create mode 100644 plugins/ai_image/providers/base.py create mode 100644 plugins/ai_image/providers/openai.py create mode 100644 plugins/ai_image/providers/openrouter.py create mode 100644 plugins/ai_image/providers/replicate.py create mode 100644 plugins/ai_image/providers/stability.py create mode 100644 plugins/ai_image/registry.py create mode 100644 plugins/wordpress/handlers/_companion_hint.py create mode 100644 plugins/wordpress/handlers/_media_core.py create mode 100644 plugins/wordpress/handlers/_media_optimize.py create mode 100644 plugins/wordpress/handlers/_media_security.py create mode 100644 plugins/wordpress/handlers/ai_media.py create mode 100644 plugins/wordpress/handlers/audit_hook.py create mode 100644 plugins/wordpress/handlers/bulk_meta.py create mode 100644 plugins/wordpress/handlers/cache_purge.py create mode 100644 plugins/wordpress/handlers/capabilities.py create mode 100644 plugins/wordpress/handlers/export.py create mode 100644 plugins/wordpress/handlers/media_attach.py create mode 100644 plugins/wordpress/handlers/media_bulk.py create mode 100644 plugins/wordpress/handlers/media_chunked.py create mode 100644 plugins/wordpress/handlers/media_probe.py create mode 100644 plugins/wordpress/handlers/regenerate_thumbnails.py create mode 100644 plugins/wordpress/handlers/site_health.py create mode 100644 plugins/wordpress/handlers/transient_flush.py create mode 100644 tests/plugins/wordpress/test_ai_media.py create mode 100644 tests/plugins/wordpress/test_ai_media_idempotency.py create mode 100644 tests/plugins/wordpress/test_audit_hook.py create mode 100644 tests/plugins/wordpress/test_bulk_meta.py create mode 100644 tests/plugins/wordpress/test_cache_purge.py create mode 100644 tests/plugins/wordpress/test_capabilities.py create mode 100644 tests/plugins/wordpress/test_client_fast_fail.py create mode 100644 tests/plugins/wordpress/test_companion_install_hint.py create mode 100644 tests/plugins/wordpress/test_export.py create mode 100644 tests/plugins/wordpress/test_get_post_fields.py create mode 100644 tests/plugins/wordpress/test_media_attach.py create mode 100644 tests/plugins/wordpress/test_media_audit.py create mode 100644 tests/plugins/wordpress/test_media_bulk.py create mode 100644 tests/plugins/wordpress/test_media_chunked.py create mode 100644 tests/plugins/wordpress/test_media_chunked_status.py create mode 100644 tests/plugins/wordpress/test_media_companion_upload.py create mode 100644 tests/plugins/wordpress/test_media_error_taxonomy.py create mode 100644 tests/plugins/wordpress/test_media_optimize.py create mode 100644 tests/plugins/wordpress/test_media_post_parent.py create mode 100644 tests/plugins/wordpress/test_media_probe.py create mode 100644 tests/plugins/wordpress/test_media_upload.py create mode 100644 tests/plugins/wordpress/test_openrouter_model_discovery.py create mode 100644 tests/plugins/wordpress/test_openrouter_pricing.py create mode 100644 tests/plugins/wordpress/test_openrouter_provider.py create mode 100644 tests/plugins/wordpress/test_regenerate_thumbnails.py create mode 100644 tests/plugins/wordpress/test_seo_roundtrip.py create mode 100644 tests/plugins/wordpress/test_site_health.py create mode 100644 tests/plugins/wordpress/test_tool_rate_limit.py create mode 100644 tests/plugins/wordpress/test_transient_flush.py create mode 100644 tests/plugins/wordpress/test_upload_and_attach.py create mode 100644 tests/test_api_keys_bcrypt_upgrade.py create mode 100644 tests/test_capability_badge_integration.py create mode 100644 tests/test_capability_badge_partial.py create mode 100644 tests/test_capability_probe.py create mode 100644 tests/test_capability_probe_adapters.py create mode 100644 tests/test_capability_probe_read_tier.py create mode 100644 tests/test_capability_tier_fit.py create mode 100644 tests/test_companion_audit.py create mode 100644 tests/test_dashboard_ux_hints.py create mode 100644 tests/test_gitea_f17_ergonomics.py create mode 100644 tests/test_site_provider_keys.py create mode 100644 tests/test_tool_access_visibility.py create mode 100644 tests/test_user_endpoints_tool_filter.py create mode 100644 wordpress-plugin/airano-mcp-bridge.zip create mode 100644 wordpress-plugin/airano-mcp-bridge/README.md create mode 100644 wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php create mode 100644 wordpress-plugin/airano-mcp-bridge/readme.txt delete mode 100644 wordpress-plugin/airano-mcp-seo-bridge.zip delete mode 100644 wordpress-plugin/airano-mcp-seo-bridge/README.md delete mode 100644 wordpress-plugin/airano-mcp-seo-bridge/readme.txt delete mode 100644 wordpress-plugin/airano-mcp-seo-bridge/seo-api-bridge.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e809aa3..d3bcf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ All notable changes to MCP Hub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.12.0] — 2026-04-25 + +### Media pipeline, AI image generation, capability probe, companion v2.9.0 (Tracks F.5a + F.7e + F.18) + +The biggest release since v3.11.0 — three months of incremental work batched into one community drop. Core themes: + +- **WordPress media pipeline**: end-to-end image flow with optimization, AI generation, and chunked uploads. +- **Capability discovery**: every site now publishes what it can actually do, and the dashboard reflects it. +- **Companion plugin overhaul**: `airano-mcp-bridge` (renamed from `airano-mcp-seo-bridge`) gained eight new endpoints and a wp.org Plugin Check pass. +- **Reliability polish**: idempotent retries, fast-fail on unreachable sites, install hints in every error. + +#### Added +- **AI image generation** (`F.5a.4` / `F.5a.9`): pluggable provider system (OpenAI, Stability, Replicate, Google Nano Banana, OpenRouter image models) → `wordpress_generate_and_upload_image`, attached as featured media or to WC products in one call. +- **Pillow-based image optimization pipeline** (`F.5a.2`): client-side resize / format conversion / quality knobs before upload. +- **Chunked media upload** (`F.5a.5`): large files uploaded in resumable chunks; `upload_media_chunked_status` lets you resume after a disconnect (`F.5a.8.4`). +- **Bulk media tools** (`F.5a.8.3`): `bulk_delete_media` and `bulk_reassign_media`. +- **Idempotency-Key dedup** for AI media upload retries — same key returns the previous result instead of double-uploading. +- **Companion v2.9.0** (`airano-mcp-bridge`): + - `/upload-and-attach` — single-call combined endpoint (`F.5a.8.5`). + - `/regenerate-thumbnails` (`F.5a.8.2`). + - `/capabilities` + `wordpress_probe_capabilities` (`F.18.1`). + - `/bulk-meta` + `bulk_update_meta` (`F.18.2`). + - `/export` + `export_content` (`F.18.3`). + - `/cache-purge` + `cache_purge` (`F.18.4`). + - `/transient-flush` + `transient_flush` (`F.18.5`). + - `/site-health` + `site_health` (`F.18.6`). + - `/audit-hook` + MCPHub webhook receiver (`F.18.7`). +- **Provider Keys dashboard** (`F.18.8`): per-site AI provider key management UI with no-reload UX; AI tool visibility is now gated on per-site provider key presence. +- **Credential capability probe** (`F.7e`): WordPress / WooCommerce / Gitea adapters report what tier of operations the saved credential can actually perform; capability badge UI + HTMX partial re-check on the site manage page. +- **Tier requirements engine**: `TIER_REQUIREMENTS` + `evaluate_tier_fit(granted ∪ roles)` so alias matching unions both sources. +- **Companion install hint** in every companion-unreachable error (`F.7e`). +- **Gitea ergonomics** (`F.17`): batch file ops, tree, search, compare, releases, fork shortcuts. +- **Opportunistic bcrypt upgrade** for legacy SHA-256 admin keys (`F.8`). +- **n8n refactor**: structured errors, capability probe, missing tools backfilled. + +#### Changed +- **`get_post`** returns `slug`, `featured_media`, `featured_media_url` by default. +- **`get_media` / `list_media`** expose `post_parent`. +- **WordPress client** fast-fails on unreachable sites with an `install_hint`. +- **Rank Math** integration uses canonical `rank_math_title` instead of the older `rank_math_seo_title`. +- **Tool prerequisites** surfaced in the dashboard; tool list shows the expected provider/credential per tier. +- **WooCommerce probe** infers capabilities from REST key scope. +- **Default model** + dark background polish across dashboard surfaces. +- **Companion route map** is single-source (`capabilities` and `site_health` share their definitions). + +#### Fixed +- **AI provider gating**: per-site visibility now requires a present provider key; surfaces correctly hide tools when keys are absent. +- **Capability badge re-check** is an HTMX partial swap (no full reload). +- **WC media tools** accept WordPress App Password and global attribute id. +- **`set_featured_image` for WC products**, AI image attach to WC products, provider enum narrowing, WC badge wording. +- **Companion `airano-mcp-bridge` security**: scheme allowlist on the audit-hook endpoint URL; wp.org Plugin Check warnings (i18n + `WP_Filesystem`) cleared. + +#### Deployment +- **PyPI mirror chain** + removed dead hardcoded proxy. +- **Optional `BUILD_HTTP_PROXY`** for restrictive build networks. +- Alpine `apk` swapped to Yandex mirror; **Debian-slim Plan-B** Dockerfile added. +- `mirror.gcr.io` variant for Docker Hub TLS-timeout fallback. +- Gitea mirror documented as fallback deploy source. + +#### Documentation +- README: dropped fixed "633 tools" claims in favour of per-plugin approximations + a note that the count grows with each release. +- CLAUDE.md: total tool count is no longer asserted as a fixed number. + +#### Companion plugin +- **Renamed**: `airano-mcp-seo-bridge` → `airano-mcp-bridge` (the plugin now does much more than SEO). + +--- + ## [3.11.0] — 2026-04-14 ### Plugin-specific access levels, regression tests, UX polish (Track F.7d) diff --git a/CLAUDE.md b/CLAUDE.md index 429fc1a..10343f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 633 tools total. The tool count stays constant regardless of how many sites are configured. +**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify). Total tool count varies with which plugins are enabled and grows as new plugins are added; per-plugin counts are surfaced in the dashboard. The exposed count for a given user/endpoint stays constant regardless of how many sites of the same type are configured. ## Quick Setup @@ -190,7 +190,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud ## Gotchas - Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only. -- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python +- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-bridge) — these are PHP, not Python - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled. - OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login diff --git a/Dockerfile b/Dockerfile index f2854eb..3a36f2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,8 @@ RUN pip install --no-cache-dir --user -r requirements.txt FROM python:3.12-alpine AS production # CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools -RUN apk add --no-cache wget curl docker-cli +# libmagic is required by python-magic (F.5a media upload MIME sniffing) +RUN apk add --no-cache wget curl docker-cli libmagic # Create non-root user for security and grant Docker socket access # Docker group (GID 999) allows access to /var/run/docker.sock diff --git a/Dockerfile.mirror b/Dockerfile.mirror new file mode 100644 index 0000000..60e46e9 --- /dev/null +++ b/Dockerfile.mirror @@ -0,0 +1,123 @@ +# =================================== +# MCP Hub — Dockerfile (mirror + PyPI-mirror-fallback variant) +# =================================== +# Identical to Dockerfile except: +# 1. Base images come from mirror.gcr.io (Docker Hub mirror) — the +# build host doesn't need to reach registry-1.docker.io. +# Alternative Docker registry mirrors reachable from this host +# (swap in by s/mirror.gcr.io/... if gcr also fails): +# - dockerhub.timeweb.cloud/library +# - docker.m.daocloud.io +# - docker.arvancloud.ir +# 2. Alpine apk repos point to mirror.yandex.ru (more reachable than +# dl-cdn.alpinelinux.org from restrictive networks). +# 3. pip install tries pypi.org first, then falls back through Aliyun +# and Tsinghua PyPI mirrors. If an optional BUILD_PROXY ARG is +# provided, it is tried as a final fallback. +# +# Switch back to vanilla Dockerfile once Docker Hub + Alpine CDN + +# pypi.org are all reachable directly from the build host. +# =================================== + +# Stage 1: Build stage +FROM mirror.gcr.io/library/python:3.12-alpine AS builder + +# Optional HTTP proxy (empty by default — only used if set via Coolify +# build-arg). ARG values are not baked into the runtime image. +ARG BUILD_PROXY="" + +# Use Yandex apk mirror — reachable when dl-cdn.alpinelinux.org is not. +RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories + +# Install build dependencies — direct first, proxy fallback if provided. +RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev \ + || ( [ -n "${BUILD_PROXY}" ] \ + && echo "==> direct apk failed; retrying via BUILD_PROXY" \ + && HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \ + apk add --no-cache gcc musl-dev libffi-dev openssl-dev ) + +# Create build directory +WORKDIR /build + +# Install Python dependencies — four-step fallback chain: +# 1. pypi.org (direct) +# 2. Aliyun mirror (mirrors.aliyun.com) +# 3. Tsinghua mirror (pypi.tuna.tsinghua.edu.cn) +# 4. Optional BUILD_PROXY (only tried if the ARG is non-empty) +COPY requirements.txt . +RUN pip install --no-cache-dir --user --retries 1 --timeout 15 -r requirements.txt \ + || ( echo "==> pypi.org failed; trying Aliyun mirror" \ + && pip install --no-cache-dir --user --retries 1 --timeout 15 \ + -i https://mirrors.aliyun.com/pypi/simple/ \ + --trusted-host mirrors.aliyun.com \ + -r requirements.txt ) \ + || ( echo "==> Aliyun failed; trying Tsinghua mirror" \ + && pip install --no-cache-dir --user --retries 1 --timeout 20 \ + -i https://pypi.tuna.tsinghua.edu.cn/simple/ \ + --trusted-host pypi.tuna.tsinghua.edu.cn \ + -r requirements.txt ) \ + || ( [ -n "${BUILD_PROXY}" ] \ + && echo "==> Tsinghua failed; retrying via BUILD_PROXY" \ + && pip install --no-cache-dir --user \ + --proxy "${BUILD_PROXY}" -r requirements.txt ) + + +# Stage 2: Production stage +FROM mirror.gcr.io/library/python:3.12-alpine AS production + +# Re-declare proxy ARG (ARGs don't cross stage boundaries). +ARG BUILD_PROXY="" + +# Same Yandex apk mirror swap as the builder stage. +RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories + +# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools +# libmagic is required by python-magic (F.5a media upload MIME sniffing) +RUN apk add --no-cache wget curl docker-cli libmagic \ + || ( [ -n "${BUILD_PROXY}" ] \ + && echo "==> direct apk failed; retrying via BUILD_PROXY" \ + && HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \ + apk add --no-cache wget curl docker-cli libmagic ) + +# Create non-root user for security and grant Docker socket access +# Docker group (GID 999) allows access to /var/run/docker.sock +RUN addgroup -g 1001 appgroup && \ + adduser -u 1001 -G appgroup -s /bin/sh -D appuser && \ + addgroup -g 999 docker 2>/dev/null || true && \ + adduser appuser docker 2>/dev/null || true + +# Set working directory +WORKDIR /app + +# Copy Python packages from builder +COPY --from=builder /root/.local /home/appuser/.local + +# Copy application code +COPY --chown=appuser:appgroup . . + +# Create data directories for API keys and logs with correct ownership +# This must be done before switching to non-root user +RUN mkdir -p /app/data /app/logs && \ + chown -R appuser:appgroup /app/data /app/logs && \ + chmod 755 /app/data /app/logs + +# Make server.py executable +RUN chmod +x server.py + +# Switch to non-root user +USER appuser + +# Add local packages to PATH +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 + +# CRITICAL: EXPOSE port for Coolify +EXPOSE 8000 + +# CRITICAL: Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1 + +# CRITICAL: Listen on 0.0.0.0 (not localhost!) +# Run server with streamable-http transport on port 8000 +CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"] diff --git a/Dockerfile.slim b/Dockerfile.slim new file mode 100644 index 0000000..9c9eaa9 --- /dev/null +++ b/Dockerfile.slim @@ -0,0 +1,113 @@ +# =================================== +# MCP Hub — Dockerfile (Debian slim, restrictive-network variant) +# =================================== +# Plan B fallback when both registry-1.docker.io AND +# dl-cdn.alpinelinux.org are unreachable from the build host. +# +# Switches the base from python:3.12-alpine (musl + apk) to +# python:3.12-slim-bookworm (Debian + apt). Debian's package mirrors +# are CDN-fronted (Cloudflare/Fastly) and are usually reachable from +# networks that block Alpine's CDN. Image is pulled from mirror.gcr.io +# to also bypass Docker Hub TLS issues. +# +# Tradeoffs vs Dockerfile.mirror (Alpine): +# - Image size: ~120 MB (slim) vs ~60 MB (alpine) — acceptable +# - Compatibility: ALL Python wheels work (no musl rebuild needed) +# - Security baseline: equivalent (slim is minimal Debian, no shell extras) +# +# Switch back to Dockerfile.mirror once dl-cdn.alpinelinux.org reachable +# OR back to Dockerfile once registry-1.docker.io is reachable. +# =================================== + +# Stage 1: Build stage +FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS builder + +# Optional HTTP proxy for restricted networks. ARG values are NOT baked +# into the final image, so the runtime container never carries the proxy. +ARG BUILD_HTTP_PROXY="" +ARG BUILD_HTTPS_PROXY="" +ARG BUILD_NO_PROXY="" + +# Install build dependencies via apt (proxy honoured if BUILD_HTTP_PROXY set) +RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \ + HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \ + NO_PROXY="${BUILD_NO_PROXY}" \ + && apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libffi-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create build directory +WORKDIR /build + +# Copy requirements and install Python dependencies (proxy honoured) +COPY requirements.txt . +RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \ + HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \ + NO_PROXY="${BUILD_NO_PROXY}" \ + && pip install --no-cache-dir --user -r requirements.txt + + +# Stage 2: Production stage +FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS production + +# Re-declare proxy ARGs in this stage (ARGs don't cross stage boundaries). +ARG BUILD_HTTP_PROXY="" +ARG BUILD_HTTPS_PROXY="" +ARG BUILD_NO_PROXY="" + +# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools +# libmagic1 is required by python-magic (F.5a media upload MIME sniffing) +RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \ + HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \ + NO_PROXY="${BUILD_NO_PROXY}" \ + && apt-get update && apt-get install -y --no-install-recommends \ + wget \ + curl \ + docker.io \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security and grant Docker socket access +# Docker group (GID 999) allows access to /var/run/docker.sock +RUN groupadd -g 1001 appgroup && \ + useradd -u 1001 -g appgroup -s /bin/sh -m appuser && \ + (groupadd -g 999 docker 2>/dev/null || true) && \ + (usermod -aG docker appuser 2>/dev/null || true) + +# Set working directory +WORKDIR /app + +# Copy Python packages from builder +COPY --from=builder /root/.local /home/appuser/.local + +# Copy application code +COPY --chown=appuser:appgroup . . + +# Create data directories for API keys and logs with correct ownership +# This must be done before switching to non-root user +RUN mkdir -p /app/data /app/logs && \ + chown -R appuser:appgroup /app/data /app/logs && \ + chmod 755 /app/data /app/logs + +# Make server.py executable +RUN chmod +x server.py + +# Switch to non-root user +USER appuser + +# Add local packages to PATH +ENV PATH=/home/appuser/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 + +# CRITICAL: EXPOSE port for Coolify +EXPOSE 8000 + +# CRITICAL: Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1 + +# CRITICAL: Listen on 0.0.0.0 (not localhost!) +# Run server with streamable-http transport on port 8000 +CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"] diff --git a/README.md b/README.md index 21c3df8..65c1dde 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/) [![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/) [![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub) -[![Tests: 828 passing](https://img.shields.io/badge/tests-828%20passing-brightgreen.svg)]() -[![Tools: 633](https://img.shields.io/badge/tools-633-orange.svg)]() +[![Plugins: 10](https://img.shields.io/badge/plugins-10-orange.svg)]() [![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml) @@ -25,7 +24,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now. -MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do: +MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do: > *"Update the SEO meta description for all WooCommerce products that don't have one"* > @@ -41,7 +40,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and | AI agent integration | No | No | No | **Native (MCP)** | | Full WordPress API | Dashboard | Dashboard | Content only | **67 tools** | | WooCommerce management | No | Limited | No | **28 tools** | -| Git/CI management | No | No | No | **56 tools (Gitea)** | +| Git/CI management | No | No | No | **65 tools (Gitea)** | | Automation workflows | No | No | No | **56 tools (n8n)** | | Self-hosted | No | Yes | N/A | **Yes** | | Open source | No | Core only | Varies | **Fully open** | @@ -49,22 +48,28 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and --- -## 633 Tools Across 10 Plugins +## 10 Plugins, Hundreds of Tools -| Plugin | Tools | What You Can Do | -|--------|-------|-----------------| -| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) | -| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping | -| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management | -| **Gitea** | 58 | Repos, issues, pull requests, releases, webhooks, organizations, labels | -| **n8n** | 56 | Workflows, executions, credentials, variables, audit | -| **Supabase** | 70 | Database, auth, storage, edge functions, realtime | -| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system | -| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging | -| **Directus** | 100 | Collections, items, users, files, flows, permissions | -| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services | -| **System** | 23 | Health monitoring, API keys, OAuth management, audit | -| **Total** | **633** | Constant count — scales to unlimited sites | +The exact tool count grows as new plugins ship and existing ones gain endpoints. +What you actually expose is controlled by your `ENABLED_PLUGINS` setting and per-key +scope — pick a plugin-specific endpoint to keep the surface area small. + +| Plugin | Approx. Tools | What You Can Do | +|--------|---------------:|-----------------| +| **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) | +| **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping | +| **WordPress Advanced** | ~20 | Database ops, bulk operations, WP-CLI, system management | +| **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare | +| **n8n** | ~55 | Workflows, executions, credentials, variables, audit | +| **Supabase** | ~70 | Database, auth, storage, edge functions, realtime | +| **OpenPanel** | ~40 | Events, export, insights, profiles, projects, system | +| **Appwrite** | ~100 | Databases, auth, storage, functions, teams, messaging | +| **Directus** | ~100 | Collections, items, users, files, flows, permissions | +| **Coolify** | ~65 | Applications, deployments, servers, projects, databases, services | +| **System** | ~25 | Health monitoring, API keys, OAuth management, audit | + +> Per-site duplication does **not** inflate the tool count — adding a second +> WordPress site reuses the same WordPress tools with a different `site` argument. --- @@ -266,30 +271,32 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au ## Architecture ``` -/mcp → Admin endpoint (all 633 tools) -/system/mcp → System tools only (23 tools) -/wordpress/mcp → WordPress tools (67 tools) -/woocommerce/mcp → WooCommerce tools (28 tools) -/wordpress-advanced/mcp → WordPress Advanced tools (22 tools) -/gitea/mcp → Gitea tools (58 tools) -/n8n/mcp → n8n tools (56 tools) -/supabase/mcp → Supabase tools (70 tools) -/openpanel/mcp → OpenPanel tools (42 tools) -/appwrite/mcp → Appwrite tools (100 tools) -/directus/mcp → Directus tools (100 tools) -/coolify/mcp → Coolify tools (67 tools) +/mcp → Admin endpoint (every enabled tool) +/system/mcp → System tools only +/wordpress/mcp → WordPress tools +/woocommerce/mcp → WooCommerce tools +/wordpress-advanced/mcp → WordPress Advanced tools +/gitea/mcp → Gitea tools +/n8n/mcp → n8n tools +/supabase/mcp → Supabase tools +/openpanel/mcp → OpenPanel tools +/appwrite/mcp → Appwrite tools +/directus/mcp → Directus tools +/coolify/mcp → Coolify tools /project/{alias}/mcp → Per-project endpoint (auto-injects site) /u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users) ``` -**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage. +**Recommendation**: Use plugin-specific endpoints instead of the all-tools `/mcp` +admin endpoint to keep your AI client's tool window small (and your token bill +lower). -| Endpoint | Use Case | Tools | -|----------|----------|------:| -| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 | -| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 | -| `/{plugin}/mcp` | Multi-site management | 23-101 | -| `/mcp` | Admin & discovery only | 633 | +| Endpoint | Use Case | +|----------|----------| +| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) — single service | +| `/project/{alias}/mcp` | Single-site workflow (recommended) | +| `/{plugin}/mcp` | Multi-site management for one service | +| `/mcp` | Admin & discovery only — every enabled tool | ### Security @@ -307,7 +314,7 @@ Some MCP Hub tools require companion WordPress plugins: | Tools | Requirement | |-------|-------------| -| SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-bridge/)) + Rank Math or Yoast SEO | +| SEO + capability/audit tools (`wordpress_get_post_seo`, capability probe, audit hook, etc.) | [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) ([GitHub](wordpress-plugin/airano-mcp-bridge/)) + Rank Math or Yoast SEO | | WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config | | WordPress Advanced database/system tools | Docker socket + `CONTAINER` config | | OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) | @@ -345,7 +352,7 @@ Set the `container` field when adding a WordPress site in the dashboard. Without # Install with dev dependencies pip install -e ".[dev]" -# Run tests (481 tests) +# Run tests pytest # Format and lint diff --git a/core/api_keys.py b/core/api_keys.py index 62abd6a..578c46c 100644 --- a/core/api_keys.py +++ b/core/api_keys.py @@ -6,6 +6,7 @@ and audit trail. """ import hashlib +import hmac import json import logging import os @@ -185,13 +186,60 @@ class APIKeyManager: """Hash API key for storage using bcrypt.""" return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode() + @staticmethod + def _is_bcrypt_hash(key_hash: str) -> bool: + """Return True if the stored hash is in bcrypt format ($2a/$2b/$2y).""" + return key_hash.startswith("$2") + def _verify_key(self, api_key: str, key_hash: str) -> bool: - """Verify API key against stored hash (supports bcrypt and legacy SHA-256).""" - if key_hash.startswith("$2"): - # bcrypt hash - return bcrypt.checkpw(api_key.encode(), key_hash.encode()) - # Legacy SHA-256 fallback - return hashlib.sha256(api_key.encode()).hexdigest() == key_hash + """Verify API key against stored hash. + + Supports both the current bcrypt format and a legacy SHA-256 + fallback so pre-bcrypt keys keep working. F.8 security- + hardening note: SHA-256 is a fast hash with no per-hash salt — + a stolen keys.json file would enable offline brute-force on + any legacy entry. We therefore: + + 1. accept legacy hashes here (so customers aren't locked out), and + 2. opportunistically upgrade every legacy hash to bcrypt the + first time it's successfully verified (see + :meth:`_upgrade_legacy_hash`). + + Brand-new keys created via :meth:`create_key` are always + bcrypt-hashed — legacy SHA-256 only appears for rows that + existed before the F.4 / F.8 hardening passes. + """ + if self._is_bcrypt_hash(key_hash): + try: + return bcrypt.checkpw(api_key.encode(), key_hash.encode()) + except ValueError: + # Truncated / corrupt bcrypt hash — treat as mismatch + # rather than raising. + return False + # Legacy SHA-256 fallback (constant-time compare to avoid + # timing oracles on the legacy-hash path). + expected = hashlib.sha256(api_key.encode()).hexdigest() + return hmac.compare_digest(expected, key_hash) + + def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool: + """Re-hash a legacy SHA-256 entry with bcrypt and persist. + + Called from the verify paths the moment a legacy hash is + successfully matched, so the next verify uses bcrypt. Returns + True when an upgrade happened, False otherwise. Errors during + persist are logged but swallowed — the key still validates, + we just try again next time. + """ + if self._is_bcrypt_hash(key.key_hash): + return False + try: + key.key_hash = self._hash_key(api_key) + self._save_keys() + logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id) + return True + except Exception as exc: # pragma: no cover — defensive + logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc) + return False def create_key( self, @@ -283,6 +331,9 @@ class APIKeyManager: for key_id, key in self.keys.items(): if not self._verify_key(api_key, key.key_hash): continue + # F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt + # the moment they validate successfully. + self._upgrade_legacy_hash(key, api_key) # Check if valid (not revoked, not expired) if not key.is_valid(): @@ -351,6 +402,8 @@ class APIKeyManager: """ for key_id, key in self.keys.items(): if self._verify_key(api_key, key.key_hash): + # F.8: upgrade legacy SHA-256 hashes on first successful match. + self._upgrade_legacy_hash(key, api_key) logger.debug(f"Found API key {key_id} by token") return key diff --git a/core/capability_probe.py b/core/capability_probe.py new file mode 100644 index 0000000..fba2699 --- /dev/null +++ b/core/capability_probe.py @@ -0,0 +1,485 @@ +"""F.7e — per-site credential capability probe. + +Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows +how to ask its upstream service what the saved credential can actually +do. This module wraps those calls with: + +* Per-site in-memory TTL cache (default 10 min) so the probe doesn't + hammer the upstream service on every dashboard page view. +* A thin wrapper that decrypts the site's credentials, instantiates the + plugin, calls the probe, and normalises the result. +* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard + UI consumes (wired in ``core/dashboard/routes.py``). + +The cache is process-local and not persisted. Workers start cold and +populate on demand; invalidation happens on cache expiry or explicit +site-credential update. +""" + +from __future__ import annotations + +import logging +import os +import time +from typing import Any + +from starlette.requests import Request +from starlette.responses import JSONResponse + +logger = logging.getLogger("mcphub.capability_probe") + +# Cache TTL in seconds (default 10 min). Upstream capability rarely +# changes — most drift happens when the operator rotates an +# app_password or changes a Gitea token's scopes, both of which are +# infrequent. +_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600")) + + +class _ProbeCache: + """Trivial in-memory ``site_id -> (expires_at, payload)`` cache.""" + + def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._entries: dict[str, tuple[float, dict[str, Any]]] = {} + + def get(self, site_id: str) -> dict[str, Any] | None: + entry = self._entries.get(site_id) + if entry is None: + return None + expires_at, payload = entry + if expires_at < time.time(): + self._entries.pop(site_id, None) + return None + return payload + + def set(self, site_id: str, payload: dict[str, Any]) -> None: + self._entries[site_id] = (time.time() + self._ttl, payload) + + def invalidate(self, site_id: str) -> bool: + return self._entries.pop(site_id, None) is not None + + +_cache = _ProbeCache() + + +def get_probe_cache() -> _ProbeCache: + return _cache + + +# --------------------------------------------------------------------------- +# F.7e — tier-fit evaluation: compare probe.granted with what the site's +# selected ``tool_scope`` actually needs. +# --------------------------------------------------------------------------- + + +# Capability names required for each (plugin_type, tier) pair. Tiers come +# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1 +# onto these names (e.g. Gitea scopes use ``read:repository`` rather than +# ``read``) the fit evaluator's ``_aliased_granted`` helper below +# normalises both sides before comparing. +TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = { + "wordpress": { + "read": {"read"}, + "write": {"edit_posts", "upload_files"}, + # WP-admin-tier tools need the same cap as WP's admin area itself. + "admin": {"manage_options"}, + }, + "woocommerce": { + "read": {"read_products"}, + "write": {"write_products"}, + "admin": {"write_products"}, + }, + "gitea": { + "read": {"read:repository"}, + "write": {"write:repository"}, + "admin": {"admin:repo_hook"}, + }, +} + +# Aliases that plugins may return from their probe which should be +# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS. +# Keeps the adapter implementations honest about what the upstream +# service actually says while letting the evaluator compare sets. +_CAP_ALIASES: dict[str, set[str]] = { + "manage_options": {"administrator", "manage_options"}, + "edit_posts": {"edit_posts", "editor", "administrator"}, + "upload_files": {"upload_files", "editor", "administrator"}, + "read": {"read", "subscriber", "contributor", "author", "editor", "administrator"}, + "read_products": {"read_products", "read", "read_write"}, + "write_products": {"write_products", "write", "read_write"}, + "read_orders": {"read_orders", "read", "read_write"}, + "write_orders": {"write_orders", "write", "read_write"}, +} + + +def _cap_matches(required: str, granted: set[str]) -> bool: + """Return True if ``required`` is satisfied by any cap in ``granted``. + + Uses ``_CAP_ALIASES`` to accept role names (WP) and permission + strings (WC) alongside the canonical capability names. + """ + if required in granted: + return True + aliases = _CAP_ALIASES.get(required, {required}) + return bool(aliases & granted) + + +def evaluate_tier_fit( + plugin_type: str, + tier: str | None, + probe_payload: dict[str, Any], +) -> dict[str, Any]: + """Decide whether ``probe.granted`` covers what ``tier`` requires. + + Args: + plugin_type: e.g. "wordpress", "woocommerce", "gitea". + tier: the site's selected ``tool_scope`` preset (read / write / + admin / custom / None). + probe_payload: the dict returned by ``probe_site_capabilities`` + (must contain ``probe_available`` and ``granted``). + + Returns: + A dict with: + * ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable`` + | ``unknown_tier``. + * ``required``: list[str] of caps the tier needs (empty when + the tier is ``custom`` / not in the table). + * ``missing``: list[str] of required caps not present in + ``granted``. + * ``reason``: passthrough from the probe when probe is + unavailable, else ``None``. + + ``custom`` tier always returns ``status='ok'`` because by definition + the caller picked individual tools — we can't check a tier-level + contract. + """ + tier_norm = (tier or "").strip().lower() + + if not probe_payload.get("probe_available", False): + return { + "status": "probe_unavailable", + "required": [], + "missing": [], + "reason": probe_payload.get("reason"), + } + + if tier_norm in {"", "custom"}: + return { + "status": "ok", + "required": [], + "missing": [], + "reason": None, + } + + requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm) + if requirements is None: + return { + "status": "unknown_tier", + "required": [], + "missing": [], + "reason": f"no_tier_table_for:{plugin_type}/{tier_norm}", + } + + # F.X.fix #5: the probe places WP role names under ``roles`` and + # individual capability strings under ``granted`` — historically we + # only compared against ``granted``, so the ``read`` tier always + # reported ``warning: Missing read`` even for admin users, because + # ``read`` is implied by every role but not emitted as a bare cap + # in the companion's capability map. Union the two sets so the + # alias resolver in ``_cap_matches`` can see roles too. + granted = set(probe_payload.get("granted") or []) + roles = set(probe_payload.get("roles") or []) + effective = granted | roles + missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective)) + + return { + "status": "warning" if missing else "ok", + "required": sorted(requirements), + "missing": missing, + "reason": None, + } + + +async def probe_site_capabilities( + site_id: str, + user_id: str, + *, + force: bool = False, +) -> dict[str, Any]: + """Return the capability probe payload for a user-owned site. + + Uses a 10-minute per-site TTL cache unless ``force=True``. + Response shape: + + { + "site_id": str, + "plugin_type": str, + "probe_available": bool, + "granted": list[str], + "source": str, + "reason": str | None, # only when probe_available=False + "cached": bool, + # + any plugin-specific extras (roles, plugin_version, ...) + } + """ + from core.database import get_database + from core.encryption import get_credential_encryption + from plugins import registry as plugin_registry + + db = get_database() + site = await db.get_site(site_id, user_id) + if site is None: + return { + "site_id": site_id, + "plugin_type": None, + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": "site_not_found", + "cached": False, + } + + if not force: + cached = _cache.get(site_id) + if cached is not None: + out = dict(cached) + out["cached"] = True + return out + + plugin_type = site["plugin_type"] + if not plugin_registry.is_registered(plugin_type): + return { + "site_id": site_id, + "plugin_type": plugin_type, + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": f"plugin_not_registered:{plugin_type}", + "cached": False, + } + + try: + encryptor = get_credential_encryption() + credentials = encryptor.decrypt_credentials(site["credentials"], site_id) + except Exception as exc: # noqa: BLE001 + logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc) + return { + "site_id": site_id, + "plugin_type": plugin_type, + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": "credentials_decrypt_failed", + "cached": False, + } + + config_dict: dict[str, Any] = { + "site_url": site["url"], + "url": site["url"], + "alias": site["alias"], + "user_id": user_id, + "site_id": site_id, + **credentials, + } + + try: + instance = plugin_registry.create_instance( + plugin_type, + project_id=f"probe_{site_id}", + config=config_dict, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc) + return { + "site_id": site_id, + "plugin_type": plugin_type, + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": f"plugin_instantiation_failed: {exc}", + "cached": False, + } + + try: + result = await instance.probe_credential_capabilities() + except Exception as exc: # noqa: BLE001 + logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc) + result = { + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": f"probe_call_failed: {exc}", + } + + payload: dict[str, Any] = { + "site_id": site_id, + "plugin_type": plugin_type, + "probe_available": bool(result.get("probe_available", False)), + "granted": list(result.get("granted") or []), + "source": result.get("source") or "unavailable", + } + if not payload["probe_available"]: + payload["reason"] = result.get("reason") + # F.X.fix #3: propagate install_hint so the dashboard can render + # the "site unreachable / install companion" prompt without a + # second probe call. + # F.X.fix-pass3: also propagate routes + features so the + # tool-prerequisites resolver in core/tool_access can compute + # tool availability without a second probe call. + # F.X.fix-pass5: also propagate wp_credentials_present so the + # prerequisites resolver can auto-disable WC media tools when + # the site has no WP Application Password configured. + for extra in ( + "roles", + "plugin_version", + "install_hint", + "routes", + "features", + "wp_credentials_present", + ): + if extra in result: + payload[extra] = result[extra] + + # F.X.fix-pass2 — surface the site's configured AI-provider set so + # the badge can show a distinct "no AI provider key" warning + # independent of tier fit. Even an Administrator WP credential + # can't run the AI image tool without a provider key. Cheap: one + # SQLite query over site_provider_keys. + try: + from core.site_api import list_site_providers_set + + payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id)) + except Exception as exc: # noqa: BLE001 + logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc) + payload["ai_providers_configured"] = [] + + # Cache everything, including the "probe unavailable" answer — a + # missing companion plugin is a stable fact until the operator + # installs it and re-tests the connection. + _cache.set(site_id, payload) + payload_out = dict(payload) + payload_out["cached"] = False + return payload_out + + +# --------------------------------------------------------------------------- +# Starlette handler: GET /api/sites/{id}/capabilities +# --------------------------------------------------------------------------- + + +async def api_site_capabilities(request: Request) -> JSONResponse: + """Return the capability probe for a user-owned site. + + Auth: same OAuth user session guard as the other site endpoints. + Query params: + * ``force=1`` — bypass the 10-minute cache. + """ + from core.dashboard.routes import _require_user_session + + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + if not site_id: + return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400) + + force = request.query_params.get("force") in {"1", "true", "True"} + + payload = await probe_site_capabilities( + site_id=site_id, user_id=user_session["user_id"], force=force + ) + + if payload.get("reason") == "site_not_found": + return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404) + + # F.7e: also evaluate fit against the site's currently-selected + # tool_scope tier so the UI can render the badge without needing a + # second request. The caller-supplied ?tier=... query param lets the + # dashboard preview a tier before save. + from core.database import get_database + + db = get_database() + site = await db.get_site(site_id, user_session["user_id"]) + site_tier = (site or {}).get("tool_scope") + # Allow the caller to override the tier (e.g. preview before save). + tier_override = request.query_params.get("tier") + tier = tier_override or site_tier + + fit = evaluate_tier_fit( + plugin_type=payload.get("plugin_type") or "", + tier=tier, + probe_payload=payload, + ) + + return JSONResponse( + { + "ok": True, + **payload, + "tier": tier, + "fit": fit, + } + ) + + +async def api_site_capabilities_badge(request: Request): + """F.X.fix #9 — render the capability-badge template fragment. + + Used by the HTMX Re-check button so the badge swaps in place + instead of forcing a full-page reload. Response is HTML (not JSON) + — the caller sets ``hx-swap="outerHTML"`` on the badge element. + """ + from starlette.responses import HTMLResponse + + from core.dashboard.routes import _require_user_session, templates + + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return HTMLResponse("
unauthorized
", status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + if not site_id: + return HTMLResponse("
invalid_request
", status_code=400) + + force = request.query_params.get("force") in {"1", "true", "True"} + + payload = await probe_site_capabilities( + site_id=site_id, user_id=user_session["user_id"], force=force + ) + if payload.get("reason") == "site_not_found": + return HTMLResponse("
site_not_found
", status_code=404) + + from core.database import get_database + + db = get_database() + site = await db.get_site(site_id, user_session["user_id"]) + if site is None: + return HTMLResponse("
site_not_found
", status_code=404) + + tier = request.query_params.get("tier") or site.get("tool_scope") + fit = evaluate_tier_fit( + plugin_type=payload.get("plugin_type") or "", + tier=tier, + probe_payload=payload, + ) + capability_probe = {**payload, "tier": tier, "fit": fit} + + # Pull up the companion download URL the same way the page does. + try: + from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL + + companion_download_url: str | None = COMPANION_DOWNLOAD_URL + except Exception: # noqa: BLE001 + companion_download_url = None + + lang = request.query_params.get("lang") or request.cookies.get("lang") or "en" + return templates.TemplateResponse( + request, + "dashboard/sites/_capability_badge.html", + { + "capability_probe": capability_probe, + "site": site, + "lang": lang, + "companion_download_url": companion_download_url, + }, + ) diff --git a/core/companion_audit.py b/core/companion_audit.py new file mode 100644 index 0000000..8022ba7 --- /dev/null +++ b/core/companion_audit.py @@ -0,0 +1,325 @@ +"""F.18.7 — Companion audit-hook receiver + per-site secret store. + +The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress +action events (post transitions, user events, plugin activations, etc.) +to MCPHub as HMAC-SHA256-signed webhooks. This module provides: + +1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``. +2. ``verify_companion_signature`` — constant-time HMAC verification. +3. ``handle_companion_audit_request`` — Starlette handler that validates + the signature, appends the event to the audit log, and returns 200. + +The store is intentionally independent of the SQLite sites DB so the +webhook can land before a dashboard UI exists; the UI (future work) can +replace ``CompanionAuditSecretStore`` with DB-backed storage without +changing the wire format. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any + +from starlette.requests import Request +from starlette.responses import JSONResponse + +logger = logging.getLogger("mcphub.companion_audit") + + +def _parse_timestamp(raw: Any) -> float | None: + """Coerce the envelope ``timestamp`` field to epoch seconds. + + Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the + companion plugin's current format via PHP ``gmdate``) or a numeric + epoch value (for forward compatibility with other senders). + Returns None on any parse failure so callers can emit a uniform + 401 without leaking which step failed. + """ + if raw is None: + return None + if isinstance(raw, int | float): + return float(raw) + if isinstance(raw, str): + s = raw.strip() + if not s: + return None + # Python 3.11+ fromisoformat handles "Z" suffix; on older + # versions we translate it to +00:00 explicitly. + iso = s.replace("Z", "+00:00") if s.endswith("Z") else s + try: + return datetime.fromisoformat(iso).timestamp() + except ValueError: + pass + # Numeric-string fallback (e.g. "1712345678"). + try: + return float(s) + except ValueError: + return None + return None + + +# Pre-F.20 security sweep: bound incoming payload so a misconfigured +# (or malicious) companion can't queue up unbounded memory with a +# single request. Real audit events are well under 4 KB; 64 KB is a +# generous ceiling that still fits within Starlette's default limit. +_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024))) + +# Replay-protection window (seconds). Events whose ``timestamp`` field +# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401. +# Set to 0 or negative to disable (not recommended). Default 5 minutes +# balances clock-skew tolerance against replay risk. +_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300")) + + +def _normalise_url(url: str) -> str: + return url.rstrip("/").strip().lower() + + +class CompanionAuditSecretStore: + """File-backed ``site_url -> shared_secret`` map. + + The file is JSON; keys are normalised (lowercased, trailing slash + stripped). Access is serialised through a lock to keep reads and + writes atomic on a single-process server. Multi-process deployments + (gunicorn workers) can still race on the write path — that's fine + because the dashboard UI will be the only writer and operates in + the master process for this MVP. + """ + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self._lock = threading.Lock() + self._cache: dict[str, str] | None = None + + def _load(self) -> dict[str, str]: + if self._cache is not None: + return self._cache + if not self.path.exists(): + self._cache = {} + return self._cache + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + logger.warning( + "companion audit secret file %s is not a JSON object; ignoring.", + self.path, + ) + self._cache = {} + else: + self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Could not read companion audit secret file %s: %s", self.path, exc) + self._cache = {} + return self._cache + + def _save(self, data: dict[str, str]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(self.path) + try: + os.chmod(self.path, 0o600) + except OSError: + pass + self._cache = dict(data) + + def get(self, site_url: str) -> str | None: + with self._lock: + return self._load().get(_normalise_url(site_url)) + + def set(self, site_url: str, secret: str) -> None: + if not secret or len(secret) < 16: + raise ValueError("companion audit secret must be at least 16 characters") + with self._lock: + data = dict(self._load()) + data[_normalise_url(site_url)] = secret + self._save(data) + + def delete(self, site_url: str) -> bool: + with self._lock: + data = dict(self._load()) + key = _normalise_url(site_url) + if key not in data: + return False + del data[key] + self._save(data) + return True + + def list_sites(self) -> list[dict[str, Any]]: + """List sites with secret metadata only — never returns plaintext.""" + with self._lock: + return [ + { + "site_url": site_url, + "secret_set": True, + "secret_last4": secret[-4:] if len(secret) >= 4 else "", + } + for site_url, secret in self._load().items() + ] + + +_DEFAULT_STORE_PATH = Path( + os.environ.get( + "COMPANION_AUDIT_SECRETS_PATH", + "/tmp/mcphub-data/companion-audit-secrets.json", + ) +) +_default_store: CompanionAuditSecretStore | None = None + + +def get_companion_audit_store( + path: str | Path | None = None, +) -> CompanionAuditSecretStore: + global _default_store + if path is not None: + return CompanionAuditSecretStore(path) + if _default_store is None: + _default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH) + return _default_store + + +def verify_companion_signature( + body_bytes: bytes, signature_header: str | None, secret: str +) -> bool: + """Constant-time HMAC-SHA256 verification. + + Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant. + Returns False on any shape error so callers can emit 401 uniformly. + """ + if not signature_header or not secret: + return False + sig = signature_header.strip() + if sig.startswith("sha256="): + sig = sig[len("sha256=") :] + if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig): + return False + expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected.lower(), sig.lower()) + + +# Accepted event names — mirror the PHP side. Events outside this list +# are still accepted but tagged so operators can spot rogue sources. +_KNOWN_EVENTS = frozenset( + { + "transition_post_status", + "deleted_post", + "user_register", + "profile_update", + "deleted_user", + "activated_plugin", + "deactivated_plugin", + "switch_theme", + } +) + + +async def handle_companion_audit_request(request: Request) -> JSONResponse: + """Starlette handler for ``POST /api/companion-audit``. + + Validates the HMAC signature using a per-site secret, parses the + envelope, applies a replay window on the ``timestamp`` field, and + writes an audit-log entry. Returns 200 on success, 400 on shape + errors, 401 on signature/replay failure, 413 on oversized body. + """ + # Pre-F.20 security sweep: bound the incoming body before reading the + # whole thing. Prefer the framework's Content-Length hint; fall back + # to reading up to one byte past the ceiling so we can cleanly 413. + content_length_header = request.headers.get("content-length") + if content_length_header: + try: + content_length = int(content_length_header) + except ValueError: + return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400) + if content_length > _MAX_BODY_BYTES: + return JSONResponse( + {"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES}, + status_code=413, + ) + + # Read raw body — we need the exact bytes for HMAC verification. + body_bytes = await request.body() + if len(body_bytes) > _MAX_BODY_BYTES: + return JSONResponse( + {"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES}, + status_code=413, + ) + if not body_bytes: + return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400) + + site_header = request.headers.get("X-Airano-MCP-Site") or "" + signature = request.headers.get("X-Airano-MCP-Signature") + + try: + envelope = json.loads(body_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400) + + if not isinstance(envelope, dict): + return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400) + + site_url = str(envelope.get("site_url") or site_header or "") + if not site_url: + return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400) + + store = get_companion_audit_store() + secret = store.get(site_url) + if secret is None: + # Don't leak whether the site exists; same response as a bad sig. + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + if not verify_companion_signature(body_bytes, signature, secret): + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + # Pre-F.20 security sweep: enforce a replay window on the signed + # timestamp. Captured webhooks replayed outside the window are + # rejected with 401 (same opaque response as a bad signature to + # avoid giving attackers a distinguishing oracle). + if _REPLAY_WINDOW_SECONDS > 0: + ts = _parse_timestamp(envelope.get("timestamp")) + if ts is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + now = time.time() + if abs(now - ts) > _REPLAY_WINDOW_SECONDS: + logger.warning( + "companion audit replay rejected site=%s skew=%.1fs", + site_url, + now - ts, + ) + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + event_name = str(envelope.get("event") or "unknown") + details = { + "site_url": site_url, + "event": event_name, + "known_event": event_name in _KNOWN_EVENTS, + "timestamp": envelope.get("timestamp"), + "wp_user_id": envelope.get("user_id"), + "plugin_version": envelope.get("plugin_version"), + "data": envelope.get("data"), + } + + try: + # Deferred import — avoid pulling audit_log into the store for tests. + from core.audit_log import get_audit_logger + + audit_logger = get_audit_logger() + audit_logger.log_system_event( + event=f"companion_audit:{event_name}", + details=details, + ) + except Exception as exc: # noqa: BLE001 + logger.error("failed to append companion audit event: %s", exc) + return JSONResponse( + {"ok": False, "error": "audit_write_failed"}, + status_code=500, + ) + + return JSONResponse({"ok": True, "event": event_name}, status_code=200) diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index f4f2104..ac25243 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -7,6 +7,7 @@ Phase K.1: Core Infrastructure import logging import os from datetime import UTC, datetime +from typing import Any from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response @@ -2927,7 +2928,14 @@ async def dashboard_sites_view(request: Request) -> Response: import json from core.config_snippets import get_supported_clients - from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site + from core.site_api import ( + SITE_PROVIDERS, + get_user_credential_fields, + get_user_plugin_names, + get_user_site, + list_site_providers_set, + site_supports_provider_keys, + ) from core.tool_access import get_scope_presets_for_plugin site = await get_user_site(site_id, user_session["user_id"]) @@ -2946,6 +2954,97 @@ async def dashboard_sites_view(request: Request) -> Response: plugin_names = get_user_plugin_names() scope_presets = get_scope_presets_for_plugin(site["plugin_type"]) + # F.5a.9.x: per-site AI provider keys — only surface the section on + # WordPress/WooCommerce sites (the only consumers of + # wordpress_generate_and_upload_image). + provider_keys_supported = site_supports_provider_keys(site["plugin_type"]) + provider_key_rows: list[dict[str, Any]] = [] + if provider_keys_supported: + configured = await list_site_providers_set(site_id) + # F.X.fix-pass3: pull each row's default_model so the UI can + # render a "Set default" pill that's pre-highlighted on the + # current selection. One query, all rows. + default_models: dict[str, str | None] = {} + try: + from core.database import get_database + + db = get_database() + rows = await db.list_site_provider_keys(site_id) + for r in rows: + default_models[r["provider"]] = r.get("default_model") + except Exception as exc: # noqa: BLE001 + logger.debug("default_model lookup skipped site=%s: %s", site_id, exc) + _provider_labels = { + "openai": "OpenAI", + "stability": "Stability AI", + "replicate": "Replicate", + "openrouter": "OpenRouter (Gemini / multi-model)", + } + for provider in SITE_PROVIDERS: + provider_key_rows.append( + { + "provider": provider, + "label": _provider_labels.get(provider, provider.title()), + "status": "set" if provider in configured else "unset", + "default_model": default_models.get(provider), + } + ) + + # F.20 prep: companion-plugin download hint, shown on WP/WC site + # pages only. See dashboard_service_page for the matching hint on + # the services catalogue. + companion_download_url = None + if site["plugin_type"] in {"wordpress", "woocommerce"}: + companion_download_url = ( + "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip" + ) + + # F.7e: run the capability probe server-side so the manage page can + # render the badge on first paint without a second round-trip. Any + # failure falls through to "probe unavailable" so the page always + # loads. The frontend Re-check button hits the same endpoint with + # force=1 to bypass the 10-min cache. + capability_probe = None + try: + from core.capability_probe import evaluate_tier_fit, probe_site_capabilities + + probe_payload = await probe_site_capabilities( + site_id=site_id, user_id=user_session["user_id"] + ) + fit = evaluate_tier_fit( + plugin_type=site["plugin_type"], + tier=site.get("tool_scope"), + probe_payload=probe_payload, + ) + capability_probe = {**probe_payload, "fit": fit} + except Exception as exc: # noqa: BLE001 + logger.warning("capability probe render failed for %s: %s", site_id, exc) + capability_probe = { + "probe_available": False, + "granted": [], + "source": "unavailable", + "reason": f"render_error: {exc}", + "fit": {"status": "probe_unavailable", "required": [], "missing": []}, + } + + # F.X.fix-pass5 — surface which credential fields are *currently + # stored* (not the values themselves) so the form can show a + # "✓ Stored" badge and require an explicit "Clear" action to + # remove an existing value, instead of silently wiping it on a + # blank-input save. + cred_states: dict[str, bool] = {} + try: + from core.encryption import get_credential_encryption + + encryptor = get_credential_encryption() + decrypted = encryptor.decrypt_credentials(site["credentials"], site_id) + for field in plugin_fields.get(site["plugin_type"], []): + value = decrypted.get(field["name"]) + cred_states[field["name"]] = bool(value and str(value).strip()) + except Exception as exc: # noqa: BLE001 + logger.debug("cred_states lookup failed for site %s: %s", site_id, exc) + cred_states = {} + return templates.TemplateResponse( request, "dashboard/sites/manage.html", @@ -2962,6 +3061,12 @@ async def dashboard_sites_view(request: Request) -> Response: "mcp_url": mcp_url, "clients": get_supported_clients(), "current_page": "my_sites", + "provider_keys_supported": provider_keys_supported, + "provider_key_rows": provider_key_rows, + "companion_download_url": companion_download_url, + "capability_probe": capability_probe, + "cred_states": cred_states, + "cred_states_json": json.dumps(cred_states), }, ) @@ -3337,15 +3442,24 @@ async def api_list_site_tools(request: Request) -> Response: return err assert site is not None + from core.site_api import list_site_providers_set from core.tool_access import get_tool_access_manager access = get_tool_access_manager() tools = await access.list_tools_for_site(site["id"], site["plugin_type"]) + # F.X.fix #8: expose the site's configured AI-provider set so the + # Tool Access template can render "Configure key for X" CTAs + # without issuing a second /api/sites/{id}/provider-keys call. + try: + configured_providers = sorted(await list_site_providers_set(site["id"])) + except Exception: # noqa: BLE001 + configured_providers = [] return JSONResponse( { "site_id": site["id"], "plugin_type": site["plugin_type"], "tool_scope": site.get("tool_scope", "admin"), + "configured_providers": configured_providers, "tools": tools, } ) @@ -3729,6 +3843,15 @@ async def dashboard_service_page(request: Request) -> Response: lang = detect_language(accept_language, query_lang) t = get_translations(lang) + # F.20 prep: dashboard UX hint — surface the companion-plugin download + # link on the WP / WC service pages. The link switches from the GitHub + # raw-download URL to wp.org once the plugin ships (see F.20 scope). + companion_download_url = None + if plugin_type in {"wordpress", "woocommerce"}: + companion_download_url = ( + "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip" + ) + return templates.TemplateResponse( request, "dashboard/service.html", @@ -3740,10 +3863,206 @@ async def dashboard_service_page(request: Request) -> Response: "display_info": display_info, "current_page": "services", "service": data, + "companion_download_url": companion_download_url, }, ) +# ====================================================================== +# F.18.8 — Provider Keys Dashboard UI (REMOVED in F.5a.9.x) +# ====================================================================== +# +# Per-user AI provider keys and the /dashboard/provider-keys page have +# been replaced by per-site keys defined in each WordPress / WooCommerce +# site's Connection Settings. The site-scoped API is the F.5a.9.x block +# below. The user_provider_keys table is dropped in schema migration v12. +# +# Deleted helpers / handlers: +# _PROVIDER_LABELS, _build_provider_rows, +# dashboard_provider_keys_page, +# api_user_provider_keys_set, api_user_provider_keys_delete. + + +# ====================================================================== +# F.5a.9.x — Per-site AI provider key endpoints +# ====================================================================== + + +async def api_site_provider_keys_list(request: Request) -> Response: + """GET /api/sites/{id}/provider-keys — list providers with a key stored. + + Returns ``{"providers": ["openai", ...]}``. Does not leak ciphertext + or plaintext. + """ + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + from core.site_api import get_user_site, list_site_providers_set, site_supports_provider_keys + + site = await get_user_site(site_id, user_session["user_id"]) + if site is None: + return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404) + if not site_supports_provider_keys(site["plugin_type"]): + return JSONResponse({"ok": False, "error": "plugin_unsupported"}, status_code=400) + + providers = sorted(await list_site_providers_set(site_id)) + return JSONResponse({"ok": True, "providers": providers}) + + +async def api_site_provider_keys_set(request: Request) -> Response: + """POST /api/sites/{id}/provider-keys/{provider} — upsert a per-site key. + + Body: ``{"api_key": "..."}``. Enforces site ownership, plugin-type gate, + and provider allow-list (site_api.set_site_provider_key). + """ + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + provider = (request.path_params.get("provider") or "").strip().lower() + + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400) + if not isinstance(body, dict): + return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400) + + api_key = str(body.get("api_key") or "").strip() + if len(api_key) < 8: + return JSONResponse( + { + "ok": False, + "error": "key_too_short", + "message": "api_key must be at least 8 characters", + }, + status_code=400, + ) + + from core.site_api import set_site_provider_key + + try: + await set_site_provider_key(site_id, user_session["user_id"], provider, api_key) + except ValueError as exc: + return JSONResponse( + {"ok": False, "error": "invalid_request", "message": str(exc)}, + status_code=400, + ) + except Exception as exc: + logger.error( + "site_provider_keys set failed user=%s site=%s provider=%s: %s", + user_session["user_id"], + site_id, + provider, + exc, + ) + return JSONResponse( + {"ok": False, "error": "storage_failed", "message": str(exc)}, + status_code=500, + ) + + return JSONResponse( + { + "ok": True, + "site_id": site_id, + "provider": provider, + "secret_last4": api_key[-4:], + } + ) + + +async def api_site_provider_keys_delete(request: Request) -> Response: + """DELETE /api/sites/{id}/provider-keys/{provider} — remove a per-site key.""" + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + provider = (request.path_params.get("provider") or "").strip().lower() + + from core.site_api import delete_site_provider_key + + try: + deleted = await delete_site_provider_key(site_id, user_session["user_id"], provider) + except Exception as exc: + logger.error( + "site_provider_keys delete failed user=%s site=%s provider=%s: %s", + user_session["user_id"], + site_id, + provider, + exc, + ) + return JSONResponse( + {"ok": False, "error": "storage_failed", "message": str(exc)}, + status_code=500, + ) + return JSONResponse({"ok": True, "site_id": site_id, "provider": provider, "deleted": deleted}) + + +async def api_site_provider_default_model(request: Request) -> Response: + """PATCH /api/sites/{id}/provider-keys/{provider}/default-model. + + Body: ``{"model": ""}`` to set, ``{"model": null}`` (or empty + string) to clear. F.X.fix-pass3 — lets the user pin a discovered + OpenRouter image-model id as the implicit default for a site, so + MCP callers don't have to pass ``model=`` every time. + """ + user_session, redirect = _require_user_session(request) + if redirect or user_session is None: + return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) + + site_id = (request.path_params.get("id") or "").strip() + provider = (request.path_params.get("provider") or "").strip().lower() + + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400) + if not isinstance(body, dict): + return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400) + + raw = body.get("model") + model: str | None + if raw is None: + model = None + elif isinstance(raw, str): + stripped = raw.strip() + model = stripped or None + else: + return JSONResponse({"ok": False, "error": "invalid_model"}, status_code=400) + + from core.site_api import get_user_site + + site = await get_user_site(site_id, user_session["user_id"]) + if site is None: + return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404) + + from core.database import get_database + + db = get_database() + updated = await db.set_site_provider_default_model(site_id, provider, model) + if not updated: + return JSONResponse( + { + "ok": False, + "error": "provider_key_not_found", + "message": "Save the provider key first, then set the default model.", + }, + status_code=404, + ) + return JSONResponse( + { + "ok": True, + "site_id": site_id, + "provider": provider, + "default_model": model, + } + ) + + def register_dashboard_routes(mcp): """ Register dashboard routes with the MCP server. @@ -3848,6 +4167,15 @@ def register_dashboard_routes(mcp): mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site) mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site) + # Per-site AI provider keys (F.5a.9.x) + mcp.custom_route("/api/sites/{id}/provider-keys", methods=["GET"])(api_site_provider_keys_list) + mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["POST"])( + api_site_provider_keys_set + ) + mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["DELETE"])( + api_site_provider_keys_delete + ) + # User API Key routes (E.3) mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys) mcp.custom_route("/api/keys", methods=["POST"])(api_create_key) @@ -3867,4 +4195,11 @@ def register_dashboard_routes(mcp): dashboard_user_oauth_clients_delete ) + # F.18.8 /dashboard/provider-keys routes removed in F.5a.9.x — + # per-site AI provider keys replace the per-user page. See the + # per-site endpoints registered above: + # GET /api/sites/{id}/provider-keys + # POST /api/sites/{id}/provider-keys/{provider} + # DELETE /api/sites/{id}/provider-keys/{provider} + logger.info("Dashboard routes registered successfully") diff --git a/core/database.py b/core/database.py index 1ce3b89..1fa6a78 100644 --- a/core/database.py +++ b/core/database.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data" # Schema version — increment when adding migrations -SCHEMA_VERSION = 8 +SCHEMA_VERSION = 13 # Initial schema DDL _SCHEMA_SQL = """\ @@ -114,6 +114,48 @@ CREATE TABLE IF NOT EXISTS site_tool_toggles ( UNIQUE(site_id, tool_name) ); CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id); + +-- F.5a.4 user_provider_keys table removed in F.5a.9.x / schema v12 — +-- replaced by site_provider_keys (defined below) which stores AI provider +-- credentials per-site instead of per-user. + +-- F.5a.5: chunked-upload sessions (SQLite metadata + disk spill) +CREATE TABLE IF NOT EXISTS upload_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + filename TEXT NOT NULL, + total_bytes INTEGER NOT NULL, + mime TEXT, + sha256 TEXT, + received_bytes INTEGER NOT NULL DEFAULT 0, + next_chunk INTEGER NOT NULL DEFAULT 0, + spill_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status + ON upload_sessions(user_id, status); +CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires + ON upload_sessions(expires_at); + +-- F.5a.9.x: per-site AI provider keys (AES-GCM encrypted, reversible). +-- Replaces the per-user user_provider_keys table with a per-site model so +-- each WordPress/WooCommerce site carries its own OpenAI/Stability/Replicate +-- credential in its Connection Settings. Encryption scope: +-- site_provider:{site_id}:{provider} +CREATE TABLE IF NOT EXISTS site_provider_keys ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + key_ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + last_used TEXT, + default_model TEXT, + UNIQUE(site_id, provider) +); +CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site + ON site_provider_keys(site_id); """ # Migration registry: version -> SQL string @@ -170,6 +212,75 @@ _MIGRATIONS: dict[int, str] = { # F.7c: per-site API keys — allow keys scoped to a single site "ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n" ), + 9: ( + # F.5a.4: per-user AI provider keys (AES-GCM encrypted, reversible — + # distinct from the bcrypt-hashed user_api_keys which authenticate MCP + # clients and cannot be used to call outbound provider APIs). + "CREATE TABLE IF NOT EXISTS user_provider_keys (\n" + " id TEXT PRIMARY KEY,\n" + " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n" + " provider TEXT NOT NULL,\n" + " key_ciphertext BLOB NOT NULL,\n" + " created_at TEXT NOT NULL,\n" + " last_used TEXT,\n" + " UNIQUE(user_id, provider)\n" + ");\n" + "CREATE INDEX IF NOT EXISTS idx_user_provider_keys_user " + "ON user_provider_keys(user_id);\n" + ), + 10: ( + # F.5a.5: chunked upload sessions (SQLite metadata + disk spill) + "CREATE TABLE IF NOT EXISTS upload_sessions (\n" + " id TEXT PRIMARY KEY,\n" + " user_id TEXT NOT NULL,\n" + " filename TEXT NOT NULL,\n" + " total_bytes INTEGER NOT NULL,\n" + " mime TEXT,\n" + " sha256 TEXT,\n" + " received_bytes INTEGER NOT NULL DEFAULT 0,\n" + " next_chunk INTEGER NOT NULL DEFAULT 0,\n" + " spill_path TEXT NOT NULL,\n" + " status TEXT NOT NULL DEFAULT 'open',\n" + " created_at TEXT NOT NULL,\n" + " expires_at TEXT NOT NULL\n" + ");\n" + "CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status " + "ON upload_sessions(user_id, status);\n" + "CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires " + "ON upload_sessions(expires_at);\n" + ), + 11: ( + # F.5a.9.x step 1: add per-site provider keys table. The legacy + # per-user user_provider_keys table is dropped in migration 12 + # (after the code paths that read it are removed). + "CREATE TABLE IF NOT EXISTS site_provider_keys (\n" + " id TEXT PRIMARY KEY,\n" + " site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n" + " provider TEXT NOT NULL,\n" + " key_ciphertext BLOB NOT NULL,\n" + " created_at TEXT NOT NULL,\n" + " last_used TEXT,\n" + " default_model TEXT,\n" + " UNIQUE(site_id, provider)\n" + ");\n" + "CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site " + "ON site_provider_keys(site_id);\n" + ), + 12: ( + # F.5a.9.x step 2: drop the legacy per-user provider-keys store + # now that the resolver, dashboard page, and HTTP endpoints have + # been removed. Confirmed with operator that no users had keys + # stored on the live instance, so no data migration is needed. + "DROP INDEX IF EXISTS idx_user_provider_keys_user;\n" + "DROP TABLE IF EXISTS user_provider_keys;\n" + ), + 13: ( + # F.X.fix-pass3: per-site default image model. Lets the user + # pick a discovered OpenRouter model (e.g. google/gemini-2.5- + # flash-image) as the implicit default for that site, so MCP + # callers don't have to pass `model=...` every time. + "ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n" + ), } @@ -880,6 +991,104 @@ class Database: return "admin" return row["tool_scope"] or "admin" + # ------------------------------------------------------------------ + # Site provider keys (F.5a.9.x) — per-site AI provider credentials + # (replaces the F.5a.4 per-user ``*_provider_key`` helpers dropped in v12) + # ------------------------------------------------------------------ + + async def upsert_site_provider_key( + self, + site_id: str, + provider: str, + key_ciphertext: bytes, + ) -> dict[str, Any]: + """Insert or replace a site's API key for a given AI provider. + + Args: + site_id: Site UUID. + provider: Provider identifier (``openai``, ``stability``, ``replicate``). + key_ciphertext: AES-256-GCM encrypted key bytes. + + Returns: + The stored row (without plaintext). + """ + key_id = str(uuid.uuid4()) + now = _utc_now() + await self.execute( + "INSERT INTO site_provider_keys (id, site_id, provider, key_ciphertext, created_at) " + "VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(site_id, provider) DO UPDATE SET " + "key_ciphertext = excluded.key_ciphertext, created_at = excluded.created_at", + (key_id, site_id, provider, key_ciphertext, now), + ) + row = await self.fetchone( + "SELECT id, site_id, provider, created_at, last_used FROM site_provider_keys " + "WHERE site_id = ? AND provider = ?", + (site_id, provider), + ) + if row is None: + raise RuntimeError(f"Failed to read back site provider key for {site_id}/{provider}") + return row + + async def get_site_provider_key(self, site_id: str, provider: str) -> dict[str, Any] | None: + """Return the encrypted provider key row for a site (includes ciphertext).""" + return await self.fetchone( + "SELECT * FROM site_provider_keys WHERE site_id = ? AND provider = ?", + (site_id, provider), + ) + + async def list_site_provider_keys(self, site_id: str) -> list[dict[str, Any]]: + """List providers a site has keys for (excluding ciphertext).""" + return await self.fetchall( + "SELECT id, site_id, provider, created_at, last_used, default_model " + "FROM site_provider_keys WHERE site_id = ? ORDER BY provider", + (site_id,), + ) + + async def delete_site_provider_key(self, site_id: str, provider: str) -> bool: + """Remove a site's provider key. Returns True if a row was deleted.""" + cursor = await self.execute( + "DELETE FROM site_provider_keys WHERE site_id = ? AND provider = ?", + (site_id, provider), + ) + return cursor.rowcount > 0 + + async def touch_site_provider_key(self, site_id: str, provider: str) -> None: + """Update last_used timestamp after a successful provider call.""" + await self.execute( + "UPDATE site_provider_keys SET last_used = ? WHERE site_id = ? AND provider = ?", + (_utc_now(), site_id, provider), + ) + + async def set_site_provider_default_model( + self, site_id: str, provider: str, model: str | None + ) -> bool: + """F.X.fix-pass3 — record the per-site default model for a provider. + + ``model=None`` clears the default. Returns ``True`` when a row + was actually updated (i.e. the site has a key for the provider); + callers can surface a 404 to the user when the row doesn't exist + rather than silently writing nothing. + """ + cursor = await self.execute( + "UPDATE site_provider_keys SET default_model = ? " "WHERE site_id = ? AND provider = ?", + (model, site_id, provider), + ) + return cursor.rowcount > 0 + + async def get_site_provider_default_model(self, site_id: str, provider: str) -> str | None: + """Return the default model id for a site's provider, or None.""" + row = await self.fetchone( + "SELECT default_model FROM site_provider_keys " "WHERE site_id = ? AND provider = ?", + (site_id, provider), + ) + if row is None: + return None + value = row.get("default_model") + if isinstance(value, str) and value: + return value + return None + async def set_site_tool_scope(self, site_id: str, scope: str) -> None: """Update the ``tool_scope`` preset for a site. diff --git a/core/encryption.py b/core/encryption.py index 16999c7..835ec15 100644 --- a/core/encryption.py +++ b/core/encryption.py @@ -178,6 +178,35 @@ class CredentialEncryption: json_str = self.decrypt(cipherdata, site_id) return json.loads(json_str) + def encrypt_for_scope(self, plaintext: str, scope: str) -> bytes: + """Encrypt a plaintext string using an arbitrary HKDF scope string. + + Used when the encrypted value is not a per-site credentials blob but + still needs per-key isolation (e.g. per-site AI provider API keys + where scope is ``site_provider:{site_id}:{provider}``). + + Args: + plaintext: The string to encrypt. + scope: Scope string used as HKDF info for key derivation. Any + caller reading back the ciphertext must pass the same scope. + + Returns: + Encrypted bytes (same wire format as :meth:`encrypt`). + """ + return self.encrypt(plaintext, scope) + + def decrypt_for_scope(self, cipherdata: bytes, scope: str) -> str: + """Decrypt cipherdata produced by :meth:`encrypt_for_scope`. + + Args: + cipherdata: Encrypted bytes. + scope: Scope string — must exactly match what was used to encrypt. + + Returns: + Original plaintext string. + """ + return self.decrypt(cipherdata, scope) + # Global credential encryption instance _credential_encryption: CredentialEncryption | None = None diff --git a/core/media_audit.py b/core/media_audit.py new file mode 100644 index 0000000..5a28e9e --- /dev/null +++ b/core/media_audit.py @@ -0,0 +1,67 @@ +"""F.5a.6.4 — Audit-log emission for media uploads. + +One ``media.upload`` entry is written per **successful** upload regardless +of source (base64 / url / chunked / ai:). Failures are intentionally +NOT logged here — they surface to the caller as typed ``UploadError`` JSON +and to the dashboard via the existing tool-call audit emitted by the +ToolRouter wrapper. Logging failures twice would double-count error rates. + +GDPR: never log raw bytes, base64, prompts, or URLs that may carry tokens. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def log_media_upload( + *, + site: str | None, + user_id: str | None, + mime: str | None, + size_bytes: int, + source: str, + media_id: int | None, + cost_usd: float | None = None, +) -> None: + """Emit a single ``media.upload`` audit entry. Best-effort — never raises. + + Args: + site: Site URL or alias (whichever the handler has on hand). + user_id: Calling user id (None for admin / env-fallback). + mime: Sniffed MIME type after security validation. + size_bytes: Final uploaded byte count (post-optimization). + source: One of ``"base64"``, ``"url"``, ``"chunked"``, ``"ai:"``. + media_id: WordPress media library id of the resulting attachment. + cost_usd: Provider cost in USD for AI-generated uploads only. + """ + try: + from core.audit_log import get_audit_logger + + audit = get_audit_logger() + except Exception: # noqa: BLE001 + return + + params: dict[str, Any] = { + "site": site, + "mime": mime, + "size_bytes": int(size_bytes), + "source": source, + "media_id": media_id, + } + if cost_usd is not None: + params["cost_usd"] = round(float(cost_usd), 6) + + try: + audit.log_tool_call( + tool_name="media.upload", + site=site, + params=params, + result_summary=(f"{source} {size_bytes}B {mime or '?'} -> media_id={media_id}"), + user_id=user_id, + ) + except Exception: # noqa: BLE001 + logger.debug("media.upload audit emit failed", exc_info=True) diff --git a/core/media_error_codes.py b/core/media_error_codes.py new file mode 100644 index 0000000..e06b21f --- /dev/null +++ b/core/media_error_codes.py @@ -0,0 +1,91 @@ +"""F.5a.6.2 — Stable error-code taxonomy for media upload tools. + +Every :class:`plugins.wordpress.handlers._media_security.UploadError` and +:class:`core.upload_sessions.UploadSessionError` raised inside the media +stack MUST use one of the codes listed here (or match the dynamic +``WP_`` pattern for upstream WordPress REST responses). + +The accompanying test ``tests/plugins/wordpress/test_media_error_taxonomy.py`` +scans the source files for raise-site literals and asserts each one is in +this set — so the contract stays stable as the code evolves. + +The full reference is in ``docs/media-error-codes.md``. +""" + +from __future__ import annotations + +import re + +#: All stable upload/media error codes. Keep alphabetical within groups. +MEDIA_ERROR_CODES: frozenset[str] = frozenset( + { + # --- Input / validation ------------------------------------------ + "BAD_BASE64", + "BAD_MODE", + "BAD_ROLE", + "BAD_SIZE", + "BAD_SOURCE", + "EMPTY_FILE", + "MEDIA_NOT_FOUND", + "MIME_REJECTED", + "MISSING_FIELD", + "SSRF", + "TOO_LARGE", + "URL_FETCH_FAILED", + # --- WordPress REST upstream ------------------------------------- + "WP_413", + "WP_AUTH", + "WP_BAD_RESPONSE", + # F.X.fix-pass4 — WC sites with consumer_key/consumer_secret + # auth need a separate WP Application Password to upload to + # /wp/v2/media. Surfaced by media_attach.py when the user + # hasn't filled wp_username/wp_app_password in Connection + # Settings. + "WP_CREDENTIALS_MISSING", + # WP_ (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE + # --- Companion plugin upload-chunk route (F.5a.7) ---------------- + "COMPANION_BAD_RESPONSE", + # COMPANION_ (e.g. COMPANION_500) is also allowed — same + # shape as WP_, see _COMPANION_STATUS_RE. + # --- Chunked upload session -------------------------------------- + "BAD_STATE", + "CHECKSUM_MISMATCH", + "CHUNK_CHECKSUM", + "CHUNK_ORDER", + "CHUNK_OVERFLOW", + "EXPIRED", + "INCOMPLETE", + "NO_SESSION", + "QUOTA_EXCEEDED", + "SESSION_TOO_LARGE", + # --- AI generation providers ------------------------------------- + "GENERATION_FAILED", + "NO_PROVIDER_KEY", + "PROVIDER_AUTH", + "PROVIDER_BAD_REQUEST", + "PROVIDER_BAD_RESPONSE", + "PROVIDER_QUOTA", + "PROVIDER_TIMEOUT", + "PROVIDER_UNAVAILABLE", + "PROVIDER_UNKNOWN", + # --- Rate / policy ----------------------------------------------- + "TOOL_RATE_LIMITED", + # --- Catchall ---------------------------------------------------- + "INTERNAL", + } +) + +#: Dynamic WP REST status codes (e.g. WP_400, WP_500). +_WP_STATUS_RE = re.compile(r"^WP_\d{3}$") + +#: Dynamic companion upload-chunk status codes (e.g. COMPANION_400, COMPANION_500). +_COMPANION_STATUS_RE = re.compile(r"^COMPANION_\d{3}$") + + +def is_valid_code(code: str) -> bool: + """Return True if ``code`` is a documented media error code.""" + return ( + code in MEDIA_ERROR_CODES + or bool(_WP_STATUS_RE.match(code)) + or bool(_COMPANION_STATUS_RE.match(code)) + ) diff --git a/core/site_api.py b/core/site_api.py index f1eb2a4..1b5c7e1 100644 --- a/core/site_api.py +++ b/core/site_api.py @@ -35,14 +35,18 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "label": "Username", "type": "text", "required": True, - "hint": "Your WordPress admin username", + "hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).", }, { "name": "app_password", "label": "Application Password", "type": "password", "required": True, - "hint": "WordPress Admin → Users → Profile → Application Passwords", + "hint": ( + "WordPress Admin → Users → Profile → Application Passwords. " + "This IS the API credential — no separate API key is needed. " + "Paste the value WP shows once after creation (spaces can stay or be removed)." + ), }, ], "woocommerce": [ @@ -51,14 +55,53 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "label": "Consumer Key", "type": "text", "required": True, - "hint": "WooCommerce → Settings → Advanced → REST API", + "hint": ( + "WooCommerce → Settings → Advanced → REST API → Add Key. " + "Read/Write permission. This pair IS the API auth — no extra API key field exists." + ), }, { "name": "consumer_secret", "label": "Consumer Secret", "type": "password", "required": True, - "hint": "Shown once when creating the REST API key", + "hint": ( + "Shown once when the REST API key is created. " + "Starts with ``cs_``. Save it immediately — WooCommerce will not display it again." + ), + }, + # F.X.fix-pass4 — optional WP Application Password, only used by + # media tools (upload_and_attach_to_product / attach_media_to_ + # product / set_featured_image). WooCommerce's Consumer Key + + # Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``; + # WordPress core REST media uploads require an Application + # Password from the WP admin user. Leave blank if the store + # never needs MCP-driven image uploads. + { + "name": "wp_username", + "label": "WordPress Username (for media tools)", + "type": "text", + "required": False, + "advanced": True, + "hint": ( + "Only required if you want the AI / media tools " + "(upload_and_attach_to_product, attach_media_to_product, " + "set_featured_image, generate_and_upload_image with attach_to_post). " + "Other WC tools work with Consumer Key / Secret alone." + ), + }, + { + "name": "wp_app_password", + "label": "WordPress Application Password (for media tools)", + "type": "password", + "required": False, + "advanced": True, + "hint": ( + "WP Admin → Users → Profile → Application Passwords. " + "WC media uploads hit /wp/v2/media which only accepts " + "Application Passwords (not Consumer Key / Secret). " + "Leave empty if you don't use MCP for image uploads." + ), }, ], "wordpress_advanced": [ @@ -67,21 +110,24 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = { "label": "Username", "type": "text", "required": True, - "hint": "Your WordPress admin username", + "hint": "Your WordPress admin username (HTTP Basic username for every API call).", }, { "name": "app_password", "label": "Application Password", "type": "password", "required": True, - "hint": "WordPress Admin → Users → Profile → Application Passwords", + "hint": ( + "WordPress Admin → Users → Profile → Application Passwords. " + "IS the API credential — no separate API key needed." + ), }, { "name": "container", "label": "Docker Container Name", "type": "text", "required": False, - "hint": "Docker container running WordPress (for WP-CLI access)", + "hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.", }, ], "gitea": [ @@ -654,6 +700,170 @@ async def update_user_site( return site_dict +# --------------------------------------------------------------------------- +# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate) +# --------------------------------------------------------------------------- + +# Only WordPress / WooCommerce sites can host AI image provider keys — the +# only consumer is ``wordpress_generate_and_upload_image``, which uploads into +# a WordPress media library. Other plugin types never surface the section. +PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"}) + +# Providers supported for per-site keys. Must stay in sync with +# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to +# drive the UI). +SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter") + + +def site_provider_scope(site_id: str, provider: str) -> str: + """Return the HKDF scope used for per-site provider-key encryption. + + Format: ``site_provider:{site_id}:{provider}`` — distinct from the + per-site credentials scope (bare ``site_id``) so the same master key + never produces the same derived key for both credential types. + """ + return f"site_provider:{site_id}:{provider}" + + +def site_supports_provider_keys(plugin_type: str) -> bool: + """Return True if the plugin type can hold AI provider keys.""" + return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES + + +async def set_site_provider_key( + site_id: str, + user_id: str, + provider: str, + api_key: str, +) -> dict[str, Any]: + """Encrypt and store an AI provider API key for a user-owned site. + + Args: + site_id: Site UUID. + user_id: Owner's UUID (enforces tenant isolation). + provider: One of :data:`SITE_PROVIDERS`. + api_key: The plaintext API key to store. + + Returns: + The stored row (without plaintext). + + Raises: + ValueError: If the site is not found, the plugin type does not + support provider keys, the provider is unknown, or the key + is empty. + """ + from core.database import get_database + from core.encryption import get_credential_encryption + + if provider not in SITE_PROVIDERS: + raise ValueError( + f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}" + ) + if not api_key or not api_key.strip(): + raise ValueError("API key must not be empty") + + db = get_database() + site = await db.get_site(site_id, user_id) + if site is None: + raise ValueError("Site not found") + + if not site_supports_provider_keys(site["plugin_type"]): + raise ValueError( + f"Plugin type '{site['plugin_type']}' does not support AI " + f"provider keys (only WordPress/WooCommerce)." + ) + + encryptor = get_credential_encryption() + ciphertext = encryptor.encrypt_for_scope( + api_key.strip(), site_provider_scope(site_id, provider) + ) + + row = await db.upsert_site_provider_key(site_id, provider, ciphertext) + logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id) + return row + + +async def get_site_provider_key( + site_id: str, + provider: str, +) -> str | None: + """Return the decrypted AI provider key for a site, or None. + + Caller is responsible for verifying site ownership — this helper is + also called from the AI tool handler which already trusts the + resolved site_id. + + Args: + site_id: Site UUID. + provider: Provider identifier. + + Returns: + Decrypted API key string, or None if no key is stored. + """ + from core.database import get_database + from core.encryption import get_credential_encryption + + if provider not in SITE_PROVIDERS: + return None + + db = get_database() + row = await db.get_site_provider_key(site_id, provider) + if row is None: + return None + + encryptor = get_credential_encryption() + try: + return encryptor.decrypt_for_scope( + row["key_ciphertext"], site_provider_scope(site_id, provider) + ) + except Exception: + logger.exception( + "Failed to decrypt site provider key (site=%s provider=%s)", + site_id, + provider, + ) + return None + + +async def list_site_providers_set(site_id: str) -> set[str]: + """Return the set of providers that have a key stored for the site.""" + from core.database import get_database + + db = get_database() + rows = await db.list_site_provider_keys(site_id) + return {row["provider"] for row in rows} + + +async def delete_site_provider_key( + site_id: str, + user_id: str, + provider: str, +) -> bool: + """Remove an AI provider key for a site. Returns True if a row was deleted. + + Args: + site_id: Site UUID. + user_id: Owner's UUID — enforces tenant isolation before deletion. + provider: Provider identifier. + """ + from core.database import get_database + + db = get_database() + site = await db.get_site(site_id, user_id) + if site is None: + return False + + deleted = await db.delete_site_provider_key(site_id, provider) + if deleted: + logger.info( + "Deleted %s provider key for site %s (user %s)", + provider, + site_id, + user_id, + ) + return deleted + + async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]: """Test connectivity to an existing site. diff --git a/core/templates/dashboard/connect.html b/core/templates/dashboard/connect.html index 82b2313..fc37b5d 100644 --- a/core/templates/dashboard/connect.html +++ b/core/templates/dashboard/connect.html @@ -143,13 +143,13 @@ diff --git a/core/templates/dashboard/keys/list.html b/core/templates/dashboard/keys/list.html index 071a42a..919865d 100644 --- a/core/templates/dashboard/keys/list.html +++ b/core/templates/dashboard/keys/list.html @@ -259,13 +259,13 @@ diff --git a/core/templates/dashboard/service.html b/core/templates/dashboard/service.html index 37b73a0..84fe42b 100644 --- a/core/templates/dashboard/service.html +++ b/core/templates/dashboard/service.html @@ -134,6 +134,57 @@ {% endif %} + + {% if companion_download_url %} +
+
+
+ + + +
+
+

+ {% if lang == 'fa' %} + افزونه همراه Airano MCP Bridge (اختیاری اما توصیه‌شده) + {% else %} + Airano MCP Bridge — companion plugin (optional but recommended) + {% endif %} +

+

+ {% if lang == 'fa' %} + نصب افزونه همراه، این قابلیت‌ها را فعال می‌کند: آپلود فایل بزرگ‌تر از + upload_max_filesize، گزارش یکپارچه‌ی سلامت سایت، + purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیت‌ها، + و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار می‌کنند اما موارد فوق غیرفعال خواهند بود. + {% else %} + Installing the companion plugin unlocks: uploads larger than + upload_max_filesize, unified site-health snapshot, + cache purge, transient flush, bulk meta writes, structured export, capability probe, + and audit-hook webhooks. Without it, basic tools still work but those features stay disabled. + {% endif %} +

+
+ + + + + {% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %} + + + {% if lang == 'fa' %} + نصب: WP Admin → افزونه‌ها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعال‌سازی + {% else %} + Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate + {% endif %} + +
+
+
+
+ {% endif %} + {% if service.credential_fields %}
diff --git a/core/templates/dashboard/sites/_capability_badge.html b/core/templates/dashboard/sites/_capability_badge.html new file mode 100644 index 0000000..5284348 --- /dev/null +++ b/core/templates/dashboard/sites/_capability_badge.html @@ -0,0 +1,173 @@ +{# F.X.fix #9 — capability-badge partial, HTMX-swappable. + +Rendered both (a) inline by manage.html on first page load and (b) as +the response body of GET /api/sites/{id}/capabilities/badge when the +Re-check button is clicked. Swapping this element with hx-swap= +"outerHTML" avoids the full-page reload the legacy button triggered. + +F.X.fix-pass2 — wording corrected for WordPress: application passwords +inherit ALL capabilities from the user account, so the "credential +tier" framing is misleading. WP-specific branches reference the WP +user's role instead. AI-provider availability is surfaced here too +so removing a provider key gives visible feedback on Re-check. + +Required context: + capability_probe — probe payload (with .fit, .granted, .reason, + .ai_providers_configured, optionally .install_hint) + site — site dict (for .plugin_type, .tool_scope) + lang — "fa" | "en" + companion_download_url — string, may be empty +#} +{% set fit_status = capability_probe.fit.status %} +{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %} +{% set is_wc = site.plugin_type == 'woocommerce' %} +{% set is_wp_like = is_wp or is_wc %} +{% set ai_providers = capability_probe.ai_providers_configured or [] %} +{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %} +
+
+
+ {% if fit_status == 'ok' %} +
+ {% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخاب‌شده کافی است{% else %}✓ Credential grants what this tier needs{% endif %} +
+ {% if capability_probe.granted %} +
+ {% if lang == 'fa' %}دسترسی‌های احراز شده:{% else %}Granted:{% endif %} + {{ capability_probe.granted | join(', ') }} +
+ {% endif %} + {% elif fit_status == 'warning' %} +
+ {% if is_wc %} + {% if lang == 'fa' %} + ⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست + {% else %} + ⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier + {% endif %} + {% elif is_wp %} + {% if lang == 'fa' %} + ⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد + {% else %} + ⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier + {% endif %} + {% else %} + {% if lang == 'fa' %} + ⚠ credential ذخیره‌شده برای سطح «{{ site.tool_scope }}» کافی نیست + {% else %} + ⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier + {% endif %} + {% endif %} +
+
+ {% if lang == 'fa' %}دسترسی‌های ناموجود:{% else %}Missing:{% endif %} + {{ capability_probe.fit.missing | join(', ') }} +
+ {% if capability_probe.granted %} +
+ {% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %} + {{ capability_probe.granted | join(', ') }} +
+ {% endif %} +
+ {% if is_wc %} + {% if lang == 'fa' %} + کلید ووکامرس (Consumer Key + Secret) دارای فیلد permissions با مقادیر + read یا read_write است. گزینه‌ها: + (۱) سطح پایین‌تر انتخاب کنید؛ + (۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛ + (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند. + {% else %} + WooCommerce REST API keys carry a permissions field + (read / read_write). Options: + (1) pick a lower access level; + (2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings; + (3) continue — tools needing missing caps will return 403 at call time. + {% endif %} + {% elif is_wp %} + {% if lang == 'fa' %} + application password در وردپرس همیشه تمام دسترسی‌های کاربر صاحب آن را می‌گیرد و قابل محدودکردن نیست. گزینه‌ها: + (۱) سطح دسترسی پایین‌تری انتخاب کنید؛ + (۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛ + (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند. + {% else %} + WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down. + Options: (1) pick a lower access level; + (2) create the application password from a higher-role user (e.g. Administrator or Editor); + (3) continue — tools needing missing caps will return 403 at call time. + {% endif %} + {% else %} + {% if lang == 'fa' %} + گزینه‌ها: (۱) انتخاب سطح پایین‌تر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند. + {% else %} + Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time. + {% endif %} + {% endif %} +
+ {% elif fit_status == 'probe_unavailable' %} +
+ {% if lang == 'fa' %}ℹ امکان بررسی خودکار credential در این سایت وجود ندارد{% else %}ℹ Capability probe is unavailable for this site{% endif %} +
+
+ {{ capability_probe.reason or 'probe_unavailable' }} +
+ {% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %} +
+ {% if lang == 'fa' %} + برای فعال‌سازی probe، افزونه + Airano MCP Bridge + را روی وردپرس نصب کنید. + {% else %} + Install the + Airano MCP Bridge + plugin on your WordPress to enable the probe. + {% endif %} +
+ {% endif %} + {% elif fit_status == 'unknown_tier' %} +
+ {% if lang == 'fa' %}ℹ probe برای این plugin + سطح پیاده‌سازی نشده{% else %}ℹ Probe not implemented for this plugin/tier combination{% endif %} +
+ {% endif %} + + {# F.X.fix-pass2 — AI provider status line. Independent of the + tier-fit state: even a perfectly-fit credential cannot run + the AI image tool without an OpenRouter / OpenAI / … key. #} + {% if ai_provider_missing %} +
+ {% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %} + + {% if lang == 'fa' %} + — ابزار wordpress_generate_and_upload_image غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید. + {% else %} + — wordpress_generate_and_upload_image is disabled. Add a key in the "AI Image Generation" section above. + {% endif %} + +
+ {% elif is_wp_like and ai_providers %} +
+ {% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %} + {{ ai_providers | join(', ') }} +
+ {% endif %} +
+ +
+
diff --git a/core/templates/dashboard/sites/manage.html b/core/templates/dashboard/sites/manage.html index aeeb5fe..58fe4f5 100644 --- a/core/templates/dashboard/sites/manage.html +++ b/core/templates/dashboard/sites/manage.html @@ -62,13 +62,20 @@ {% set fields = plugin_fields.get(site.plugin_type, []) %} {% for field in fields %} {% if not field.get('advanced', False) %} -
-