feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
485
core/capability_probe.py
Normal file
485
core/capability_probe.py
Normal file
@@ -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("<div>unauthorized</div>", status_code=401)
|
||||
|
||||
site_id = (request.path_params.get("id") or "").strip()
|
||||
if not site_id:
|
||||
return HTMLResponse("<div>invalid_request</div>", 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("<div>site_not_found</div>", 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("<div>site_not_found</div>", 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,
|
||||
},
|
||||
)
|
||||
325
core/companion_audit.py
Normal file
325
core/companion_audit.py
Normal file
@@ -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)
|
||||
@@ -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": "<id>"}`` 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")
|
||||
|
||||
211
core/database.py
211
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
67
core/media_audit.py
Normal file
67
core/media_audit.py
Normal file
@@ -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:<provider>). 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:<provider>"``.
|
||||
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)
|
||||
91
core/media_error_codes.py
Normal file
91
core/media_error_codes.py
Normal file
@@ -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_<status>`` 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_<status> (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE
|
||||
# --- Companion plugin upload-chunk route (F.5a.7) ----------------
|
||||
"COMPANION_BAD_RESPONSE",
|
||||
# COMPANION_<status> (e.g. COMPANION_500) is also allowed — same
|
||||
# shape as WP_<status>, 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))
|
||||
)
|
||||
224
core/site_api.py
224
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.
|
||||
|
||||
|
||||
@@ -143,13 +143,13 @@
|
||||
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
را روی سایت وردپرسی خود نصب کنید.
|
||||
<strong>وردپرس:</strong> برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
|
||||
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||
را دانلود و روی سایت وردپرسی خود نصب کنید.
|
||||
{% else %}
|
||||
<strong>WordPress:</strong> For SEO tools, install the
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
plugin on your WordPress site.
|
||||
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
|
||||
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||
companion plugin on your WordPress site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -259,13 +259,13 @@
|
||||
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
را روی سایت وردپرسی خود نصب کنید.
|
||||
<strong>وردپرس:</strong> برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
|
||||
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||
را دانلود و روی سایت وردپرسی خود نصب کنید.
|
||||
{% else %}
|
||||
<strong>WordPress:</strong> For SEO tools, install the
|
||||
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
|
||||
plugin on your WordPress site.
|
||||
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
|
||||
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||
companion plugin on your WordPress site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -134,6 +134,57 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- F.20 prep: Companion plugin download hint (WP / WC only) -->
|
||||
{% if companion_download_url %}
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
{% if lang == 'fa' %}
|
||||
افزونه همراه Airano MCP Bridge (اختیاری اما توصیهشده)
|
||||
{% else %}
|
||||
Airano MCP Bridge — companion plugin (optional but recommended)
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-indigo-800/90 dark:text-indigo-300/90 leading-relaxed">
|
||||
{% if lang == 'fa' %}
|
||||
نصب افزونه همراه، این قابلیتها را فعال میکند: آپلود فایل بزرگتر از
|
||||
<code class="font-mono">upload_max_filesize</code>، گزارش یکپارچهی سلامت سایت،
|
||||
purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیتها،
|
||||
و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار میکنند اما موارد فوق غیرفعال خواهند بود.
|
||||
{% else %}
|
||||
Installing the companion plugin unlocks: uploads larger than
|
||||
<code class="font-mono">upload_max_filesize</code>, 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 %}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<a href="{{ companion_download_url }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %}
|
||||
</a>
|
||||
<span class="text-xs text-indigo-700/70 dark:text-indigo-300/70">
|
||||
{% if lang == 'fa' %}
|
||||
نصب: WP Admin → افزونهها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعالسازی
|
||||
{% else %}
|
||||
Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Setup Requirements -->
|
||||
{% if service.credential_fields %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
|
||||
173
core/templates/dashboard/sites/_capability_badge.html
Normal file
173
core/templates/dashboard/sites/_capability_badge.html
Normal file
@@ -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) %}
|
||||
<div id="capability-badge"
|
||||
class="border rounded-lg px-4 py-3 text-sm
|
||||
{% if fit_status == 'ok' %}
|
||||
bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300
|
||||
{% elif fit_status == 'warning' %}
|
||||
bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300
|
||||
{% elif fit_status == 'unknown_tier' %}
|
||||
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300
|
||||
{% else %}
|
||||
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400
|
||||
{% endif %}">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if fit_status == 'ok' %}
|
||||
<div class="font-semibold text-sm">
|
||||
{% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخابشده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
|
||||
</div>
|
||||
{% if capability_probe.granted %}
|
||||
<div class="mt-1 text-xs opacity-80">
|
||||
{% if lang == 'fa' %}دسترسیهای احراز شده:{% else %}Granted:{% endif %}
|
||||
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif fit_status == 'warning' %}
|
||||
<div class="font-semibold text-sm">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="mt-1 text-xs opacity-90">
|
||||
{% if lang == 'fa' %}دسترسیهای ناموجود:{% else %}Missing:{% endif %}
|
||||
<code class="font-mono text-xs">{{ capability_probe.fit.missing | join(', ') }}</code>
|
||||
</div>
|
||||
{% if capability_probe.granted %}
|
||||
<div class="mt-1 text-xs opacity-80">
|
||||
{% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
|
||||
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-2 text-xs">
|
||||
{% if is_wc %}
|
||||
{% if lang == 'fa' %}
|
||||
کلید ووکامرس (Consumer Key + Secret) دارای فیلد <code class="font-mono">permissions</code> با مقادیر
|
||||
<code class="font-mono">read</code> یا <code class="font-mono">read_write</code> است. گزینهها:
|
||||
(۱) سطح پایینتر انتخاب کنید؛
|
||||
(۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
|
||||
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
|
||||
{% else %}
|
||||
WooCommerce REST API keys carry a <code class="font-mono">permissions</code> field
|
||||
(<code class="font-mono">read</code> / <code class="font-mono">read_write</code>). 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 %}
|
||||
</div>
|
||||
{% elif fit_status == 'probe_unavailable' %}
|
||||
<div class="font-semibold text-sm">
|
||||
{% if lang == 'fa' %}ℹ امکان بررسی خودکار credential در این سایت وجود ندارد{% else %}ℹ Capability probe is unavailable for this site{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-xs opacity-80 font-mono">
|
||||
{{ capability_probe.reason or 'probe_unavailable' }}
|
||||
</div>
|
||||
{% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
|
||||
<div class="mt-2 text-xs">
|
||||
{% if lang == 'fa' %}
|
||||
برای فعالسازی probe، افزونه
|
||||
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
|
||||
را روی وردپرس نصب کنید.
|
||||
{% else %}
|
||||
Install the
|
||||
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
|
||||
plugin on your WordPress to enable the probe.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif fit_status == 'unknown_tier' %}
|
||||
<div class="font-semibold text-sm">
|
||||
{% if lang == 'fa' %}ℹ probe برای این plugin + سطح پیادهسازی نشده{% else %}ℹ Probe not implemented for this plugin/tier combination{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="mt-3 pt-2 border-t border-current/20 text-xs">
|
||||
<span class="font-semibold">{% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}</span>
|
||||
<span class="opacity-80">
|
||||
{% if lang == 'fa' %}
|
||||
— ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
|
||||
{% else %}
|
||||
— <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled. Add a key in the "AI Image Generation" section above.
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% elif is_wp_like and ai_providers %}
|
||||
<div class="mt-3 pt-2 border-t border-current/20 text-xs opacity-80">
|
||||
{% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
|
||||
<code class="font-mono text-xs">{{ ai_providers | join(', ') }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button"
|
||||
hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"
|
||||
hx-target="#capability-badge"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="this"
|
||||
class="flex-shrink-0 px-2.5 py-1 rounded border border-current/30 text-xs font-medium hover:bg-white/20 dark:hover:bg-black/20 transition-colors disabled:opacity-50">
|
||||
{% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,13 +62,20 @@
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
{% if not field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
{% set is_stored = cred_states.get(field.name, False) %}
|
||||
<div data-cred-row="{{ field.name }}">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
|
||||
<span>{{ field.label }}{% if field.required %} *{% endif %}</span>
|
||||
{% if is_stored %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
|
||||
✓ {% if lang == 'fa' %}ذخیرهشده{% else %}Stored{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||
placeholder="{% if is_stored %}{{ t.keep_existing }}{% elif field.required %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
|
||||
data-required="{{ field.required | lower }}"
|
||||
data-stored="{{ is_stored | lower }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
@@ -81,15 +88,30 @@
|
||||
{% if has_advanced %}
|
||||
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||
Advanced Settings
|
||||
{% if lang == 'fa' %}تنظیمات پیشرفته{% else %}Advanced Settings{% endif %}
|
||||
</summary>
|
||||
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||
{% for field in fields %}
|
||||
{% if field.get('advanced', False) %}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
|
||||
{% set is_stored = cred_states.get(field.name, False) %}
|
||||
<div data-cred-row="{{ field.name }}">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
|
||||
<span>{{ field.label }}</span>
|
||||
{% if is_stored %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
|
||||
✓ {% if lang == 'fa' %}ذخیرهشده{% else %}Stored{% endif %}
|
||||
</span>
|
||||
<button type="button"
|
||||
data-cred-clear="{{ field.name }}"
|
||||
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
|
||||
{% if lang == 'fa' %}🗑 پاک کن{% else %}🗑 Clear{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
|
||||
placeholder="{% if is_stored %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
|
||||
data-required="false"
|
||||
data-stored="{{ is_stored | lower }}"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
{% if field.hint %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||
@@ -118,6 +140,113 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if provider_keys_supported %}
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 1b: AI Image Generation (per-site provider keys) -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onclick="toggleSection('aikeys')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-pink-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}تولید تصویر با هوش مصنوعی{% else %}AI Image Generation{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{% if lang == 'fa' %}
|
||||
کلید API هر ارائهدهنده را تنها برای این سایت ذخیره کنید. بدون کلید، ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است.
|
||||
{% else %}
|
||||
Store an API key per provider, scoped only to this site. Without a key, <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled for this site.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="aikeys-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="aikeys-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-3">
|
||||
<div id="aikeys-flash" class="hidden rounded-lg p-3 text-sm"></div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for row in provider_key_rows %}
|
||||
<div class="py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" data-site-ai-row="{{ row.provider }}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-3 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ row.label }}</h4>
|
||||
{% if row.status == 'set' %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
|
||||
{% if lang == 'fa' %}تنظیمشده{% else %}Set{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}تنظیم نشده{% else %}Unset{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
<form class="flex items-center gap-2"
|
||||
data-site-ai-set="{{ row.provider }}"
|
||||
onsubmit="return window.__siteAiKeys.setKey(event, '{{ row.provider }}')">
|
||||
<input type="password"
|
||||
name="api_key"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
placeholder="{% if lang == 'fa' %}کلید API{% else %}New API key{% endif %}"
|
||||
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent w-48"/>
|
||||
<button type="submit"
|
||||
class="px-3 py-2 rounded-lg bg-pink-600 hover:bg-pink-500 text-white text-sm font-medium transition-colors">
|
||||
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button"
|
||||
data-site-ai-remove="{{ row.provider }}"
|
||||
onclick="return window.__siteAiKeys.deleteKey('{{ row.provider }}')"
|
||||
class="px-3 py-2 rounded-lg border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors {% if row.status != 'set' %}hidden{% endif %}">
|
||||
{% if lang == 'fa' %}حذف{% else %}Remove{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if row.provider == 'openrouter' %}
|
||||
<details class="ml-0 sm:ml-0 mt-1 text-xs"
|
||||
data-site-ai-models="openrouter"
|
||||
data-current-default="{{ row.default_model or '' }}">
|
||||
<summary class="cursor-pointer inline-block px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 select-none">
|
||||
{% if lang == 'fa' %}🖼 مدلهای تصویری در دسترس{% if row.default_model %} — پیشفرض: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% else %}🖼 Available image models{% if row.default_model %} — default: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% endif %}
|
||||
</summary>
|
||||
<div class="mt-2 rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200">
|
||||
<div class="text-gray-500 dark:text-gray-400" data-models-placeholder>
|
||||
{% if lang == 'fa' %}در حال بارگذاری…{% else %}Loading…{% endif %}
|
||||
</div>
|
||||
<ul class="space-y-2 hidden" data-models-list></ul>
|
||||
<p class="mt-2 text-[11px] text-gray-500 dark:text-gray-400" data-models-hint>
|
||||
{% if lang == 'fa' %}
|
||||
با کلیک روی «پیشفرض» مدل بهعنوان مدل پیشفرض این سایت ذخیره میشود — اگر <code class="font-mono">model</code> در فراخوانی <code class="font-mono">wordpress_generate_and_upload_image</code> داده نشود، از این مقدار استفاده میشود.
|
||||
{% else %}
|
||||
Click "Set default" to make a model the site's default — used when <code class="font-mono">wordpress_generate_and_upload_image</code> is called without an explicit <code class="font-mono">model</code>.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 pt-2">
|
||||
{% if lang == 'fa' %}
|
||||
کلیدها با AES-256-GCM رمزگذاری میشوند و فقط برای همین سایت قابل استفادهاند.
|
||||
{% else %}
|
||||
Keys are encrypted at rest (AES-256-GCM) and scoped to this site only.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<!-- Section 2: Tool Access -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
@@ -141,6 +270,31 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
{% if companion_download_url %}
|
||||
<!-- F.20 prep: companion-plugin hint (compact, WP/WC only) -->
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-800 dark:text-indigo-300">
|
||||
{% if lang == 'fa' %}
|
||||
برای قابلیتهای کامل (آپلود بزرگ، cache purge، site-health، و…) افزونهی
|
||||
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
|
||||
را در وردپرس نصب کنید (اختیاری).
|
||||
{% else %}
|
||||
For full capability (large uploads, cache purge, site-health, bulk meta, audit hooks, …), install the
|
||||
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
|
||||
plugin on your WordPress (optional).
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# F.7e: capability probe badge — tells the user whether the
|
||||
saved credential can actually back the selected tier,
|
||||
before they hit a 403 from a real tool call. #}
|
||||
{# F.X.fix #9: badge is an HTMX-swappable partial. Re-check
|
||||
button hits /api/sites/{id}/capabilities/badge and swaps
|
||||
this element in place — no full-page reload. #}
|
||||
{% if capability_probe %}
|
||||
{% include "dashboard/sites/_capability_badge.html" %}
|
||||
{% endif %}
|
||||
|
||||
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||
</div>
|
||||
@@ -303,6 +457,30 @@
|
||||
}
|
||||
|
||||
// ── Connection form ─────────────────────────────────────────────
|
||||
// F.X.fix-pass5 — Clear-button wiring. Marks the matching input
|
||||
// with data-clear="1" + visually flashes the row so the user
|
||||
// knows the next Save will wipe the stored value.
|
||||
document.querySelectorAll('[data-cred-clear]').forEach(btn => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
const fieldName = btn.dataset.credClear;
|
||||
const input = document.querySelector(`[name="cred_${fieldName}"]`);
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
input.dataset.clear = '1';
|
||||
input.classList.add('border-red-400', 'dark:border-red-500');
|
||||
const row = btn.closest('[data-cred-row]');
|
||||
const stored = row?.querySelector('[data-cred-stored]');
|
||||
if (stored) {
|
||||
stored.textContent = '{% if lang == "fa" %}پس از Save پاک میشود{% else %}Will clear on Save{% endif %}';
|
||||
stored.classList.remove('bg-green-100', 'dark:bg-green-900/40', 'text-green-700', 'dark:text-green-300');
|
||||
stored.classList.add('bg-red-100', 'dark:bg-red-900/40', 'text-red-700', 'dark:text-red-300');
|
||||
}
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
@@ -313,13 +491,22 @@
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
const creds = {};
|
||||
// F.X.fix-pass5 — Empty input means "keep existing"; clearing
|
||||
// a stored optional value requires an explicit Clear button
|
||||
// click (which sets data-clear="1" on the input). Old
|
||||
// behaviour silently wiped optional fields on every save.
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (!input) return;
|
||||
const val = input.value.trim();
|
||||
if (val) creds[field.name] = val;
|
||||
else if (input.dataset.required === 'false') creds[field.name] = '';
|
||||
if (val) {
|
||||
creds[field.name] = val;
|
||||
} else if (input.dataset.clear === '1') {
|
||||
// Explicit clear: send empty string to wipe.
|
||||
creds[field.name] = '';
|
||||
}
|
||||
// else: leave out → server keeps existing value.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +578,11 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// F.X.fix-pass2 — exposed so the AI-keys set/delete handlers can
|
||||
// trigger a live refresh of the gating UI after configuring a
|
||||
// provider key (no full-page reload).
|
||||
window.loadToolAccess = loadToolAccess;
|
||||
|
||||
function renderToolAccess() {
|
||||
document.getElementById('tool-access-loading').classList.add('hidden');
|
||||
document.getElementById('tool-access-content').classList.remove('hidden');
|
||||
@@ -642,16 +834,35 @@
|
||||
|
||||
function renderToolRow(tool) {
|
||||
const inScope = isToolInScope(tool);
|
||||
// F.X.fix-pass3 — central prerequisite gating. Backend has
|
||||
// already evaluated `available` + `unavailable_reason` so the
|
||||
// UI just renders accordingly. Reasons we know how to handle:
|
||||
// provider_key — needs an AI provider key on this site
|
||||
// companion_route — needs the Airano MCP Bridge companion plugin
|
||||
// feature — needs an SEO plugin (Rank Math / Yoast)
|
||||
const reason = tool.unavailable_reason || null;
|
||||
const missingProviderKey = reason === 'provider_key';
|
||||
const missingCompanion = reason === 'companion_route';
|
||||
const missingSeoPlugin = reason === 'feature';
|
||||
const missingWpCreds = reason === 'wp_credentials';
|
||||
const unavailable = !tool.available;
|
||||
const dimmed = !inScope || unavailable;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
|
||||
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (dimmed ? ' opacity-40' : '');
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
|
||||
nameEl.className = 'text-sm truncate font-mono ' + (dimmed ? 'text-gray-400 dark:text-gray-500 line-through' : 'text-gray-800 dark:text-gray-200');
|
||||
nameEl.textContent = tool.name;
|
||||
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
|
||||
const titleParts = [tool.description || ''];
|
||||
if (!inScope) titleParts.push('— {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
|
||||
if (missingProviderKey) titleParts.push('— {% if lang == "fa" %}نیاز به تنظیم کلید AI Provider{% else %}needs an AI provider key{% endif %}');
|
||||
if (missingCompanion) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه Airano MCP Bridge{% else %}needs the Airano MCP Bridge companion plugin{% endif %}');
|
||||
if (missingSeoPlugin) titleParts.push('— {% if lang == "fa" %}نیاز به افزونهی SEO (Rank Math یا Yoast){% else %}needs an SEO plugin (Rank Math or Yoast){% endif %}');
|
||||
if (missingWpCreds) titleParts.push('— {% if lang == "fa" %}نیاز به Application Password وردپرس{% else %}needs a WordPress Application Password{% endif %}');
|
||||
nameEl.title = titleParts.join(' ');
|
||||
left.appendChild(nameEl);
|
||||
|
||||
if (!inScope) {
|
||||
@@ -668,29 +879,74 @@
|
||||
left.appendChild(badge);
|
||||
}
|
||||
|
||||
if (missingProviderKey) {
|
||||
const configurePill = document.createElement('button');
|
||||
configurePill.type = 'button';
|
||||
configurePill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-500/30 transition-colors';
|
||||
configurePill.textContent = '{% if lang == "fa" %}⚙ تنظیم کلید{% else %}⚙ Configure key{% endif %}';
|
||||
configurePill.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const section = document.getElementById('aikeys-section');
|
||||
if (section && section.classList.contains('hidden')) {
|
||||
toggleSection('aikeys');
|
||||
}
|
||||
const target = document.getElementById('aikeys-section');
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
left.appendChild(configurePill);
|
||||
} else if (missingCompanion) {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300';
|
||||
pill.textContent = '{% if lang == "fa" %}🧩 افزونهی همراه نیاز است{% else %}🧩 needs companion plugin{% endif %}';
|
||||
left.appendChild(pill);
|
||||
} else if (missingSeoPlugin) {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300';
|
||||
pill.textContent = '{% if lang == "fa" %}🔍 SEO plugin نیاز است{% else %}🔍 needs SEO plugin{% endif %}';
|
||||
left.appendChild(pill);
|
||||
} else if (missingWpCreds) {
|
||||
const pill = document.createElement('button');
|
||||
pill.type = 'button';
|
||||
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300 hover:bg-orange-200 dark:hover:bg-orange-500/30 transition-colors';
|
||||
pill.textContent = '{% if lang == "fa" %}🔑 WP App Password نیاز{% else %}🔑 needs WP App Password{% endif %}';
|
||||
pill.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const link = document.createElement('a');
|
||||
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
|
||||
link.click();
|
||||
});
|
||||
left.appendChild(pill);
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||
label.className = 'relative inline-flex items-center flex-shrink-0 ' + (unavailable ? 'cursor-not-allowed' : 'cursor-pointer');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.className = 'sr-only peer';
|
||||
input.checked = tool.enabled !== false;
|
||||
input.onchange = async () => {
|
||||
const enabled = input.checked;
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) input.checked = !enabled;
|
||||
else {
|
||||
tool.enabled = enabled;
|
||||
const en = allTools.filter(t => t.enabled !== false).length;
|
||||
document.getElementById('tool-summary').textContent =
|
||||
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
|
||||
}
|
||||
} catch (_) { input.checked = !enabled; }
|
||||
};
|
||||
// When prerequisites unmet, force toggle visually OFF regardless
|
||||
// of the user's saved preference — backend filter already drops
|
||||
// the tool from the live endpoint.
|
||||
input.checked = tool.available !== false && tool.enabled !== false;
|
||||
input.disabled = unavailable;
|
||||
if (!unavailable) {
|
||||
input.onchange = async () => {
|
||||
const enabled = input.checked;
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) input.checked = !enabled;
|
||||
else {
|
||||
tool.enabled = enabled;
|
||||
const en = allTools.filter(t => t.enabled !== false).length;
|
||||
document.getElementById('tool-summary').textContent =
|
||||
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
|
||||
}
|
||||
} catch (_) { input.checked = !enabled; }
|
||||
};
|
||||
}
|
||||
const slider = document.createElement('div');
|
||||
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||
label.appendChild(input);
|
||||
@@ -759,6 +1015,252 @@
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Per-site AI provider keys (F.5a.9.x) ────────────────────────
|
||||
{% if provider_keys_supported %}
|
||||
(function () {
|
||||
const flash = document.getElementById('aikeys-flash');
|
||||
function show(kind, msg) {
|
||||
if (!flash) return;
|
||||
flash.textContent = msg;
|
||||
flash.className = 'rounded-lg p-3 text-sm ' + (
|
||||
kind === 'ok'
|
||||
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/30'
|
||||
: 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/30'
|
||||
);
|
||||
flash.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// F.X.fix-pass2 — in-place DOM update after set/delete (no
|
||||
// window.location.reload()), so the AI Image Generation panel
|
||||
// stays expanded and the scroll position is preserved. Also
|
||||
// triggers a Tool Access reload + a capability-badge re-check
|
||||
// so the gating UI reflects the new provider state live.
|
||||
function _updateRowStatus(provider, isSet) {
|
||||
const row = document.querySelector('[data-site-ai-row="' + provider + '"]');
|
||||
if (!row) return;
|
||||
const badge = row.querySelector('span.inline-flex');
|
||||
if (badge) {
|
||||
if (isSet) {
|
||||
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
|
||||
badge.textContent = '{% if lang == "fa" %}تنظیمشده{% else %}Set{% endif %}';
|
||||
} else {
|
||||
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
|
||||
badge.textContent = '{% if lang == "fa" %}تنظیم نشده{% else %}Unset{% endif %}';
|
||||
}
|
||||
}
|
||||
const removeBtn = document.querySelector('[data-site-ai-remove="' + provider + '"]');
|
||||
if (removeBtn) {
|
||||
removeBtn.classList.toggle('hidden', !isSet);
|
||||
}
|
||||
const form = document.querySelector('[data-site-ai-set="' + provider + '"]');
|
||||
if (form) {
|
||||
const input = form.querySelector('input[name="api_key"]');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshLinkedUi() {
|
||||
// Re-fetch tools so the AI tool's gating updates inline.
|
||||
if (typeof window.loadToolAccess === 'function') {
|
||||
window.loadToolAccess();
|
||||
}
|
||||
// Trigger the HTMX re-check button so the capability badge
|
||||
// re-renders with the new ai_providers_configured state.
|
||||
const recheck = document.querySelector('#capability-badge button[hx-get]');
|
||||
if (recheck && window.htmx) {
|
||||
window.htmx.trigger(recheck, 'click');
|
||||
}
|
||||
}
|
||||
|
||||
async function setKey(ev, provider) {
|
||||
ev.preventDefault();
|
||||
const form = ev.target;
|
||||
const fd = new FormData(form);
|
||||
const apiKey = (fd.get('api_key') || '').toString();
|
||||
if (!apiKey || apiKey.length < 8) {
|
||||
show('err', '{% if lang == "fa" %}کلید باید حداقل ۸ کاراکتر باشد{% else %}Key must be at least 8 characters{% endif %}');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ api_key: apiKey }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
show('err', data.message || data.error || 'save failed');
|
||||
return false;
|
||||
}
|
||||
_updateRowStatus(provider, true);
|
||||
_refreshLinkedUi();
|
||||
show('ok', '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %} — last4: ' + (data.secret_last4 || ''));
|
||||
} catch (e) {
|
||||
show('err', e.message || String(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function deleteKey(provider) {
|
||||
if (!window.confirm('{% if lang == "fa" %}آیا مطمئن هستید؟{% else %}Remove this provider key?{% endif %}')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
show('err', data.message || data.error || 'delete failed');
|
||||
return false;
|
||||
}
|
||||
_updateRowStatus(provider, false);
|
||||
_refreshLinkedUi();
|
||||
show('ok', '{% if lang == "fa" %}حذف شد{% else %}Removed{% endif %}');
|
||||
} catch (e) {
|
||||
show('err', e.message || String(e));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// F.X.fix-pass2 — OpenRouter model discovery UI. Lazy-loaded on
|
||||
// first <details> expand so the panel open doesn't pay the
|
||||
// network cost unless the user asks. Falls back gracefully
|
||||
// when no key is set (catalog is public but availability
|
||||
// flags reflect the operator's account when we pass a key).
|
||||
async function _loadOpenRouterModels(details) {
|
||||
if (details.dataset.loaded === '1') return;
|
||||
details.dataset.loaded = '1';
|
||||
const placeholder = details.querySelector('[data-models-placeholder]');
|
||||
const list = details.querySelector('[data-models-list]');
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'/api/providers/openrouter/models?site_id=' + encodeURIComponent(siteId),
|
||||
{ credentials: 'same-origin' }
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
placeholder.textContent = data.message || data.error || '{% if lang == "fa" %}خطا در دریافت لیست{% else %}Failed to load models{% endif %}';
|
||||
return;
|
||||
}
|
||||
const models = Array.isArray(data.models) ? data.models : [];
|
||||
if (!models.length) {
|
||||
placeholder.textContent = '{% if lang == "fa" %}هیچ مدلی یافت نشد{% else %}No image models found{% endif %}';
|
||||
return;
|
||||
}
|
||||
placeholder.classList.add('hidden');
|
||||
list.classList.remove('hidden');
|
||||
list.innerHTML = '';
|
||||
const provider = details.dataset.siteAiModels || 'openrouter';
|
||||
let currentDefault = details.dataset.currentDefault || '';
|
||||
const renderRow = (m) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'flex items-start justify-between gap-2 pb-1 border-b border-gray-200 dark:border-gray-700 last:border-0';
|
||||
const info = document.createElement('div');
|
||||
info.className = 'flex-1 min-w-0';
|
||||
const id = document.createElement('code');
|
||||
id.className = 'font-mono text-gray-800 dark:text-gray-200 break-all';
|
||||
id.textContent = m.id;
|
||||
const nameLine = document.createElement('div');
|
||||
nameLine.className = 'text-[11px] text-gray-500 dark:text-gray-400 mt-0.5';
|
||||
const nameBits = [];
|
||||
if (m.name && m.name !== m.id) nameBits.push(m.name);
|
||||
if (m.price_per_image_usd != null) {
|
||||
nameBits.push('$' + Number(m.price_per_image_usd).toFixed(4) + '/img');
|
||||
}
|
||||
nameLine.textContent = nameBits.join(' · ');
|
||||
info.appendChild(id);
|
||||
if (nameBits.length) info.appendChild(nameLine);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'flex-shrink-0 flex items-center gap-1';
|
||||
|
||||
const isDefault = currentDefault === m.id;
|
||||
const defaultBtn = document.createElement('button');
|
||||
defaultBtn.type = 'button';
|
||||
defaultBtn.className = 'px-2 py-0.5 rounded border text-[11px] transition-colors ' + (
|
||||
isDefault
|
||||
? 'border-emerald-300 dark:border-emerald-600 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
);
|
||||
defaultBtn.textContent = isDefault
|
||||
? '{% if lang == "fa" %}★ پیشفرض{% else %}★ Default{% endif %}'
|
||||
: '{% if lang == "fa" %}پیشفرض{% else %}Set default{% endif %}';
|
||||
defaultBtn.addEventListener('click', async () => {
|
||||
const target = isDefault ? null : m.id;
|
||||
defaultBtn.disabled = true;
|
||||
const orig = defaultBtn.textContent;
|
||||
defaultBtn.textContent = '…';
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'/api/sites/' + encodeURIComponent(siteId) +
|
||||
'/provider-keys/' + encodeURIComponent(provider) +
|
||||
'/default-model',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: target }),
|
||||
credentials: 'same-origin',
|
||||
}
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.ok) {
|
||||
defaultBtn.textContent = orig;
|
||||
show('err', data.message || data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}');
|
||||
return;
|
||||
}
|
||||
currentDefault = target || '';
|
||||
details.dataset.currentDefault = currentDefault;
|
||||
// Re-render the entire list so all rows reflect the new default.
|
||||
list.innerHTML = '';
|
||||
for (const x of models) list.appendChild(renderRow(x));
|
||||
show('ok', '{% if lang == "fa" %}پیشفرض ذخیره شد{% else %}Default saved{% endif %}');
|
||||
} catch (e) {
|
||||
defaultBtn.textContent = orig;
|
||||
show('err', e.message || String(e));
|
||||
} finally {
|
||||
defaultBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const copy = document.createElement('button');
|
||||
copy.type = 'button';
|
||||
copy.className = 'px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
|
||||
copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}';
|
||||
copy.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(m.id);
|
||||
copy.textContent = '{% if lang == "fa" %}کپی شد{% else %}Copied{% endif %}';
|
||||
setTimeout(() => { copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}'; }, 1500);
|
||||
});
|
||||
|
||||
actions.appendChild(defaultBtn);
|
||||
actions.appendChild(copy);
|
||||
li.appendChild(info);
|
||||
li.appendChild(actions);
|
||||
return li;
|
||||
};
|
||||
for (const m of models) list.appendChild(renderRow(m));
|
||||
} catch (e) {
|
||||
placeholder.textContent = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('details[data-site-ai-models]').forEach((d) => {
|
||||
d.addEventListener('toggle', () => {
|
||||
if (d.open) _loadOpenRouterModels(d);
|
||||
});
|
||||
});
|
||||
|
||||
window.__siteAiKeys = { setKey, deleteKey };
|
||||
})();
|
||||
{% endif %}
|
||||
|
||||
// F.X.fix #9: Re-check is now an HTMX partial swap — see the
|
||||
// hx-get attribute on the button in _capability_badge.html. No JS
|
||||
// shim needed here any more.
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadToolAccess();
|
||||
|
||||
@@ -403,6 +403,11 @@ class ToolAccessManager:
|
||||
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||
|
||||
tools = await self.apply_site_toggles(tools, site_id)
|
||||
# F.X.fix-pass3: filter out tools whose central prerequisites
|
||||
# are unmet (no AI provider key, missing companion route,
|
||||
# missing SEO plugin) so the live endpoint never advertises a
|
||||
# tool that would 100% fail at call time.
|
||||
tools = await self.apply_prerequisites_filter(tools, site_id)
|
||||
return tools
|
||||
|
||||
async def toggle_tool(
|
||||
@@ -488,6 +493,19 @@ class ToolAccessManager:
|
||||
Used by the dashboard API to present the per-site management view.
|
||||
Does not apply scope filters — the UI decides what to show.
|
||||
|
||||
F.X.fix #8: each row also carries ``provider_key_required`` and
|
||||
``provider_key_configured`` so the Tool Access template can
|
||||
render AI tools greyed + "Configure key" CTA when the site has
|
||||
no keys for the required provider, instead of letting the user
|
||||
enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
|
||||
|
||||
F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
|
||||
derived from a central :data:`_TOOL_PREREQUISITES` resolver so
|
||||
the UI can auto-grey/disable tools whose requirements (companion
|
||||
route, SEO plugin, AI key) the site doesn't satisfy. This is the
|
||||
same data the live MCP endpoint uses to silently filter the
|
||||
catalog (see :meth:`get_visible_tools`).
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
plugin_type: Plugin type.
|
||||
@@ -504,20 +522,235 @@ class ToolAccessManager:
|
||||
except RuntimeError:
|
||||
toggles = {}
|
||||
|
||||
try:
|
||||
from core.site_api import list_site_providers_set
|
||||
|
||||
configured_providers = await list_site_providers_set(site_id)
|
||||
except Exception: # noqa: BLE001
|
||||
configured_providers = set()
|
||||
|
||||
# F.X.fix-pass3: pull the cached probe payload so we can
|
||||
# evaluate prerequisites for SEO / companion-route tools.
|
||||
# ``probe_site_capabilities`` reuses its 10-min cache so the
|
||||
# cost is one SQLite query in the common path.
|
||||
probe_payload: dict[str, Any] | None = None
|
||||
try:
|
||||
from core.capability_probe import get_probe_cache
|
||||
|
||||
cached = get_probe_cache().get(site_id)
|
||||
if cached is not None:
|
||||
probe_payload = cached
|
||||
except Exception: # noqa: BLE001
|
||||
probe_payload = None
|
||||
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_plugin_type(plugin_type)
|
||||
return [
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"plugin_type": t.plugin_type,
|
||||
"category": t.category,
|
||||
"sensitivity": t.sensitivity,
|
||||
"required_scope": t.required_scope,
|
||||
"enabled": toggles.get(t.name, True),
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for t in tools:
|
||||
available, reason = check_tool_prerequisites(
|
||||
t.name,
|
||||
probe_payload=probe_payload,
|
||||
configured_providers=configured_providers,
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"plugin_type": t.plugin_type,
|
||||
"category": t.category,
|
||||
"sensitivity": t.sensitivity,
|
||||
"required_scope": t.required_scope,
|
||||
"enabled": toggles.get(t.name, True),
|
||||
"provider_key_required": _tool_requires_provider_key(t.name),
|
||||
"provider_key_configured": _tool_has_configured_provider(
|
||||
t.name, configured_providers
|
||||
),
|
||||
"available": available,
|
||||
"unavailable_reason": reason,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
async def apply_prerequisites_filter(
|
||||
self,
|
||||
tools: list[ToolDefinition],
|
||||
site_id: str,
|
||||
) -> list[ToolDefinition]:
|
||||
"""Drop tools whose central prerequisites are not satisfied.
|
||||
|
||||
Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
|
||||
objects so the live MCP endpoint pipeline (``get_visible_tools``)
|
||||
can call it inline. Reads the same cached probe + provider-key
|
||||
set so a fresh probe never gets triggered from this hot path.
|
||||
"""
|
||||
try:
|
||||
from core.site_api import list_site_providers_set
|
||||
|
||||
configured_providers = await list_site_providers_set(site_id)
|
||||
except Exception: # noqa: BLE001
|
||||
configured_providers = set()
|
||||
|
||||
probe_payload: dict[str, Any] | None = None
|
||||
try:
|
||||
from core.capability_probe import get_probe_cache
|
||||
|
||||
cached = get_probe_cache().get(site_id)
|
||||
if cached is not None:
|
||||
probe_payload = cached
|
||||
except Exception: # noqa: BLE001
|
||||
probe_payload = None
|
||||
|
||||
kept: list[ToolDefinition] = []
|
||||
for tool in tools:
|
||||
available, _ = check_tool_prerequisites(
|
||||
tool.name,
|
||||
probe_payload=probe_payload,
|
||||
configured_providers=configured_providers,
|
||||
)
|
||||
if available:
|
||||
kept.append(tool)
|
||||
return kept
|
||||
|
||||
|
||||
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
|
||||
# Today only the AI image generator; expand as more AI tools land.
|
||||
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
|
||||
|
||||
|
||||
def _tool_requires_provider_key(tool_name: str) -> bool:
|
||||
return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
|
||||
|
||||
|
||||
def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
|
||||
"""``True`` iff the site has at least one provider key for this tool.
|
||||
|
||||
The AI image tool is happy with ANY supported provider key (the
|
||||
caller picks which one at call time via the ``provider`` arg) so
|
||||
any non-empty configured set counts.
|
||||
"""
|
||||
if not _tool_requires_provider_key(tool_name):
|
||||
return True
|
||||
return bool(configured)
|
||||
|
||||
|
||||
# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
|
||||
# Single source of truth for "this tool needs X to work". The resolver
|
||||
# below decides per-call whether a tool is *available* on a given site
|
||||
# given the cached probe payload + the site's configured provider keys.
|
||||
# Three predicate kinds:
|
||||
#
|
||||
# provider_key — site needs at least one of the listed AI provider
|
||||
# keys.
|
||||
# companion_route — the companion plugin must advertise the named
|
||||
# route in probe.routes.
|
||||
# feature_any — the WP probe features must include at least one of
|
||||
# the listed feature names (e.g. rank_math / yoast).
|
||||
#
|
||||
# The resolver's output (``available`` + ``unavailable_reason``) is
|
||||
# attached to every tool row in ``list_tools_for_site`` AND used by
|
||||
# ``apply_prerequisites_filter`` to drop unavailable tools from the
|
||||
# live MCP endpoint, so models calling the endpoint never see a tool
|
||||
# that is guaranteed to fail at call time.
|
||||
|
||||
_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
|
||||
# AI image — any provider key is enough; the caller picks at call time.
|
||||
"wordpress_generate_and_upload_image": [
|
||||
{
|
||||
"kind": "provider_key",
|
||||
"any_of": ["openai", "stability", "replicate", "openrouter"],
|
||||
}
|
||||
],
|
||||
# Companion-route-backed WP tools.
|
||||
"wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
|
||||
"wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||
"wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
|
||||
"wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
|
||||
"wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
|
||||
"wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||
"wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||
"wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||
"wordpress_regenerate_thumbnails": [
|
||||
{"kind": "companion_route", "name": "regenerate_thumbnails"}
|
||||
],
|
||||
"wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||
"wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||
# SEO — needs Rank Math (or Yoast in the future). Probe surfaces
|
||||
# both flags; we accept either.
|
||||
"wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||
"wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||
"wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||
"wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||
"wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||
# F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
|
||||
# WC consumer_key + secret can't authenticate. Need a WP App
|
||||
# Password (wp_username + wp_app_password fields, or legacy
|
||||
# username + app_password single-credential mode).
|
||||
"woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
|
||||
"woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
|
||||
"woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
|
||||
# AI image, exposed on both WP and WC plugins. The WC variant
|
||||
# needs both an AI provider key AND WP credentials (to upload
|
||||
# /wp/v2/media); the WP variant only needs the provider key
|
||||
# since its primary client already uses Application Password.
|
||||
"woocommerce_generate_and_upload_image": [
|
||||
{"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
|
||||
{"kind": "wp_credentials"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def check_tool_prerequisites(
|
||||
tool_name: str,
|
||||
*,
|
||||
probe_payload: dict[str, Any] | None,
|
||||
configured_providers: set[str],
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Decide whether a tool's prerequisites are satisfied.
|
||||
|
||||
Returns ``(available, unavailable_reason)``. ``unavailable_reason``
|
||||
is one of ``provider_key`` | ``companion_route`` | ``feature``
|
||||
| ``probe_unknown`` when the tool is unavailable, else ``None``.
|
||||
|
||||
A tool with no prerequisites declared is always available — most
|
||||
of the catalog (plain CRUD over WP REST) needs no companion / no
|
||||
AI key, so the default of "no entry → True" keeps the rule list
|
||||
short.
|
||||
"""
|
||||
rules = _TOOL_PREREQUISITES.get(tool_name)
|
||||
if not rules:
|
||||
return True, None
|
||||
|
||||
routes = ((probe_payload or {}).get("routes")) or {}
|
||||
features = ((probe_payload or {}).get("features")) or {}
|
||||
|
||||
for rule in rules:
|
||||
kind = rule.get("kind")
|
||||
if kind == "provider_key":
|
||||
allowed = set(rule.get("any_of") or [])
|
||||
if allowed and not (allowed & configured_providers):
|
||||
return False, "provider_key"
|
||||
elif kind == "companion_route":
|
||||
name = rule.get("name") or ""
|
||||
# Probe is authoritative when present; absence of probe data
|
||||
# (e.g. companion not installed → companion_available=False)
|
||||
# is also treated as "unavailable".
|
||||
if not routes.get(name):
|
||||
return False, "companion_route"
|
||||
elif kind == "feature_any":
|
||||
names = list(rule.get("names") or [])
|
||||
# WP companion's features dict carries booleans like
|
||||
# rank_math: true / yoast: false. WC has no analogous gate.
|
||||
if not any(features.get(n) for n in names):
|
||||
return False, "feature"
|
||||
elif kind == "wp_credentials":
|
||||
# F.X.fix-pass5 — WC media tools and the WC AI image tool
|
||||
# need a WP Application Password to authenticate
|
||||
# /wp/v2/media. The WC plugin probe surfaces a
|
||||
# ``wp_credentials_present`` flag; absence is a hard fail.
|
||||
if not (probe_payload or {}).get("wp_credentials_present"):
|
||||
return False, "wp_credentials"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
122
core/tool_rate_limiter.py
Normal file
122
core/tool_rate_limiter.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Per-tool, per-user rate limiting (F.5a.6).
|
||||
|
||||
Provides a small token-bucket limiter keyed by (user_id, tool_name) for
|
||||
expensive tools such as AI image generation and chunked-upload finish.
|
||||
Admin / env-fallback callers (``user_id is None``) are exempt.
|
||||
|
||||
Intentionally minimal — separate from :mod:`core.rate_limiter` (which is a
|
||||
global per-client limiter). This one is scoped to specific tools with
|
||||
small hourly caps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.rate_limiter import TokenBucket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Default caps (per hour per user). Documented in ROADMAP F.5a.6.
|
||||
DEFAULT_LIMITS: dict[str, int] = {
|
||||
"wordpress_generate_and_upload_image": 10,
|
||||
"wordpress_upload_media_chunked_finish": 30,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRateLimitError(Exception):
|
||||
"""Raised when a per-tool limit is exceeded."""
|
||||
|
||||
tool_name: str
|
||||
limit_per_hour: int
|
||||
retry_after_seconds: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__init__(
|
||||
f"Per-tool rate limit exceeded for '{self.tool_name}' "
|
||||
f"({self.limit_per_hour}/hour per user). "
|
||||
f"Retry in {self.retry_after_seconds:.0f}s."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"error_code": "TOOL_RATE_LIMITED",
|
||||
"message": str(self),
|
||||
"details": {
|
||||
"tool": self.tool_name,
|
||||
"limit_per_hour": self.limit_per_hour,
|
||||
"retry_after_seconds": round(self.retry_after_seconds, 2),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class PerToolRateLimiter:
|
||||
"""Token-bucket limiter keyed by (user_id, tool_name).
|
||||
|
||||
``check(tool, user_id)`` consumes one token and raises
|
||||
:class:`ToolRateLimitError` when the user is over quota. Admin / env
|
||||
callers (``user_id`` is None or empty) are exempt.
|
||||
"""
|
||||
|
||||
def __init__(self, limits: dict[str, int] | None = None) -> None:
|
||||
self._limits = dict(limits if limits is not None else DEFAULT_LIMITS)
|
||||
self._buckets: dict[tuple[str, str], TokenBucket] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def configure(self, tool_name: str, per_hour: int) -> None:
|
||||
"""Override the per-hour cap for a tool."""
|
||||
self._limits[tool_name] = per_hour
|
||||
# Existing buckets keep their old capacity until reset — tests can
|
||||
# reset() to re-read the new limit.
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._buckets.clear()
|
||||
|
||||
def check(self, tool_name: str, user_id: str | None) -> None:
|
||||
"""Consume one token for (user_id, tool_name). Exempt when user_id is falsy."""
|
||||
if not user_id:
|
||||
return
|
||||
limit = self._limits.get(tool_name)
|
||||
if limit is None or limit <= 0:
|
||||
return
|
||||
|
||||
key = (user_id, tool_name)
|
||||
with self._lock:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
bucket = TokenBucket(capacity=limit, refill_rate=limit / 3600.0)
|
||||
self._buckets[key] = bucket
|
||||
|
||||
if not bucket.consume(1):
|
||||
wait = bucket.get_wait_time(1)
|
||||
logger.warning(
|
||||
"Per-tool rate limit hit: user=%s tool=%s limit=%d/h retry_after=%.1fs",
|
||||
user_id,
|
||||
tool_name,
|
||||
limit,
|
||||
wait,
|
||||
)
|
||||
raise ToolRateLimitError(
|
||||
tool_name=tool_name, limit_per_hour=limit, retry_after_seconds=wait
|
||||
)
|
||||
|
||||
|
||||
_limiter: PerToolRateLimiter | None = None
|
||||
|
||||
|
||||
def get_tool_rate_limiter() -> PerToolRateLimiter:
|
||||
global _limiter
|
||||
if _limiter is None:
|
||||
_limiter = PerToolRateLimiter()
|
||||
return _limiter
|
||||
|
||||
|
||||
def set_tool_rate_limiter(limiter: PerToolRateLimiter | None) -> None:
|
||||
"""Override the singleton (used by tests)."""
|
||||
global _limiter
|
||||
_limiter = limiter
|
||||
474
core/upload_sessions.py
Normal file
474
core/upload_sessions.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""Chunked upload session store (F.5a.5).
|
||||
|
||||
Server-side buffering for large media uploads: SQLite metadata + disk spill.
|
||||
Sessions are deterministic (same user+file metadata yields the same
|
||||
session_id), enabling resumable uploads. Enforces per-user concurrency
|
||||
quota, per-session byte cap, and a 1h TTL reaped by a background task.
|
||||
|
||||
Chunks are appended sequentially; out-of-order or duplicate indexes raise
|
||||
a typed error. Optional full-payload sha256 (supplied at `start`) is
|
||||
verified when the session is finalized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.database import Database, get_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Config ----------------------------------------------------------------
|
||||
|
||||
DEFAULT_SPILL_DIR = Path(os.environ.get("MCPHUB_UPLOAD_SPILL_DIR", "/tmp/mcphub-uploads"))
|
||||
SESSION_TTL = timedelta(seconds=int(os.environ.get("MCPHUB_UPLOAD_TTL_SEC", "3600")))
|
||||
MAX_SESSION_BYTES = int(os.environ.get("MCPHUB_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
|
||||
MAX_CONCURRENT_PER_USER = int(os.environ.get("MCPHUB_UPLOAD_MAX_CONCURRENT", "10"))
|
||||
|
||||
|
||||
# --- Errors ----------------------------------------------------------------
|
||||
|
||||
|
||||
class UploadSessionError(Exception):
|
||||
"""Typed session error with stable JSON code."""
|
||||
|
||||
def __init__(self, code: str, message: str, details: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"error_code": self.code, "message": self.message, "details": self.details}
|
||||
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> datetime:
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def make_session_id(
|
||||
user_id: str,
|
||||
filename: str,
|
||||
total_bytes: int,
|
||||
mime: str | None,
|
||||
sha256: str | None,
|
||||
) -> str:
|
||||
"""Deterministic session id — same tuple → same id (enables resume)."""
|
||||
payload = f"{user_id}|{filename}|{total_bytes}|{mime or ''}|{sha256 or ''}"
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def _ensure_spill_dir(spill_dir: Path) -> None:
|
||||
spill_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(spill_dir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --- Store -----------------------------------------------------------------
|
||||
|
||||
|
||||
class UploadSession:
|
||||
"""Runtime view of an upload session row."""
|
||||
|
||||
__slots__ = (
|
||||
"id",
|
||||
"user_id",
|
||||
"filename",
|
||||
"total_bytes",
|
||||
"mime",
|
||||
"sha256",
|
||||
"received_bytes",
|
||||
"next_chunk",
|
||||
"spill_path",
|
||||
"status",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
for name in self.__slots__:
|
||||
setattr(self, name, kwargs.get(name))
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: dict[str, Any]) -> UploadSession:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
filename=row["filename"],
|
||||
total_bytes=int(row["total_bytes"]),
|
||||
mime=row["mime"],
|
||||
sha256=row["sha256"],
|
||||
received_bytes=int(row["received_bytes"]),
|
||||
next_chunk=int(row["next_chunk"]),
|
||||
spill_path=Path(row["spill_path"]),
|
||||
status=row["status"],
|
||||
created_at=_parse_iso(row["created_at"]),
|
||||
expires_at=_parse_iso(row["expires_at"]),
|
||||
)
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.id,
|
||||
"filename": self.filename,
|
||||
"total_bytes": self.total_bytes,
|
||||
"received_bytes": self.received_bytes,
|
||||
"next_chunk": self.next_chunk,
|
||||
"status": self.status,
|
||||
"expires_at": _iso(self.expires_at),
|
||||
}
|
||||
|
||||
|
||||
class UploadSessionStore:
|
||||
"""Persistent chunk-upload session store with disk spill."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
db: Database | None = None,
|
||||
spill_dir: Path | None = None,
|
||||
ttl: timedelta = SESSION_TTL,
|
||||
max_session_bytes: int = MAX_SESSION_BYTES,
|
||||
max_concurrent_per_user: int = MAX_CONCURRENT_PER_USER,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self.spill_dir = Path(spill_dir or DEFAULT_SPILL_DIR)
|
||||
self.ttl = ttl
|
||||
self.max_session_bytes = max_session_bytes
|
||||
self.max_concurrent_per_user = max_concurrent_per_user
|
||||
self._lock = asyncio.Lock()
|
||||
_ensure_spill_dir(self.spill_dir)
|
||||
|
||||
@property
|
||||
def db(self) -> Database:
|
||||
return self._db or get_database()
|
||||
|
||||
# -- start -------------------------------------------------------------
|
||||
|
||||
async def start(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
filename: str,
|
||||
total_bytes: int,
|
||||
mime: str | None = None,
|
||||
sha256: str | None = None,
|
||||
) -> UploadSession:
|
||||
if total_bytes <= 0:
|
||||
raise UploadSessionError("BAD_SIZE", "total_bytes must be positive.")
|
||||
if total_bytes > self.max_session_bytes:
|
||||
raise UploadSessionError(
|
||||
"SESSION_TOO_LARGE",
|
||||
f"total_bytes {total_bytes} exceeds limit {self.max_session_bytes}.",
|
||||
{"max": self.max_session_bytes},
|
||||
)
|
||||
|
||||
session_id = make_session_id(user_id, filename, total_bytes, mime, sha256)
|
||||
|
||||
async with self._lock:
|
||||
existing = await self._get_row(session_id)
|
||||
if existing is not None:
|
||||
sess = UploadSession.from_row(existing)
|
||||
if sess.status == "open" and sess.expires_at > _utc_now():
|
||||
return sess
|
||||
# Stale/finished — replace
|
||||
await self._delete_row(session_id)
|
||||
_unlink_silent(sess.spill_path)
|
||||
|
||||
open_count = await self._count_open_for_user(user_id)
|
||||
if open_count >= self.max_concurrent_per_user:
|
||||
raise UploadSessionError(
|
||||
"QUOTA_EXCEEDED",
|
||||
f"User has {open_count} open upload sessions "
|
||||
f"(max {self.max_concurrent_per_user}).",
|
||||
{"open": open_count, "max": self.max_concurrent_per_user},
|
||||
)
|
||||
|
||||
now = _utc_now()
|
||||
expires = now + self.ttl
|
||||
spill_path = self.spill_dir / f"{session_id}.part"
|
||||
# Touch an empty spill file with 0600 perms
|
||||
_ensure_spill_dir(self.spill_dir)
|
||||
with open(spill_path, "wb") as f:
|
||||
f.truncate(0)
|
||||
try:
|
||||
os.chmod(spill_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
await self.db.execute(
|
||||
"INSERT INTO upload_sessions "
|
||||
"(id, user_id, filename, total_bytes, mime, sha256, "
|
||||
" received_bytes, next_chunk, spill_path, status, "
|
||||
" created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open', ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
user_id,
|
||||
filename,
|
||||
total_bytes,
|
||||
mime,
|
||||
sha256,
|
||||
str(spill_path),
|
||||
_iso(now),
|
||||
_iso(expires),
|
||||
),
|
||||
)
|
||||
return UploadSession(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
total_bytes=total_bytes,
|
||||
mime=mime,
|
||||
sha256=sha256,
|
||||
received_bytes=0,
|
||||
next_chunk=0,
|
||||
spill_path=spill_path,
|
||||
status="open",
|
||||
created_at=now,
|
||||
expires_at=expires,
|
||||
)
|
||||
|
||||
# -- append chunk ------------------------------------------------------
|
||||
|
||||
async def append_chunk(
|
||||
self,
|
||||
session_id: str,
|
||||
index: int,
|
||||
data: bytes,
|
||||
*,
|
||||
chunk_sha256: str | None = None,
|
||||
) -> UploadSession:
|
||||
async with self._lock:
|
||||
sess = await self._require_open(session_id)
|
||||
if index != sess.next_chunk:
|
||||
raise UploadSessionError(
|
||||
"CHUNK_ORDER",
|
||||
f"Expected chunk index {sess.next_chunk}, got {index}.",
|
||||
{"expected": sess.next_chunk, "got": index},
|
||||
)
|
||||
new_size = sess.received_bytes + len(data)
|
||||
if new_size > sess.total_bytes:
|
||||
raise UploadSessionError(
|
||||
"CHUNK_OVERFLOW",
|
||||
f"Chunk would exceed declared total_bytes "
|
||||
f"({new_size} > {sess.total_bytes}).",
|
||||
{"declared": sess.total_bytes, "would_be": new_size},
|
||||
)
|
||||
if chunk_sha256 is not None:
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual.lower() != chunk_sha256.lower():
|
||||
raise UploadSessionError(
|
||||
"CHUNK_CHECKSUM",
|
||||
"Chunk sha256 does not match supplied value.",
|
||||
{"expected": chunk_sha256, "actual": actual, "index": index},
|
||||
)
|
||||
|
||||
with open(sess.spill_path, "ab") as f:
|
||||
f.write(data)
|
||||
sess.received_bytes = new_size
|
||||
sess.next_chunk = index + 1
|
||||
await self.db.execute(
|
||||
"UPDATE upload_sessions SET received_bytes = ?, next_chunk = ? WHERE id = ?",
|
||||
(sess.received_bytes, sess.next_chunk, session_id),
|
||||
)
|
||||
return sess
|
||||
|
||||
# -- finalize ----------------------------------------------------------
|
||||
|
||||
async def finalize(self, session_id: str) -> tuple[UploadSession, bytes]:
|
||||
"""Read the full spill file and verify. Returns (session, bytes).
|
||||
|
||||
On checksum mismatch the session is kept (status remains 'open') so
|
||||
the caller can retry; on success, the row and spill file are removed.
|
||||
"""
|
||||
async with self._lock:
|
||||
sess = await self._require_open(session_id)
|
||||
if sess.received_bytes != sess.total_bytes:
|
||||
raise UploadSessionError(
|
||||
"INCOMPLETE",
|
||||
f"Received {sess.received_bytes}/{sess.total_bytes} bytes.",
|
||||
{
|
||||
"received": sess.received_bytes,
|
||||
"total": sess.total_bytes,
|
||||
},
|
||||
)
|
||||
with open(sess.spill_path, "rb") as f:
|
||||
data = f.read()
|
||||
if sess.sha256:
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual.lower() != sess.sha256.lower():
|
||||
raise UploadSessionError(
|
||||
"CHECKSUM_MISMATCH",
|
||||
"Assembled sha256 does not match value supplied at start.",
|
||||
{"expected": sess.sha256, "actual": actual},
|
||||
)
|
||||
# Success — drop the session
|
||||
await self._delete_row(session_id)
|
||||
_unlink_silent(sess.spill_path)
|
||||
return sess, data
|
||||
|
||||
# -- abort -------------------------------------------------------------
|
||||
|
||||
async def abort(self, session_id: str) -> bool:
|
||||
async with self._lock:
|
||||
row = await self._get_row(session_id)
|
||||
if row is None:
|
||||
return False
|
||||
sess = UploadSession.from_row(row)
|
||||
await self._delete_row(session_id)
|
||||
_unlink_silent(sess.spill_path)
|
||||
return True
|
||||
|
||||
# -- get ---------------------------------------------------------------
|
||||
|
||||
async def get(self, session_id: str) -> UploadSession | None:
|
||||
row = await self._get_row(session_id)
|
||||
return UploadSession.from_row(row) if row else None
|
||||
|
||||
# -- cleanup -----------------------------------------------------------
|
||||
|
||||
async def cleanup_expired(self, *, now: datetime | None = None) -> int:
|
||||
now = now or _utc_now()
|
||||
rows = await self.db.fetchall(
|
||||
"SELECT * FROM upload_sessions WHERE expires_at < ?", (_iso(now),)
|
||||
)
|
||||
reaped = 0
|
||||
for row in rows:
|
||||
sess = UploadSession.from_row(row)
|
||||
_unlink_silent(sess.spill_path)
|
||||
await self._delete_row(sess.id)
|
||||
reaped += 1
|
||||
if reaped:
|
||||
logger.info("Reaped %d expired upload session(s)", reaped)
|
||||
return reaped
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
async def _get_row(self, session_id: str) -> dict[str, Any] | None:
|
||||
return await self.db.fetchone("SELECT * FROM upload_sessions WHERE id = ?", (session_id,))
|
||||
|
||||
async def _delete_row(self, session_id: str) -> None:
|
||||
await self.db.execute("DELETE FROM upload_sessions WHERE id = ?", (session_id,))
|
||||
|
||||
async def _count_open_for_user(self, user_id: str) -> int:
|
||||
row = await self.db.fetchone(
|
||||
"SELECT COUNT(*) AS c FROM upload_sessions "
|
||||
"WHERE user_id = ? AND status = 'open' AND expires_at > ?",
|
||||
(user_id, _iso(_utc_now())),
|
||||
)
|
||||
return int(row["c"]) if row else 0
|
||||
|
||||
async def _require_open(self, session_id: str) -> UploadSession:
|
||||
row = await self._get_row(session_id)
|
||||
if row is None:
|
||||
raise UploadSessionError(
|
||||
"NO_SESSION", f"Session {session_id} not found.", {"session_id": session_id}
|
||||
)
|
||||
sess = UploadSession.from_row(row)
|
||||
if sess.status != "open":
|
||||
raise UploadSessionError(
|
||||
"BAD_STATE",
|
||||
f"Session {session_id} is in state '{sess.status}'.",
|
||||
{"state": sess.status},
|
||||
)
|
||||
if sess.expires_at <= _utc_now():
|
||||
raise UploadSessionError(
|
||||
"EXPIRED", f"Session {session_id} has expired.", {"session_id": session_id}
|
||||
)
|
||||
return sess
|
||||
|
||||
|
||||
def _unlink_silent(path: Path) -> None:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("Could not remove spill file %s: %s", path, e)
|
||||
|
||||
|
||||
# --- Singleton + background cleanup ---------------------------------------
|
||||
|
||||
_store: UploadSessionStore | None = None
|
||||
|
||||
|
||||
def get_upload_session_store() -> UploadSessionStore:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = UploadSessionStore()
|
||||
return _store
|
||||
|
||||
|
||||
def set_upload_session_store(store: UploadSessionStore | None) -> None:
|
||||
"""Override the singleton (used by tests)."""
|
||||
global _store
|
||||
_store = store
|
||||
|
||||
|
||||
class CleanupTask:
|
||||
"""Periodically reaps expired sessions. Register in server lifespan."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: UploadSessionStore | None = None,
|
||||
interval_seconds: int = 300,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self.interval = interval_seconds
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stop = asyncio.Event()
|
||||
self.id = uuid.uuid4().hex[:8]
|
||||
|
||||
@property
|
||||
def store(self) -> UploadSessionStore:
|
||||
return self._store or get_upload_session_store()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is not None:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._task = asyncio.create_task(self._run(), name=f"upload-cleanup-{self.id}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is None:
|
||||
return
|
||||
self._stop.set()
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await self.store.cleanup_expired()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Upload-session cleanup error: %s", e)
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
|
||||
except TimeoutError:
|
||||
continue
|
||||
@@ -70,12 +70,27 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
||||
def _tools_to_mcp_schema(
|
||||
tools: list[ToolDefinition],
|
||||
*,
|
||||
configured_providers: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
|
||||
|
||||
Strips the auto-injected ``site`` parameter, since user endpoints bind a
|
||||
single site per alias.
|
||||
|
||||
F.X.fix-pass6 — when ``configured_providers`` is given (i.e. the
|
||||
site has at least one AI provider key configured), narrow the
|
||||
``provider`` enum on the AI image tool to that subset. The model
|
||||
only sees providers that will actually succeed, instead of trying
|
||||
OpenAI / Stability / Replicate and getting NO_PROVIDER_KEY when
|
||||
only OpenRouter is configured.
|
||||
"""
|
||||
ai_image_tools = {
|
||||
"wordpress_generate_and_upload_image",
|
||||
"woocommerce_generate_and_upload_image",
|
||||
}
|
||||
result = []
|
||||
for tool_def in tools:
|
||||
schema = deepcopy(tool_def.input_schema)
|
||||
@@ -84,6 +99,22 @@ def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
|
||||
if "required" in schema and "site" in schema["required"]:
|
||||
schema["required"] = [r for r in schema["required"] if r != "site"]
|
||||
|
||||
if (
|
||||
configured_providers
|
||||
and tool_def.name in ai_image_tools
|
||||
and "properties" in schema
|
||||
and "provider" in schema["properties"]
|
||||
):
|
||||
schema["properties"]["provider"] = {
|
||||
**schema["properties"]["provider"],
|
||||
"enum": list(configured_providers),
|
||||
"description": (
|
||||
"AI provider to use. This site has the following providers "
|
||||
f"configured: {', '.join(configured_providers)}. Add more "
|
||||
"in Connection Settings → AI Image Generation."
|
||||
),
|
||||
}
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": tool_def.name,
|
||||
@@ -99,14 +130,41 @@ async def _get_visible_tools_for_site(
|
||||
key_scopes: list[str],
|
||||
plugin_type: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
|
||||
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b).
|
||||
|
||||
F.5a.9.x: additionally hide ``wordpress_generate_and_upload_image`` when
|
||||
the site has no provider API key configured — the tool would fail at
|
||||
call-time with ``NO_PROVIDER_KEY`` anyway, so hiding it keeps the
|
||||
surface honest for AI clients.
|
||||
"""
|
||||
from core.tool_access import get_tool_access_manager
|
||||
|
||||
access = get_tool_access_manager()
|
||||
tools = await access.get_visible_tools(
|
||||
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
|
||||
)
|
||||
return _tools_to_mcp_schema(tools)
|
||||
|
||||
configured_providers: list[str] = []
|
||||
if plugin_type in {"wordpress", "woocommerce"}:
|
||||
from core.site_api import list_site_providers_set
|
||||
|
||||
configured = await list_site_providers_set(site_id)
|
||||
if not configured:
|
||||
tools = [
|
||||
t
|
||||
for t in tools
|
||||
if t.name
|
||||
not in {
|
||||
"wordpress_generate_and_upload_image",
|
||||
"woocommerce_generate_and_upload_image",
|
||||
}
|
||||
]
|
||||
else:
|
||||
# F.X.fix-pass6 — pass the configured set so the AI image
|
||||
# tool's `provider` enum can be narrowed at /tools/list time.
|
||||
configured_providers = sorted(configured)
|
||||
|
||||
return _tools_to_mcp_schema(tools, configured_providers=configured_providers)
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
@@ -466,10 +524,16 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
)
|
||||
|
||||
# Build config dict for plugin instantiation
|
||||
# F.5a.4: pass user_id so plugins can look up per-user secrets.
|
||||
# F.5a.9.x: pass site_id so the WP AI-media handler can resolve
|
||||
# per-site provider API keys (replaces per-user keys for
|
||||
# wordpress_generate_and_upload_image).
|
||||
config_dict = {
|
||||
"site_url": site["url"],
|
||||
"url": site["url"],
|
||||
"alias": alias,
|
||||
"user_id": user_id,
|
||||
"site_id": site["id"],
|
||||
**credentials,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user