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:
@@ -33,11 +33,42 @@ class ConnectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteUnreachableError(ConnectionError):
|
||||
"""Raised when the WordPress site is not reachable at the TCP/DNS layer.
|
||||
|
||||
Subclass of :class:`ConnectionError` so existing ``except ConnectionError``
|
||||
sites keep working. Carries a structured ``error_code='SITE_UNREACHABLE'``
|
||||
plus optional ``install_hint`` so companion-backed handlers and the
|
||||
capability probe can emit a uniform error payload that the dashboard
|
||||
can render as a "check your URL / install companion" prompt in <10s
|
||||
instead of the previous 35-85s hang.
|
||||
"""
|
||||
|
||||
error_code = "SITE_UNREACHABLE"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
install_hint: dict[str, Any] | None = None,
|
||||
reason: str = "site_unreachable",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.install_hint = install_hint
|
||||
self.reason = reason
|
||||
|
||||
|
||||
# Transient HTTP status codes that are worth retrying
|
||||
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
|
||||
|
||||
# Default request timeout in seconds
|
||||
# Default request timeout in seconds (wall clock).
|
||||
_REQUEST_TIMEOUT = 30
|
||||
# Connect timeout: how long to wait for the TCP handshake before giving
|
||||
# up. Five seconds is enough to rule out DNS/TCP failure on any real
|
||||
# site; beyond that the site is down. Short connect + long total lets us
|
||||
# fail fast on unreachable hosts while still allowing slow responses
|
||||
# from reachable ones to complete.
|
||||
_CONNECT_TIMEOUT = 5
|
||||
|
||||
# Retry configuration
|
||||
_MAX_RETRIES = 2
|
||||
@@ -126,7 +157,7 @@ class WordPressClient:
|
||||
"""
|
||||
# Build URL based on endpoint type
|
||||
if use_custom_namespace:
|
||||
# For custom namespaces like airano-mcp-seo-bridge/v1
|
||||
# For custom namespaces like airano-mcp-bridge/v1
|
||||
url = f"{self.site_url}/wp-json/{endpoint}"
|
||||
elif use_woocommerce:
|
||||
# For WooCommerce endpoints
|
||||
@@ -158,8 +189,10 @@ class WordPressClient:
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||
|
||||
# Make request with retry for transient errors
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
# Make request with retry for transient errors. connect=5
|
||||
# short-circuits TCP/DNS failures in <10s (2 retries × 5s each
|
||||
# worst case) instead of the previous 35-85s hang.
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
@@ -209,11 +242,15 @@ class WordPressClient:
|
||||
raise # Never retry auth/config errors
|
||||
|
||||
except TimeoutError:
|
||||
last_exception = ConnectionError(
|
||||
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||
f"The site at {self.site_url} is not responding. "
|
||||
"Possible causes: site is overloaded, network is slow, "
|
||||
"or the server is down."
|
||||
last_exception = SiteUnreachableError(
|
||||
(
|
||||
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||
f"The site at {self.site_url} is not responding. "
|
||||
"Possible causes: site is overloaded, network is "
|
||||
"slow, or the server is down."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_timeout",
|
||||
)
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||
@@ -225,40 +262,61 @@ class WordPressClient:
|
||||
continue
|
||||
|
||||
except aiohttp.ClientConnectorCertificateError as e:
|
||||
raise ConnectionError(
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The site's SSL certificate is invalid or expired. "
|
||||
f"Details: {e}"
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The site's SSL certificate is invalid or expired. "
|
||||
f"Details: {e}"
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_ssl_error",
|
||||
) from e
|
||||
|
||||
except aiohttp.ClientConnectorDNSError as e:
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_dns_error",
|
||||
) from e
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
os_error = getattr(e, "os_error", None)
|
||||
if isinstance(os_error, socket.gaierror):
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_dns_error",
|
||||
) from e
|
||||
|
||||
raise ConnectionError(
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable. Possible causes: "
|
||||
"wrong URL, server is down, firewall blocking, or wrong port."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable. Possible causes: "
|
||||
"wrong URL, server is down, firewall blocking, "
|
||||
"or wrong port."
|
||||
),
|
||||
install_hint=self._site_unreachable_install_hint(),
|
||||
reason="site_connection_refused",
|
||||
) from e
|
||||
|
||||
except aiohttp.InvalidURL:
|
||||
raise ConnectionError(
|
||||
f"Invalid URL: {self.site_url}. "
|
||||
"Please provide a valid URL starting with https:// or http://."
|
||||
raise SiteUnreachableError(
|
||||
(
|
||||
f"Invalid URL: {self.site_url}. "
|
||||
"Please provide a valid URL starting with "
|
||||
"https:// or http://."
|
||||
),
|
||||
reason="site_invalid_url",
|
||||
)
|
||||
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
@@ -277,6 +335,25 @@ class WordPressClient:
|
||||
# All retries exhausted
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
@staticmethod
|
||||
def _site_unreachable_install_hint() -> dict[str, Any]:
|
||||
"""Structured install hint for SITE_UNREACHABLE errors.
|
||||
|
||||
Mirrors the shape produced by
|
||||
``plugins.wordpress.handlers._companion_hint.companion_install_hint``
|
||||
so dashboard code can render one uniform "fix your connection"
|
||||
prompt regardless of whether the error came from the low-level
|
||||
client or a companion-backed handler.
|
||||
"""
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint,
|
||||
)
|
||||
|
||||
return companion_install_hint(
|
||||
min_version="2.9.0",
|
||||
required_capability="manage_options",
|
||||
)
|
||||
|
||||
def _parse_error_response(
|
||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -7,14 +7,48 @@ Each handler is responsible for a specific domain of WordPress operations.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
from plugins.wordpress.handlers.ai_media import AIMediaHandler
|
||||
from plugins.wordpress.handlers.ai_media import get_tool_specifications as get_ai_media_specs
|
||||
from plugins.wordpress.handlers.audit_hook import AuditHookHandler
|
||||
from plugins.wordpress.handlers.audit_hook import (
|
||||
get_tool_specifications as get_audit_hook_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
|
||||
from plugins.wordpress.handlers.bulk_meta import get_tool_specifications as get_bulk_meta_specs
|
||||
from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
|
||||
from plugins.wordpress.handlers.cache_purge import (
|
||||
get_tool_specifications as get_cache_purge_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.capabilities import CapabilitiesHandler
|
||||
from plugins.wordpress.handlers.capabilities import (
|
||||
get_tool_specifications as get_capabilities_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.comments import CommentsHandler
|
||||
from plugins.wordpress.handlers.comments import get_tool_specifications as get_comments_specs
|
||||
from plugins.wordpress.handlers.coupons import CouponsHandler
|
||||
from plugins.wordpress.handlers.coupons import get_tool_specifications as get_coupons_specs
|
||||
from plugins.wordpress.handlers.customers import CustomersHandler
|
||||
from plugins.wordpress.handlers.customers import get_tool_specifications as get_customers_specs
|
||||
from plugins.wordpress.handlers.export import ExportHandler
|
||||
from plugins.wordpress.handlers.export import get_tool_specifications as get_export_specs
|
||||
from plugins.wordpress.handlers.media import MediaHandler
|
||||
from plugins.wordpress.handlers.media import get_tool_specifications as get_media_specs
|
||||
from plugins.wordpress.handlers.media_attach import MediaAttachHandler
|
||||
from plugins.wordpress.handlers.media_attach import (
|
||||
get_tool_specifications as get_media_attach_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_bulk import MediaBulkHandler
|
||||
from plugins.wordpress.handlers.media_bulk import (
|
||||
get_tool_specifications as get_media_bulk_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
|
||||
from plugins.wordpress.handlers.media_chunked import (
|
||||
get_tool_specifications as get_media_chunked_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.media_probe import ProbeHandler
|
||||
from plugins.wordpress.handlers.media_probe import (
|
||||
get_tool_specifications as get_media_probe_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.menus import MenusHandler
|
||||
from plugins.wordpress.handlers.menus import get_tool_specifications as get_menus_specs
|
||||
from plugins.wordpress.handlers.orders import OrdersHandler
|
||||
@@ -23,14 +57,26 @@ from plugins.wordpress.handlers.posts import PostsHandler
|
||||
from plugins.wordpress.handlers.posts import get_tool_specifications as get_posts_specs
|
||||
from plugins.wordpress.handlers.products import ProductsHandler
|
||||
from plugins.wordpress.handlers.products import get_tool_specifications as get_products_specs
|
||||
from plugins.wordpress.handlers.regenerate_thumbnails import RegenerateThumbnailsHandler
|
||||
from plugins.wordpress.handlers.regenerate_thumbnails import (
|
||||
get_tool_specifications as get_regenerate_thumbnails_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.reports import ReportsHandler
|
||||
from plugins.wordpress.handlers.reports import get_tool_specifications as get_reports_specs
|
||||
from plugins.wordpress.handlers.seo import SEOHandler
|
||||
from plugins.wordpress.handlers.seo import get_tool_specifications as get_seo_specs
|
||||
from plugins.wordpress.handlers.site import SiteHandler
|
||||
from plugins.wordpress.handlers.site import get_tool_specifications as get_site_specs
|
||||
from plugins.wordpress.handlers.site_health import SiteHealthHandler
|
||||
from plugins.wordpress.handlers.site_health import (
|
||||
get_tool_specifications as get_site_health_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.taxonomy import TaxonomyHandler
|
||||
from plugins.wordpress.handlers.taxonomy import get_tool_specifications as get_taxonomy_specs
|
||||
from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
|
||||
from plugins.wordpress.handlers.transient_flush import (
|
||||
get_tool_specifications as get_transient_flush_specs,
|
||||
)
|
||||
from plugins.wordpress.handlers.users import UsersHandler
|
||||
from plugins.wordpress.handlers.users import get_tool_specifications as get_users_specs
|
||||
from plugins.wordpress.handlers.wp_cli import WPCLIHandler
|
||||
@@ -40,6 +86,19 @@ __all__ = [
|
||||
# Core Handlers
|
||||
"PostsHandler",
|
||||
"MediaHandler",
|
||||
"MediaAttachHandler",
|
||||
"MediaBulkHandler",
|
||||
"MediaChunkedHandler",
|
||||
"ProbeHandler",
|
||||
"AIMediaHandler",
|
||||
"AuditHookHandler",
|
||||
"BulkMetaHandler",
|
||||
"CachePurgeHandler",
|
||||
"CapabilitiesHandler",
|
||||
"RegenerateThumbnailsHandler",
|
||||
"ExportHandler",
|
||||
"SiteHealthHandler",
|
||||
"TransientFlushHandler",
|
||||
"TaxonomyHandler",
|
||||
"CommentsHandler",
|
||||
"UsersHandler",
|
||||
@@ -57,6 +116,19 @@ __all__ = [
|
||||
# Tool specifications
|
||||
"get_posts_specs",
|
||||
"get_media_specs",
|
||||
"get_media_attach_specs",
|
||||
"get_media_bulk_specs",
|
||||
"get_media_chunked_specs",
|
||||
"get_media_probe_specs",
|
||||
"get_ai_media_specs",
|
||||
"get_audit_hook_specs",
|
||||
"get_bulk_meta_specs",
|
||||
"get_cache_purge_specs",
|
||||
"get_capabilities_specs",
|
||||
"get_regenerate_thumbnails_specs",
|
||||
"get_export_specs",
|
||||
"get_site_health_specs",
|
||||
"get_transient_flush_specs",
|
||||
"get_taxonomy_specs",
|
||||
"get_comments_specs",
|
||||
"get_users_specs",
|
||||
|
||||
65
plugins/wordpress/handlers/_companion_hint.py
Normal file
65
plugins/wordpress/handlers/_companion_hint.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Shared ``companion_unreachable`` error-hint helper.
|
||||
|
||||
Every companion-backed handler (cache_purge, bulk_meta, export,
|
||||
site_health, transient_flush, audit_hook, regenerate_thumbnails,
|
||||
capabilities) returns a structured JSON error when the companion
|
||||
plugin isn't installed / reachable. This module provides a single
|
||||
helper so the hint message, download URL, and install instructions
|
||||
stay in sync.
|
||||
|
||||
F.20 will swap the download URL constant from the GitHub raw path to
|
||||
``wordpress.org/plugins/airano-mcp-bridge/`` once the wp.org listing
|
||||
is live. At that point a single edit here + the dashboard template
|
||||
(``core/templates/dashboard/sites/manage.html``) covers everything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Single source of truth for the companion download URL. Mirror of the
|
||||
# constant used in ``core/dashboard/routes.py``. Kept as a plain string
|
||||
# rather than imported to avoid a core→plugin import cycle.
|
||||
COMPANION_DOWNLOAD_URL = (
|
||||
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
|
||||
)
|
||||
|
||||
|
||||
def companion_install_hint(
|
||||
*,
|
||||
min_version: str,
|
||||
required_capability: str = "manage_options",
|
||||
route: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return a ``hint`` dict that every companion-backed handler can
|
||||
merge into its ``companion_unreachable`` payload.
|
||||
|
||||
Args:
|
||||
min_version: Earliest companion plugin version that exposes the
|
||||
route this handler uses (e.g. ``"2.4.0"``). Surfaced in the
|
||||
install hint so the user knows what they need.
|
||||
required_capability: WordPress capability the calling user needs
|
||||
for the companion route. Default ``manage_options``.
|
||||
route: Optional route path — included in the hint when set so
|
||||
the user can sanity-check by hitting the endpoint directly.
|
||||
|
||||
Returns:
|
||||
Dict with ``install_url``, ``install_instructions``, and
|
||||
``required_capability``. Callers merge into their existing
|
||||
structured error response alongside ``error`` / ``message``.
|
||||
"""
|
||||
instructions = (
|
||||
f"Install the Airano MCP Bridge companion plugin (v{min_version}+) "
|
||||
"on the WordPress site. Download the zip, then in the WP admin: "
|
||||
"Plugins → Add New → Upload Plugin → select the file → Activate. "
|
||||
f"Ensure the Application Password user has the ``{required_capability}`` "
|
||||
"capability. Run ``wordpress_probe_capabilities`` to verify the "
|
||||
"route is advertised."
|
||||
)
|
||||
out = {
|
||||
"install_url": COMPANION_DOWNLOAD_URL,
|
||||
"install_instructions": instructions,
|
||||
"required_capability": required_capability,
|
||||
"companion_min_version": min_version,
|
||||
}
|
||||
if route:
|
||||
out["route"] = route
|
||||
return out
|
||||
407
plugins/wordpress/handlers/_media_core.py
Normal file
407
plugins/wordpress/handlers/_media_core.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Shared raw-binary upload primitive for WordPress media library.
|
||||
|
||||
WP REST `/wp/v2/media` expects the raw binary file body with a `Content-Disposition`
|
||||
header (NOT multipart/form-data). Metadata fields like alt_text/caption/title must
|
||||
be set via a follow-up `POST /media/{id}` JSON call.
|
||||
|
||||
F.5a.7: when the airano-mcp companion plugin is present AND the file size
|
||||
exceeds the site's advertised ``upload_max_filesize``, prefer the companion
|
||||
``POST /airano-mcp/v1/upload-chunk`` route which reads the body via
|
||||
``php://input`` (bypasses ``upload_max_filesize``). On any failure from the
|
||||
companion route we fall back to the standard ``/wp/v2/media`` path so we
|
||||
never regress the default upload behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_security import (
|
||||
ALLOWED_MIMES,
|
||||
DEFAULT_MAX_BYTES,
|
||||
UploadError,
|
||||
content_disposition,
|
||||
safe_filename,
|
||||
sniff_mime,
|
||||
validate_mime,
|
||||
validate_size,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.wordpress.media")
|
||||
|
||||
_UPLOAD_TIMEOUT = 120 # seconds
|
||||
|
||||
# Endpoint suffix (under /wp-json/) for the companion upload-chunk route.
|
||||
_COMPANION_UPLOAD_ENDPOINT = "airano-mcp/v1/upload-chunk"
|
||||
# F.5a.8.5: single-call upload + attach + featured via companion v2.9.0+.
|
||||
_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT = "airano-mcp/v1/upload-and-attach"
|
||||
|
||||
|
||||
async def _should_use_companion(client: WordPressClient, size: int) -> bool:
|
||||
"""F.5a.7: decide whether to prefer the companion upload-chunk route.
|
||||
|
||||
True when the cached probe result marks the companion helper as available
|
||||
AND the payload size exceeds the site's advertised ``upload_max_filesize``
|
||||
(falling back to the effective ceiling when that specific key is unset).
|
||||
|
||||
We only consult the *cached* probe — never trigger a fresh probe from the
|
||||
upload path — so a cold cache simply degrades to the standard route.
|
||||
"""
|
||||
try:
|
||||
from plugins.wordpress.handlers.media_probe import get_cached_limits
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
try:
|
||||
cached = await get_cached_limits(client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug("Companion probe lookup failed: %s", exc)
|
||||
return False
|
||||
if not cached or not cached.get("companion_available"):
|
||||
return False
|
||||
|
||||
limits_bytes = cached.get("limits_bytes") or {}
|
||||
ceiling = (
|
||||
limits_bytes.get("upload_max_filesize")
|
||||
or limits_bytes.get("wp_max_upload_size")
|
||||
or limits_bytes.get("effective_ceiling")
|
||||
)
|
||||
if ceiling is None or ceiling <= 0:
|
||||
return False
|
||||
return size > ceiling
|
||||
|
||||
|
||||
async def _companion_has_upload_and_attach(client: WordPressClient) -> bool:
|
||||
"""F.5a.8.5: does the cached capability probe advertise the
|
||||
``upload_and_attach`` route?
|
||||
|
||||
We only check the cached result — never force a fresh probe from
|
||||
the upload path. If the cache is cold, we conservatively return
|
||||
False and the caller falls back to the 3-step path; the probe
|
||||
will warm up on the next dashboard visit.
|
||||
"""
|
||||
try:
|
||||
from plugins.wordpress.handlers.capabilities import get_cached_capabilities
|
||||
except Exception: # pragma: no cover
|
||||
return False
|
||||
try:
|
||||
cached = await get_cached_capabilities(client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug("Companion capability cache lookup failed: %s", exc)
|
||||
return False
|
||||
if not cached or not cached.get("companion_available"):
|
||||
return False
|
||||
routes = cached.get("routes") or {}
|
||||
return bool(routes.get("upload_and_attach"))
|
||||
|
||||
|
||||
async def _companion_upload_and_attach(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
sniffed: str,
|
||||
disposition: str,
|
||||
attach_to_post: int | None,
|
||||
set_featured: bool,
|
||||
title: str | None,
|
||||
alt_text: str | None,
|
||||
caption: str | None,
|
||||
description: str | None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload + metadata + attach + featured in a single companion call.
|
||||
|
||||
Raises ``UploadError`` on any non-2xx response so the caller can
|
||||
fall back to ``wp/v2/media`` + separate metadata calls.
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if attach_to_post and attach_to_post > 0:
|
||||
params["attach_to_post"] = str(int(attach_to_post))
|
||||
if set_featured and attach_to_post and attach_to_post > 0:
|
||||
params["set_featured"] = "true"
|
||||
if title:
|
||||
params["title"] = str(title)
|
||||
if alt_text:
|
||||
params["alt_text"] = str(alt_text)
|
||||
if caption:
|
||||
params["caption"] = str(caption)
|
||||
if description:
|
||||
params["description"] = str(description)
|
||||
|
||||
url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT}"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
# F.X.fix #7: pass the caller's idempotency key through to the
|
||||
# companion so a retry after client timeout returns the original
|
||||
# attachment instead of creating an "-2.webp" orphan.
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = str(idempotency_key)
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers, params=params) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"COMPANION_{response.status}",
|
||||
f"Companion upload-and-attach failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
return await response.json(content_type=None)
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"COMPANION_BAD_RESPONSE",
|
||||
f"Companion upload-and-attach returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def _companion_raw_upload(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
sniffed: str,
|
||||
disposition: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload via the companion ``POST /airano-mcp/v1/upload-chunk`` route.
|
||||
|
||||
Raises ``UploadError`` on any non-2xx response so the caller can fall
|
||||
back to the standard /wp/v2/media path.
|
||||
"""
|
||||
url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_ENDPOINT}"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"COMPANION_{response.status}",
|
||||
f"Companion upload-chunk failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
return await response.json(content_type=None)
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"COMPANION_BAD_RESPONSE",
|
||||
f"Companion upload returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def wp_raw_upload(
|
||||
client: WordPressClient,
|
||||
data: bytes,
|
||||
*,
|
||||
filename: str | None,
|
||||
mime_hint: str | None = None,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
allowed_mimes: set[str] = ALLOWED_MIMES,
|
||||
# F.5a.8.5: optional attach + featured + metadata params. When any of
|
||||
# these is provided AND the companion's upload-and-attach route is
|
||||
# advertised in the cached capability probe, we POST to the single-
|
||||
# call endpoint instead of doing the 3-step REST dance. Returned
|
||||
# dict is marked ``_upload_route="companion_unified"`` so the caller
|
||||
# (``_apply_metadata_and_attach``) can skip the separate metadata /
|
||||
# featured-image calls.
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
description: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload raw bytes to WP media library. Returns the attachment dict from WP.
|
||||
|
||||
When the companion plugin advertises limits smaller than the payload, we
|
||||
POST to ``/airano-mcp/v1/upload-chunk`` first and only fall back to
|
||||
``/wp/v2/media`` if the companion route errors.
|
||||
|
||||
F.5a.8.5: when ``attach_to_post`` or any metadata field is set AND the
|
||||
companion advertises the ``upload_and_attach`` route, we prefer the
|
||||
single-call path which bundles upload + metadata + attach + featured
|
||||
into one PHP request.
|
||||
"""
|
||||
validate_size(data, max_bytes=max_bytes)
|
||||
sniffed = sniff_mime(data, hint=mime_hint)
|
||||
validate_mime(sniffed, allowed=allowed_mimes)
|
||||
|
||||
ascii_name, encoded = safe_filename(filename, mime=sniffed)
|
||||
disposition = content_disposition(ascii_name, encoded)
|
||||
|
||||
# F.5a.8.5 unified route: tried first when the caller wants metadata
|
||||
# applied AND the companion supports it. Falls back to the legacy
|
||||
# path on any error (companion 4xx/5xx, route not advertised, etc.).
|
||||
has_metadata_intent = (
|
||||
any(v is not None for v in (attach_to_post, title, alt_text, caption, description))
|
||||
or set_featured
|
||||
)
|
||||
if has_metadata_intent and await _companion_has_upload_and_attach(client):
|
||||
try:
|
||||
result = await _companion_upload_and_attach(
|
||||
client,
|
||||
data,
|
||||
sniffed=sniffed,
|
||||
disposition=disposition,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
description=description,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
result["_upload_route"] = "companion_unified"
|
||||
return result
|
||||
except UploadError as exc:
|
||||
_logger.warning(
|
||||
"Companion upload-and-attach failed (%s); falling back to "
|
||||
"/upload-chunk + separate metadata calls",
|
||||
exc.code,
|
||||
)
|
||||
|
||||
# F.5a.7 route selection (non-fatal; falls back to /wp/v2/media on error).
|
||||
if await _should_use_companion(client, len(data)):
|
||||
try:
|
||||
result = await _companion_raw_upload(
|
||||
client,
|
||||
data,
|
||||
sniffed=sniffed,
|
||||
disposition=disposition,
|
||||
)
|
||||
result["_upload_route"] = "companion"
|
||||
return result
|
||||
except UploadError as exc:
|
||||
_logger.warning(
|
||||
"Companion upload-chunk failed (%s); falling back to /wp/v2/media",
|
||||
exc.code,
|
||||
)
|
||||
|
||||
url = f"{client.api_base}/media"
|
||||
headers = {
|
||||
"Authorization": client.auth_header,
|
||||
"Content-Type": sniffed,
|
||||
"Content-Disposition": disposition,
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, data=data, headers=headers) as response:
|
||||
text = await response.text()
|
||||
if response.status == 413:
|
||||
raise UploadError(
|
||||
"WP_413",
|
||||
"WordPress rejected upload (413 Payload Too Large). "
|
||||
"Site's upload_max_filesize / post_max_size is below the file size.",
|
||||
{"status": 413, "body": text[:500]},
|
||||
)
|
||||
if response.status in (401, 403):
|
||||
raise UploadError(
|
||||
"WP_AUTH",
|
||||
f"WordPress rejected auth ({response.status}). Verify Application Password.",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
if response.status >= 400:
|
||||
raise UploadError(
|
||||
f"WP_{response.status}",
|
||||
f"WordPress upload failed: HTTP {response.status}",
|
||||
{"status": response.status, "body": text[:500]},
|
||||
)
|
||||
try:
|
||||
result = await response.json(content_type=None)
|
||||
if isinstance(result, dict):
|
||||
result["_upload_route"] = "rest"
|
||||
return result
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"WP_BAD_RESPONSE",
|
||||
f"WP upload returned non-JSON response: {e}",
|
||||
{"body": text[:500]},
|
||||
) from e
|
||||
|
||||
|
||||
async def wp_update_media_metadata(
|
||||
client: WordPressClient,
|
||||
media_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
description: str | None = None,
|
||||
post: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply metadata fields via POST /media/{id}. Only sends non-None fields."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if alt_text is not None:
|
||||
payload["alt_text"] = alt_text
|
||||
if caption is not None:
|
||||
payload["caption"] = caption
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if post is not None:
|
||||
payload["post"] = post
|
||||
if not payload:
|
||||
return {}
|
||||
return await client.post(f"media/{media_id}", json_data=payload)
|
||||
|
||||
|
||||
async def wp_set_featured_media(
|
||||
client: WordPressClient, post_id: int, media_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Set a post's featured image."""
|
||||
return await client.post(f"posts/{post_id}", json_data={"featured_media": media_id})
|
||||
|
||||
|
||||
async def fetch_url_bytes(
|
||||
url: str,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
timeout_sec: int = 60,
|
||||
user_agent: str = "MCPHub-MediaUploader/1.0",
|
||||
resolved_ip: str | None = None,
|
||||
) -> tuple[bytes, str | None, str]:
|
||||
"""Download up to `max_bytes` from URL. Streams and enforces the limit.
|
||||
|
||||
Returns (data, content_type, filename_guess).
|
||||
"""
|
||||
filename_guess = url.rsplit("/", 1)[-1].split("?")[0] or "download"
|
||||
headers = {"User-Agent": user_agent}
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_sec)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers, allow_redirects=True) as resp:
|
||||
if resp.status >= 400:
|
||||
raise UploadError(
|
||||
"URL_FETCH_FAILED",
|
||||
f"Download failed: HTTP {resp.status}",
|
||||
{"status": resp.status, "url": url},
|
||||
)
|
||||
declared_ct = resp.headers.get("Content-Type")
|
||||
# Stream read with byte cap
|
||||
buf = bytearray()
|
||||
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||
buf.extend(chunk)
|
||||
if len(buf) > max_bytes:
|
||||
raise UploadError(
|
||||
"TOO_LARGE",
|
||||
f"Remote file exceeds limit of {max_bytes} bytes while streaming.",
|
||||
{"max": max_bytes, "url": url},
|
||||
)
|
||||
_ = asyncio # keep import for callers if needed
|
||||
return bytes(buf), declared_ct, filename_guess
|
||||
196
plugins/wordpress/handlers/_media_optimize.py
Normal file
196
plugins/wordpress/handlers/_media_optimize.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""F.5a.2 / F.5a.8.1: Image optimization pipeline using Pillow.
|
||||
|
||||
Defaults for web publishing: JPEG quality 85, PNG→JPEG threshold via opacity check,
|
||||
max long edge 2560 px, EXIF strip, animated GIF preserved untouched.
|
||||
|
||||
F.5a.8.1 adds an optional format conversion stage: when ``convert_to`` is
|
||||
``"webp"`` or ``"avif"`` (or the env var ``WP_MEDIA_CONVERT_TO`` is set),
|
||||
raster inputs are re-encoded in that modern format regardless of source
|
||||
type. Transparency is preserved; animated GIFs are still left untouched.
|
||||
|
||||
Returns the (possibly reduced) bytes and the (possibly updated) MIME type.
|
||||
Non-raster types (pdf, svg, video, audio) pass through unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
_logger = logging.getLogger("mcphub.wordpress.media.optimize")
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
Image = None # type: ignore
|
||||
ImageOps = None # type: ignore
|
||||
|
||||
_DEFAULT_MAX_EDGE = int(os.environ.get("WP_MEDIA_MAX_EDGE", "2560"))
|
||||
_DEFAULT_JPEG_QUALITY = int(os.environ.get("WP_MEDIA_JPEG_QUALITY", "85"))
|
||||
# F.5a.8.1: optional output format override.
|
||||
# "" → no conversion (current default, keeps source format)
|
||||
# "webp" → convert raster images to image/webp
|
||||
# "avif" → convert raster images to image/avif (requires Pillow≥9.2 with AVIF)
|
||||
_DEFAULT_CONVERT_TO = os.environ.get("WP_MEDIA_CONVERT_TO", "").strip().lower()
|
||||
|
||||
_RASTER_MIMES = {"image/jpeg", "image/png", "image/webp", "image/bmp", "image/tiff"}
|
||||
|
||||
# Format map for the convert_to override. Values are (Pillow format, output MIME).
|
||||
_CONVERT_TO_FORMATS: dict[str, tuple[str, str]] = {
|
||||
"webp": ("WEBP", "image/webp"),
|
||||
"avif": ("AVIF", "image/avif"),
|
||||
}
|
||||
|
||||
|
||||
def _avif_supported() -> bool:
|
||||
"""Return True if the running Pillow actually decodes/encodes AVIF.
|
||||
|
||||
We check at call time rather than import time so environments that
|
||||
upgrade Pillow without restarting still see support as it becomes
|
||||
available.
|
||||
"""
|
||||
if Image is None:
|
||||
return False
|
||||
# Pillow registers the extension lazily; probing for a writer is more
|
||||
# reliable than checking the features module (which doesn't list AVIF
|
||||
# on every version).
|
||||
try:
|
||||
return "AVIF" in Image.registered_extensions().values()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
|
||||
def optimize(
|
||||
data: bytes,
|
||||
mime_hint: str | None,
|
||||
*,
|
||||
max_edge: int = _DEFAULT_MAX_EDGE,
|
||||
jpeg_quality: int = _DEFAULT_JPEG_QUALITY,
|
||||
strip_exif: bool = True,
|
||||
convert_to: str | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Resize/recompress raster images.
|
||||
|
||||
Args:
|
||||
data: raw image bytes.
|
||||
mime_hint: best-guess MIME from sniff/client; non-raster types are
|
||||
passed through unchanged.
|
||||
max_edge: long-edge pixel limit (default ``WP_MEDIA_MAX_EDGE`` or 2560).
|
||||
jpeg_quality: q parameter for JPEG / WebP / AVIF encoders.
|
||||
strip_exif: when True, apply ``ImageOps.exif_transpose`` to bake in
|
||||
rotation then drop the metadata.
|
||||
convert_to: force output format regardless of source. ``"webp"`` or
|
||||
``"avif"``; ``None`` or ``""`` falls back to
|
||||
``WP_MEDIA_CONVERT_TO`` then to source-format heuristics. When
|
||||
AVIF is requested but Pillow lacks AVIF support, falls back to
|
||||
WebP rather than silently writing the source bytes.
|
||||
|
||||
Returns:
|
||||
``(new_bytes, new_mime_or_original)``. If optimization produced
|
||||
larger output without resizing, the original bytes are returned.
|
||||
"""
|
||||
if Image is None:
|
||||
return data, mime_hint
|
||||
|
||||
if mime_hint and mime_hint not in _RASTER_MIMES:
|
||||
return data, mime_hint
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
except Exception:
|
||||
return data, mime_hint
|
||||
|
||||
# Preserve animated GIFs untouched (single-frame re-encode would break them)
|
||||
if getattr(img, "is_animated", False):
|
||||
return data, mime_hint
|
||||
|
||||
fmt = (img.format or "").upper() # capture BEFORE exif_transpose strips .format
|
||||
|
||||
try:
|
||||
img = ImageOps.exif_transpose(img) if strip_exif else img
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
w, h = img.size
|
||||
long_edge = max(w, h)
|
||||
|
||||
resized = False
|
||||
if long_edge > max_edge:
|
||||
scale = max_edge / long_edge
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
resized = True
|
||||
|
||||
# F.5a.8.1: explicit convert_to wins over the env default.
|
||||
requested_convert = (convert_to or _DEFAULT_CONVERT_TO or "").strip().lower()
|
||||
if requested_convert == "avif" and not _avif_supported():
|
||||
_logger.info("AVIF requested but Pillow lacks AVIF support; falling back to WebP.")
|
||||
requested_convert = "webp"
|
||||
|
||||
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
|
||||
|
||||
if requested_convert in _CONVERT_TO_FORMATS:
|
||||
out_fmt, out_mime = _CONVERT_TO_FORMATS[requested_convert]
|
||||
# Both WebP and AVIF support alpha; convert palette images to RGBA/RGB
|
||||
# so the encoder has a consistent colour model.
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA" if has_alpha else "RGB")
|
||||
elif img.mode == "LA":
|
||||
img = img.convert("RGBA")
|
||||
elif img.mode == "L":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "PNG" and not has_alpha:
|
||||
# Opaque PNG → JPEG is typically smaller
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "JPEG":
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "WEBP":
|
||||
out_fmt = "WEBP"
|
||||
out_mime = "image/webp"
|
||||
elif fmt == "PNG":
|
||||
out_fmt = "PNG"
|
||||
out_mime = "image/png"
|
||||
else:
|
||||
# BMP/TIFF etc — convert to JPEG for web delivery
|
||||
out_fmt = "JPEG"
|
||||
out_mime = "image/jpeg"
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
buf = io.BytesIO()
|
||||
save_kwargs: dict = {"optimize": True}
|
||||
if out_fmt == "JPEG":
|
||||
save_kwargs.update({"quality": jpeg_quality, "progressive": True})
|
||||
elif out_fmt == "WEBP":
|
||||
save_kwargs.update({"quality": jpeg_quality, "method": 6})
|
||||
elif out_fmt == "AVIF":
|
||||
save_kwargs.update({"quality": jpeg_quality})
|
||||
img.save(buf, format=out_fmt, **save_kwargs)
|
||||
new_bytes = buf.getvalue()
|
||||
|
||||
# Explicit format conversion is honoured even if the converted bytes
|
||||
# are larger than the source (the caller asked for a format switch on
|
||||
# purpose — e.g. to serve WebP regardless). The size guard only applies
|
||||
# to the implicit recompression path.
|
||||
if not requested_convert and not resized and len(new_bytes) >= len(data):
|
||||
return data, mime_hint
|
||||
|
||||
_logger.debug(
|
||||
"optimized %s %dx%d %dB -> %s %dB (resized=%s, convert=%s)",
|
||||
fmt,
|
||||
w,
|
||||
h,
|
||||
len(data),
|
||||
out_fmt,
|
||||
len(new_bytes),
|
||||
resized,
|
||||
requested_convert or "-",
|
||||
)
|
||||
return new_bytes, out_mime
|
||||
240
plugins/wordpress/handlers/_media_security.py
Normal file
240
plugins/wordpress/handlers/_media_security.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Media upload security primitives: MIME sniff, size validation, SSRF guard, filename safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import magic as _magic # type: ignore
|
||||
except Exception:
|
||||
_magic = None
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
"""Structured upload error with stable code for JSON responses."""
|
||||
|
||||
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}
|
||||
|
||||
|
||||
DEFAULT_MAX_BYTES = int(os.environ.get("WP_MEDIA_MAX_MB", "10")) * 1024 * 1024
|
||||
|
||||
ALLOWED_MIMES: set[str] = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/avif",
|
||||
"image/heic",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"application/pdf",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
}
|
||||
|
||||
_EXT_MAP = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/avif": ".avif",
|
||||
"image/heic": ".heic",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
}
|
||||
|
||||
|
||||
def _builtin_sniff(data: bytes) -> str | None:
|
||||
"""Minimal magic-byte sniffer for common media types (libmagic-free fallback)."""
|
||||
if not data:
|
||||
return None
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if data[:4] == b"GIF8":
|
||||
return "image/gif"
|
||||
if data.startswith(b"%PDF-"):
|
||||
return "application/pdf"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
|
||||
return "audio/wav"
|
||||
if data[4:12] in (b"ftypmp42", b"ftypisom", b"ftypMSNV", b"ftypavc1"):
|
||||
return "video/mp4"
|
||||
if data[4:8] == b"ftyp":
|
||||
# Generic ISO BMFF — likely mp4/heic
|
||||
brand = data[8:12]
|
||||
if brand in (b"heic", b"heix", b"mif1"):
|
||||
return "image/heic"
|
||||
return "video/mp4"
|
||||
if data.startswith(b"\x1aE\xdf\xa3"):
|
||||
return "video/webm"
|
||||
if data.startswith(b"ID3") or data[:2] == b"\xff\xfb":
|
||||
return "audio/mpeg"
|
||||
if data.startswith(b"OggS"):
|
||||
return "audio/ogg"
|
||||
return None
|
||||
|
||||
|
||||
def sniff_mime(data: bytes, *, hint: str | None = None) -> str:
|
||||
"""Detect MIME from magic bytes. Uses libmagic if available, else built-in sniff."""
|
||||
if _magic is not None:
|
||||
try:
|
||||
detected = _magic.from_buffer(data[:4096], mime=True)
|
||||
if detected and detected != "application/octet-stream":
|
||||
return detected
|
||||
except Exception:
|
||||
pass
|
||||
built_in = _builtin_sniff(data)
|
||||
if built_in:
|
||||
return built_in
|
||||
if hint and "/" in hint:
|
||||
return hint.lower()
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def validate_size(data: bytes, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
|
||||
if len(data) == 0:
|
||||
raise UploadError("EMPTY_FILE", "Upload data is empty.")
|
||||
if len(data) > max_bytes:
|
||||
raise UploadError(
|
||||
"TOO_LARGE",
|
||||
f"File is {len(data)} bytes; limit is {max_bytes} bytes "
|
||||
f"(~{max_bytes // 1024 // 1024} MB). Use chunked upload for larger files.",
|
||||
{"size": len(data), "max": max_bytes},
|
||||
)
|
||||
|
||||
|
||||
def validate_mime(mime: str, *, allowed: Iterable[str] = ALLOWED_MIMES) -> None:
|
||||
if mime not in allowed:
|
||||
raise UploadError(
|
||||
"MIME_REJECTED",
|
||||
f"MIME type '{mime}' is not allowed. " f"Supported: {', '.join(sorted(allowed))}.",
|
||||
{"mime": mime},
|
||||
)
|
||||
|
||||
|
||||
_FILENAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def safe_filename(filename: str | None, *, mime: str) -> tuple[str, str | None]:
|
||||
"""Return (ascii_filename, rfc5987_encoded_original_if_non_ascii)."""
|
||||
original = (filename or "").strip() or "upload"
|
||||
# Strip any path components
|
||||
original = original.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
|
||||
try:
|
||||
original.encode("ascii")
|
||||
ascii_name = _FILENAME_SAFE_RE.sub("_", original)
|
||||
encoded = None
|
||||
except UnicodeEncodeError:
|
||||
from urllib.parse import quote
|
||||
|
||||
ascii_name = _FILENAME_SAFE_RE.sub(
|
||||
"_", original.encode("ascii", "ignore").decode() or "upload"
|
||||
)
|
||||
encoded = "UTF-8''" + quote(original, safe="")
|
||||
|
||||
# Ensure extension matches MIME
|
||||
ext = _EXT_MAP.get(mime) or (mimetypes.guess_extension(mime) or "")
|
||||
if ext and not ascii_name.lower().endswith(ext):
|
||||
base = ascii_name.rsplit(".", 1)[0] if "." in ascii_name else ascii_name
|
||||
ascii_name = f"{base}{ext}"
|
||||
|
||||
return ascii_name[:255], encoded
|
||||
|
||||
|
||||
def content_disposition(filename_ascii: str, filename_encoded: str | None) -> str:
|
||||
"""Build Content-Disposition header, optionally with RFC 5987 filename* for non-ASCII."""
|
||||
base = f'attachment; filename="{filename_ascii}"'
|
||||
if filename_encoded:
|
||||
return f"{base}; filename*={filename_encoded}"
|
||||
return base
|
||||
|
||||
|
||||
# --- SSRF guard ------------------------------------------------------------
|
||||
|
||||
_BLOCKED_HOSTS = {
|
||||
"metadata.google.internal",
|
||||
"metadata",
|
||||
"169.254.169.254",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSRFCheck:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
resolved_ip: str | None = None
|
||||
|
||||
|
||||
def ssrf_check(url: str, *, allow_http: bool = False) -> SSRFCheck:
|
||||
"""Reject URLs pointing to private/loopback/link-local/metadata endpoints."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception as e:
|
||||
return SSRFCheck(False, f"URL parse failed: {e}")
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return SSRFCheck(False, f"Scheme '{parsed.scheme}' not allowed; use https (or http).")
|
||||
if parsed.scheme == "http" and not allow_http:
|
||||
return SSRFCheck(False, "HTTP URLs are disabled; use HTTPS.")
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host:
|
||||
return SSRFCheck(False, "URL has no host.")
|
||||
if host in _BLOCKED_HOSTS:
|
||||
return SSRFCheck(False, f"Host '{host}' is on the SSRF blocklist.")
|
||||
|
||||
# Resolve and check each IP
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror as e:
|
||||
return SSRFCheck(False, f"DNS resolution failed: {e}")
|
||||
|
||||
seen: list[str] = []
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
seen.append(ip_str)
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
return SSRFCheck(False, f"Unparseable IP '{ip_str}'.")
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return SSRFCheck(False, f"Host resolves to disallowed IP {ip}.", ip_str)
|
||||
|
||||
return SSRFCheck(True, None, seen[0] if seen else None)
|
||||
462
plugins/wordpress/handlers/ai_media.py
Normal file
462
plugins/wordpress/handlers/ai_media.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""F.5a.4: AI image generation + upload chain.
|
||||
|
||||
Provides ``wordpress_generate_and_upload_image`` which chains:
|
||||
|
||||
1. Resolve the caller's per-user provider API key (or env fallback).
|
||||
2. Call the chosen provider (OpenAI / Stability / Replicate) and get raw bytes.
|
||||
3. Reuse the F.5a.1 raw-upload path (optimize → POST /wp/v2/media).
|
||||
4. Optionally apply metadata (alt/caption/title), attach to a post, or set as featured.
|
||||
5. Emit an audit-log entry with cost/provider/usage.
|
||||
|
||||
The tool is registered on the WordPress plugin because the end result is
|
||||
a WordPress media item — the ``ai_image`` package is a pure provider
|
||||
library, not an MCP plugin with its own endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from plugins.ai_image.providers.base import GenerationRequest, ProviderError
|
||||
from plugins.ai_image.registry import get_provider, list_providers
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_core import wp_raw_upload
|
||||
from plugins.wordpress.handlers._media_security import UploadError
|
||||
from plugins.wordpress.handlers.media import (
|
||||
_apply_metadata_and_attach,
|
||||
_format_upload_result,
|
||||
_maybe_optimize,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("mcphub.wordpress.ai_media")
|
||||
|
||||
|
||||
def _content_sha(data: bytes) -> str:
|
||||
"""SHA-256 of the raw generated bytes, truncated for brevity.
|
||||
|
||||
Two independent provider calls for the same prompt produce
|
||||
different bytes (diffusion models are non-deterministic), so this
|
||||
hash is effectively a per-call fingerprint. Including it in the
|
||||
idempotency key means a retry with the *same already-generated*
|
||||
bytes dedupes, while a fresh call with a new image gets a new id.
|
||||
"""
|
||||
return hashlib.sha256(data).hexdigest()[:32]
|
||||
|
||||
|
||||
def _idempotency_key_for(
|
||||
*,
|
||||
provider: str,
|
||||
model: str | None,
|
||||
prompt: str,
|
||||
size: str,
|
||||
attach_to_post: int | None,
|
||||
set_featured: bool,
|
||||
site_url: str,
|
||||
user_id: str | None,
|
||||
content_sha: str,
|
||||
) -> str:
|
||||
"""Build the ``Idempotency-Key`` header value for an AI upload.
|
||||
|
||||
Matches the ``^[A-Za-z0-9_\\-]{1,128}$`` regex the companion
|
||||
validates (see airano-mcp-bridge.php handle_upload_and_attach).
|
||||
"""
|
||||
raw = "|".join(
|
||||
[
|
||||
provider or "",
|
||||
model or "",
|
||||
prompt or "",
|
||||
size or "",
|
||||
str(attach_to_post or 0),
|
||||
"1" if set_featured else "0",
|
||||
site_url or "",
|
||||
user_id or "",
|
||||
content_sha or "",
|
||||
]
|
||||
)
|
||||
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
return f"mcphub_ai_{digest[:48]}"
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "generate_and_upload_image",
|
||||
"method_name": "generate_and_upload_image",
|
||||
"description": (
|
||||
"Generate an image with an AI provider (OpenAI DALL-E, "
|
||||
"Stability, or Replicate Flux) and upload it to the WordPress "
|
||||
"media library in one call. Optionally attach to a post or set "
|
||||
"as featured image. Uses the caller's stored provider API key; "
|
||||
"falls back to server env vars if no per-user key is set."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": list_providers(),
|
||||
"description": "AI provider to use.",
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Text prompt describing the image to generate.",
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"default": "1024x1024",
|
||||
"description": "Requested image size (WxH). Providers map to supported sizes.",
|
||||
},
|
||||
"quality": {
|
||||
"type": "string",
|
||||
"default": "standard",
|
||||
"description": "Provider-specific quality hint (e.g. 'standard'/'hd').",
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional model override (e.g. 'dall-e-3', 'flux-dev').",
|
||||
},
|
||||
"negative_prompt": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
},
|
||||
"filename": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional filename hint for the WP library.",
|
||||
},
|
||||
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer", "minimum": 1}, {"type": "null"}],
|
||||
},
|
||||
"set_featured": {"type": "boolean", "default": False},
|
||||
"skip_optimize": {"type": "boolean", "default": False},
|
||||
"convert_to": {
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["webp", "avif"]},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": (
|
||||
"F.5a.8.1: re-encode the generated image as WebP or "
|
||||
"AVIF before upload (falls back to WebP if AVIF is "
|
||||
"unavailable)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["provider", "prompt"],
|
||||
},
|
||||
"scope": "write",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class AIMediaHandler:
|
||||
"""Handles AI-powered image generation + WP upload chain."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: WordPressClient,
|
||||
user_id: str | None = None,
|
||||
site_id: str | None = None,
|
||||
wc_client: WordPressClient | None = None,
|
||||
):
|
||||
self.client = client
|
||||
# ``user_id`` is injected by the user-endpoint plumbing (F.5a.4).
|
||||
# Admin / system endpoints leave it None.
|
||||
self.user_id = user_id
|
||||
# ``site_id`` is injected by the user-endpoint plumbing (F.5a.9.x)
|
||||
# and is the primary input for resolving per-site provider API
|
||||
# keys. Admin / env-driven endpoints leave it None, in which case
|
||||
# the resolver falls back to the legacy env-var path.
|
||||
self.site_id = site_id
|
||||
# F.X.fix-pass6 — optional separate client for /wc/v3/* (the
|
||||
# WC consumer-key/secret pair on a WC site). Used by
|
||||
# _apply_metadata_and_attach to detect "attach_to_post is a
|
||||
# WC product" and route the featured-image set through
|
||||
# /wc/v3/products/{id}/images instead of /wp/v2/posts/{id}
|
||||
# which 404s for the product CPT. None = same client serves
|
||||
# both REST roots (WP plugin or legacy single-credential WC).
|
||||
self.wc_client = wc_client
|
||||
|
||||
async def generate_and_upload_image(
|
||||
self,
|
||||
provider: str,
|
||||
prompt: str,
|
||||
size: str = "1024x1024",
|
||||
quality: str = "standard",
|
||||
model: str | None = None,
|
||||
negative_prompt: str | None = None,
|
||||
filename: str | None = None,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
skip_optimize: bool = False,
|
||||
convert_to: str | None = None,
|
||||
) -> str:
|
||||
started = time.time()
|
||||
try:
|
||||
from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
|
||||
|
||||
try:
|
||||
get_tool_rate_limiter().check("wordpress_generate_and_upload_image", self.user_id)
|
||||
except ToolRateLimitError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
provider_impl = get_provider(provider)
|
||||
api_key = await self._resolve_api_key(provider)
|
||||
# F.X.fix-pass3 — when caller omitted ``model``, fall back
|
||||
# to the per-site default the operator pinned in the
|
||||
# dashboard. Keeps MCP call sites short and lets the user
|
||||
# rotate models without code changes.
|
||||
if model is None and self.site_id:
|
||||
try:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
model = await db.get_site_provider_default_model(self.site_id, provider)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"default_model lookup skipped site=%s provider=%s: %s",
|
||||
self.site_id,
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
if not api_key:
|
||||
if self.site_id:
|
||||
msg = (
|
||||
f"No API key configured for provider '{provider}' on "
|
||||
f"this site. Open the site in the dashboard and add the "
|
||||
f"key under 'AI Image Generation' in Connection Settings."
|
||||
)
|
||||
dashboard_url = f"/dashboard/sites/{self.site_id}"
|
||||
else:
|
||||
msg = (
|
||||
f"No API key configured for provider '{provider}'. "
|
||||
f"Set the {provider.upper()}_API_KEY env var on the server."
|
||||
)
|
||||
dashboard_url = None
|
||||
payload = {
|
||||
"error_code": "NO_PROVIDER_KEY",
|
||||
"message": msg,
|
||||
"provider": provider,
|
||||
}
|
||||
if dashboard_url:
|
||||
payload["dashboard_url"] = dashboard_url
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
request = GenerationRequest(
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
model=model,
|
||||
negative_prompt=negative_prompt,
|
||||
)
|
||||
result = await provider_impl.generate(api_key, request)
|
||||
|
||||
data, mime_hint = _maybe_optimize(
|
||||
result.data, result.mime, skip=skip_optimize, convert_to=convert_to
|
||||
)
|
||||
# F.X.fix #7: stable idempotency key per logical call. A
|
||||
# client-side retry (after timeout) produces the same
|
||||
# digest, so the companion dedupes instead of creating an
|
||||
# "-2.webp" orphan. Key covers every input that would
|
||||
# change the intended outcome.
|
||||
idempotency_key = _idempotency_key_for(
|
||||
provider=provider,
|
||||
model=result.meta.get("model") or model,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
site_url=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
content_sha=_content_sha(data),
|
||||
)
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
data,
|
||||
filename=filename or result.filename,
|
||||
mime_hint=mime_hint or result.mime,
|
||||
# F.5a.8.5: single-call upload+metadata+attach+featured
|
||||
# when the companion's upload-and-attach route is
|
||||
# advertised; _apply_metadata_and_attach becomes a no-op.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
attach_status = await _apply_metadata_and_attach(
|
||||
self.client,
|
||||
media,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
wc_client=self.wc_client,
|
||||
)
|
||||
|
||||
duration_ms = int((time.time() - started) * 1000)
|
||||
self._audit(
|
||||
provider=provider,
|
||||
model=result.meta.get("model") or model,
|
||||
size=size,
|
||||
duration_ms=duration_ms,
|
||||
bytes_=len(result.data),
|
||||
cost_usd=result.cost_usd,
|
||||
media_id=media.get("id"),
|
||||
error=None,
|
||||
)
|
||||
from core.media_audit import log_media_upload
|
||||
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
mime=media.get("mime_type") or result.mime,
|
||||
size_bytes=len(data),
|
||||
source=f"ai:{provider}",
|
||||
media_id=media.get("id"),
|
||||
cost_usd=result.cost_usd,
|
||||
)
|
||||
|
||||
# F.X.fix-pass6 — surface partial-success: media uploaded
|
||||
# but featured-set / metadata-apply may have warnings. Old
|
||||
# behaviour bubbled those exceptions up as
|
||||
# GENERATION_FAILED, hiding the freshly-created media id
|
||||
# and leaving an orphan in the library.
|
||||
payload = _format_upload_result(media, source=f"ai:{provider}")
|
||||
payload["provider"] = provider
|
||||
payload["provider_meta"] = result.meta
|
||||
payload["cost_usd"] = result.cost_usd
|
||||
payload["duration_ms"] = duration_ms
|
||||
payload["attach"] = {
|
||||
"metadata_applied": attach_status.get("metadata_applied", False),
|
||||
"featured_set": attach_status.get("featured_set", False),
|
||||
"featured_context": attach_status.get("featured_context"),
|
||||
}
|
||||
warnings = attach_status.get("warnings") or []
|
||||
if warnings:
|
||||
payload["warnings"] = warnings
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
except ProviderError as e:
|
||||
self._audit(
|
||||
provider=provider,
|
||||
model=model,
|
||||
size=size,
|
||||
duration_ms=int((time.time() - started) * 1000),
|
||||
bytes_=0,
|
||||
cost_usd=None,
|
||||
media_id=None,
|
||||
error=e.code,
|
||||
)
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except UploadError as e:
|
||||
self._audit(
|
||||
provider=provider,
|
||||
model=model,
|
||||
size=size,
|
||||
duration_ms=int((time.time() - started) * 1000),
|
||||
bytes_=0,
|
||||
cost_usd=None,
|
||||
media_id=None,
|
||||
error=e.code,
|
||||
)
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
_logger.exception("generate_and_upload_image failed")
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "GENERATION_FAILED",
|
||||
"message": f"generate_and_upload_image failed: {e}",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def _resolve_api_key(self, provider: str) -> str | None:
|
||||
"""Resolve the provider key for this call.
|
||||
|
||||
F.5a.9.x policy (single-source, per-site):
|
||||
|
||||
1. ``site_id`` set (per-user endpoint, the only UI-reachable path):
|
||||
read the key from the per-site ``site_provider_keys`` row.
|
||||
Env fallback is deliberately NOT applied here so a single
|
||||
``OPENAI_API_KEY=...`` on the server cannot silently paper
|
||||
over a missing per-site configuration — callers instead get
|
||||
``NO_PROVIDER_KEY`` pointing at the site's dashboard page.
|
||||
|
||||
2. ``site_id is None`` (admin / master-key endpoint, no site
|
||||
context): fall back to the ``<PROVIDER>_API_KEY`` env var.
|
||||
This preserves the legacy self-hosted / admin workflow.
|
||||
|
||||
The per-user provider-keys store from F.18.8 has been removed
|
||||
in favour of the per-site model; there is no hybrid fallback.
|
||||
"""
|
||||
# 1. Per-site key (the primary path)
|
||||
if self.site_id:
|
||||
try:
|
||||
from core.site_api import get_site_provider_key
|
||||
|
||||
return await get_site_provider_key(self.site_id, provider)
|
||||
except Exception as exc:
|
||||
_logger.error(
|
||||
"Site provider-key lookup failed site=%s provider=%s: %s",
|
||||
self.site_id,
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
# 2. Admin / env-var fallback (no site context)
|
||||
env_var = f"{provider.upper()}_API_KEY"
|
||||
return os.environ.get(env_var)
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
model: str | None,
|
||||
size: str,
|
||||
duration_ms: int,
|
||||
bytes_: int,
|
||||
cost_usd: float | None,
|
||||
media_id: int | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
from core.audit_log import get_audit_logger
|
||||
|
||||
logger_ = get_audit_logger()
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
logger_.log_tool_call(
|
||||
tool_name="wordpress_generate_and_upload_image",
|
||||
params={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"size": size,
|
||||
"bytes": bytes_,
|
||||
"cost_usd": cost_usd,
|
||||
"media_id": media_id,
|
||||
},
|
||||
duration_ms=duration_ms,
|
||||
user_id=self.user_id,
|
||||
error=error,
|
||||
result_summary=(
|
||||
f"{provider}/{model or '-'} {bytes_}B "
|
||||
f"${cost_usd or 0:.4f} -> media_id={media_id}"
|
||||
if not error
|
||||
else f"failed: {error}"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
_logger.debug("audit log emit failed", exc_info=True)
|
||||
275
plugins/wordpress/handlers/audit_hook.py
Normal file
275
plugins/wordpress/handlers/audit_hook.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""F.18.7 — Manage the companion plugin's audit-hook webhook config.
|
||||
|
||||
Wraps ``GET|POST|DELETE /airano-mcp/v1/audit-hook`` (companion plugin
|
||||
v2.7.0+). Three tools:
|
||||
|
||||
- ``wordpress_audit_hook_status`` (read): returns current config + stats.
|
||||
- ``wordpress_audit_hook_configure`` (admin): upserts endpoint_url / secret /
|
||||
enabled / events.
|
||||
- ``wordpress_audit_hook_disable`` (admin): clears config and stops pushing.
|
||||
|
||||
The actual event receiver lives in ``core/companion_audit.py``; this
|
||||
module only manages the per-site configuration on the WordPress side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.audit_hook")
|
||||
|
||||
SUPPORTED_EVENTS = (
|
||||
"transition_post_status",
|
||||
"deleted_post",
|
||||
"user_register",
|
||||
"profile_update",
|
||||
"deleted_user",
|
||||
"activated_plugin",
|
||||
"deactivated_plugin",
|
||||
"switch_theme",
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "audit_hook_status",
|
||||
"method_name": "audit_hook_status",
|
||||
"description": (
|
||||
"Read the companion plugin's audit-hook configuration "
|
||||
"(v2.7.0+): current endpoint_url, whether a secret is set, "
|
||||
"enabled flag, event list, last-push timestamp, failure "
|
||||
"count. Secret is never returned in full — only the last 4 "
|
||||
"characters. Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "audit_hook_configure",
|
||||
"method_name": "audit_hook_configure",
|
||||
"description": (
|
||||
"Configure the companion plugin's audit-hook webhook. Sets "
|
||||
"the MCPHub endpoint_url, shared HMAC secret (≥16 chars), "
|
||||
"enabled flag, and list of hooked events. Returns the "
|
||||
"resulting status. Requires manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full URL to MCPHub's /api/companion-audit "
|
||||
"route (e.g. https://mcp.example.com/api/companion-audit)."
|
||||
),
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"HMAC-SHA256 shared secret (≥16 chars). Store "
|
||||
"the identical value in MCPHub's "
|
||||
"CompanionAuditSecretStore for this site."
|
||||
),
|
||||
},
|
||||
"enabled": {"type": "boolean"},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": list(SUPPORTED_EVENTS)},
|
||||
"description": (
|
||||
"List of WP action hooks to forward. Default: " "all supported events."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "audit_hook_disable",
|
||||
"method_name": "audit_hook_disable",
|
||||
"description": (
|
||||
"Clear the companion plugin's audit-hook configuration "
|
||||
"(endpoint_url, secret, events) and stop forwarding events. "
|
||||
"Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _validate_configure(
|
||||
*,
|
||||
endpoint_url: str | None,
|
||||
secret: str | None,
|
||||
enabled: Any,
|
||||
events: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
# endpoint_url: allow empty-string to mean "clear" on the PHP side,
|
||||
# but reject obviously-broken inputs client-side.
|
||||
if (
|
||||
endpoint_url is not None
|
||||
and endpoint_url
|
||||
and not endpoint_url.startswith(("http://", "https://"))
|
||||
):
|
||||
return {
|
||||
"error": "invalid_endpoint_url",
|
||||
"message": "endpoint_url must start with http:// or https://",
|
||||
}
|
||||
if secret is not None and secret != "" and len(secret) < 16:
|
||||
return {
|
||||
"error": "secret_too_short",
|
||||
"message": "shared secret must be at least 16 characters",
|
||||
}
|
||||
if events is not None:
|
||||
if not isinstance(events, list):
|
||||
return {
|
||||
"error": "invalid_events",
|
||||
"message": "events must be a list of known event names",
|
||||
}
|
||||
for e in events:
|
||||
if not isinstance(e, str) or e not in SUPPORTED_EVENTS:
|
||||
return {
|
||||
"error": "unknown_event",
|
||||
"message": f"event {e!r} is not supported",
|
||||
"supported": list(SUPPORTED_EVENTS),
|
||||
}
|
||||
if enabled is not None and not isinstance(enabled, bool):
|
||||
return {
|
||||
"error": "invalid_enabled",
|
||||
"message": "enabled must be true or false",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _unreachable(exc: Exception) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.7.0+ and "
|
||||
"manage_options capability. Run wordpress_probe_capabilities "
|
||||
"to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.7.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/audit-hook",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AuditHookHandler:
|
||||
"""Configure + query the companion plugin's audit-hook webhook."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def audit_hook_status(self) -> str:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_status companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
|
||||
async def audit_hook_configure(
|
||||
self,
|
||||
endpoint_url: str | None = None,
|
||||
secret: str | None = None,
|
||||
enabled: Any = None,
|
||||
events: Any = None,
|
||||
) -> str:
|
||||
err = _validate_configure(
|
||||
endpoint_url=endpoint_url,
|
||||
secret=secret,
|
||||
enabled=enabled,
|
||||
events=events,
|
||||
)
|
||||
if err is not None:
|
||||
return json.dumps({"ok": False, **err}, indent=2)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
if endpoint_url is not None:
|
||||
body["endpoint_url"] = endpoint_url
|
||||
if secret is not None:
|
||||
body["secret"] = secret
|
||||
if enabled is not None:
|
||||
body["enabled"] = bool(enabled)
|
||||
if events is not None:
|
||||
body["events"] = list(events)
|
||||
|
||||
if not body:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "no_fields",
|
||||
"message": (
|
||||
"Provide at least one of: endpoint_url, secret, " "enabled, events."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_configure companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
|
||||
async def audit_hook_disable(self) -> str:
|
||||
try:
|
||||
payload = await self.client.delete(
|
||||
"airano-mcp/v1/audit-hook",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("audit_hook_disable companion call failed: %s", exc)
|
||||
return json.dumps(_unreachable(exc), indent=2)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps({"ok": True, **payload}, indent=2)
|
||||
179
plugins/wordpress/handlers/bulk_meta.py
Normal file
179
plugins/wordpress/handlers/bulk_meta.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""F.18.2 — Batch post/product meta writes via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/bulk-meta`` (companion plugin v2.2.0+). One
|
||||
HTTP round-trip updates meta for up to 500 posts/products in a single
|
||||
REST call; without the companion each post would need its own request.
|
||||
|
||||
Tool: ``wordpress_bulk_update_meta(updates=[...])``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.bulk_meta")
|
||||
|
||||
# Matches BULK_META_MAX_ITEMS in airano-mcp-bridge.php so we reject client-side
|
||||
# before burning a round-trip on a 413.
|
||||
MAX_BULK_ITEMS = 500
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "bulk_update_meta",
|
||||
"method_name": "bulk_update_meta",
|
||||
"description": (
|
||||
"Batch-update post_meta (posts, pages, WooCommerce products) in a "
|
||||
"single REST round-trip via the airano-mcp-bridge companion "
|
||||
"plugin (v2.2.0+). Each item is permission-checked in PHP via "
|
||||
"current_user_can('edit_post', post_id). Pass a null meta value "
|
||||
"to delete that key. Maximum 500 items per call."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"List of {post_id, meta} objects. `meta` is a dict of "
|
||||
"meta_key => value; null values delete the key."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer"},
|
||||
"meta": {"type": "object"},
|
||||
},
|
||||
"required": ["post_id", "meta"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _validate_updates(updates: Any) -> list[dict[str, Any]] | dict[str, Any]:
|
||||
"""Validate the updates list. Returns the list on success, or an error dict."""
|
||||
if not isinstance(updates, list):
|
||||
return {
|
||||
"error": "invalid_updates",
|
||||
"message": "`updates` must be a list of {post_id, meta} objects.",
|
||||
}
|
||||
if not updates:
|
||||
return {
|
||||
"error": "empty_updates",
|
||||
"message": "No updates supplied.",
|
||||
}
|
||||
if len(updates) > MAX_BULK_ITEMS:
|
||||
return {
|
||||
"error": "too_many_items",
|
||||
"message": (
|
||||
f"At most {MAX_BULK_ITEMS} items per bulk_update_meta call; " f"got {len(updates)}."
|
||||
),
|
||||
}
|
||||
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(updates):
|
||||
if not isinstance(item, dict):
|
||||
return {
|
||||
"error": "invalid_item",
|
||||
"message": f"updates[{idx}] is not an object.",
|
||||
"index": idx,
|
||||
}
|
||||
post_id = item.get("post_id")
|
||||
meta = item.get("meta")
|
||||
if not isinstance(post_id, int) or post_id <= 0:
|
||||
return {
|
||||
"error": "invalid_post_id",
|
||||
"message": f"updates[{idx}].post_id must be a positive integer.",
|
||||
"index": idx,
|
||||
}
|
||||
if not isinstance(meta, dict):
|
||||
return {
|
||||
"error": "invalid_meta",
|
||||
"message": f"updates[{idx}].meta must be an object.",
|
||||
"index": idx,
|
||||
}
|
||||
cleaned.append({"post_id": post_id, "meta": meta})
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
class BulkMetaHandler:
|
||||
"""Batch meta writes via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def bulk_update_meta(self, updates: Any) -> str:
|
||||
validated = _validate_updates(updates)
|
||||
if isinstance(validated, dict):
|
||||
# client-side rejection — don't burn a round-trip
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
**validated,
|
||||
"total": len(updates) if isinstance(updates, list) else 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"results": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/bulk-meta",
|
||||
json_data={"updates": validated},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("bulk_update_meta companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.2.0+; "
|
||||
"install/update it from wordpress-plugin/airano-mcp-bridge.zip "
|
||||
"or run wordpress_probe_capabilities to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.2.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/bulk-meta",
|
||||
),
|
||||
"total": len(validated),
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"results": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# The companion already returns a well-shaped response — just
|
||||
# re-emit with the `ok` flag prepended so callers don't have to
|
||||
# infer success from counts.
|
||||
result = {
|
||||
"ok": True,
|
||||
"total": int(payload.get("total", 0)),
|
||||
"updated": int(payload.get("updated", 0)),
|
||||
"failed": int(payload.get("failed", 0)),
|
||||
"skipped": int(payload.get("skipped", 0)),
|
||||
"results": payload.get("results", []),
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
109
plugins/wordpress/handlers/cache_purge.py
Normal file
109
plugins/wordpress/handlers/cache_purge.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""F.18.4 — Cache purge via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/cache-purge`` (companion plugin v2.4.0+).
|
||||
Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache,
|
||||
WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes
|
||||
their purge API. Always flushes the WP object cache. Replaces the
|
||||
previous Docker-socket + WP-CLI path on managed hosts.
|
||||
|
||||
Tool: ``wordpress_cache_purge()``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.cache_purge")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "cache_purge",
|
||||
"method_name": "cache_purge",
|
||||
"description": (
|
||||
"Purge all caches on the WordPress site via the "
|
||||
"airano-mcp-bridge companion plugin (v2.4.0+). Auto-detects "
|
||||
"active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, "
|
||||
"WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and "
|
||||
"calls each one's purge API. Always flushes the object cache. "
|
||||
"Requires manage_options on the calling application password."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "admin",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class CachePurgeHandler:
|
||||
"""Cache purge via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def cache_purge(self) -> str:
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/cache-purge",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("cache_purge companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.4.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.4.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/cache-purge",
|
||||
),
|
||||
"detected": [],
|
||||
"purged": [],
|
||||
"errors": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"detected": [],
|
||||
"purged": [],
|
||||
"errors": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Pass through + normalise.
|
||||
detected = list(payload.get("detected") or [])
|
||||
purged = list(payload.get("purged") or [])
|
||||
errors = list(payload.get("errors") or [])
|
||||
ok = bool(payload.get("ok", not errors))
|
||||
|
||||
result = {
|
||||
"ok": ok,
|
||||
"detected": detected,
|
||||
"purged": purged,
|
||||
"skipped": list(payload.get("skipped") or []),
|
||||
"errors": errors,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
221
plugins/wordpress/handlers/capabilities.py
Normal file
221
plugins/wordpress/handlers/capabilities.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""F.18.1 — Probe companion-plugin capabilities for the current credentials.
|
||||
|
||||
Calls ``GET /airano-mcp/v1/capabilities`` which returns the exact capability
|
||||
set of the authenticated user plus the list of routes the installed companion
|
||||
plugin actually ships (so MCPHub can gracefully degrade if the site is on an
|
||||
older version). Consumed by F.7e's credential-capability probe.
|
||||
|
||||
Results are cached in-memory per ``(site_url, username)`` for 24 h, matching
|
||||
``media_probe``'s behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.capabilities")
|
||||
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "probe_capabilities",
|
||||
"method_name": "probe_capabilities",
|
||||
"description": (
|
||||
"Probe the airano-mcp-bridge companion plugin for the effective "
|
||||
"capability set of the calling application password plus the list of "
|
||||
"companion routes the installed version ships. Returns "
|
||||
"`companion_available: false` when the plugin is missing or outdated. "
|
||||
"Cached 24 h per (site, user)."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# The exact capability keys the companion plugin advertises. Keep this list
|
||||
# in lock-step with ``airano-mcp-bridge.php::get_capabilities()``; anything the
|
||||
# plugin returns that isn't in this list is preserved as-is under ``extra``.
|
||||
_EXPECTED_CAPS = (
|
||||
"upload_files",
|
||||
"edit_posts",
|
||||
"publish_posts",
|
||||
"edit_others_posts",
|
||||
"delete_posts",
|
||||
"edit_pages",
|
||||
"publish_pages",
|
||||
"manage_categories",
|
||||
"moderate_comments",
|
||||
"manage_options",
|
||||
"edit_users",
|
||||
"list_users",
|
||||
"manage_woocommerce",
|
||||
"edit_shop_orders",
|
||||
"edit_products",
|
||||
)
|
||||
|
||||
_EXPECTED_ROUTES = (
|
||||
"seo_meta",
|
||||
"upload_limits",
|
||||
"upload_chunk",
|
||||
"upload_and_attach",
|
||||
"capabilities",
|
||||
"bulk_meta",
|
||||
"export",
|
||||
"cache_purge",
|
||||
"transient_flush",
|
||||
"site_health",
|
||||
"audit_hook",
|
||||
"regenerate_thumbnails",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
fetched_at: float
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CapabilitiesCache:
|
||||
"""Process-local TTL cache keyed by (site_url, username)."""
|
||||
|
||||
ttl: float = CACHE_TTL_SECONDS
|
||||
_entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if (time.time() - entry.fetched_at) > self.ttl:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return dict(entry.data)
|
||||
|
||||
async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
_cache = _CapabilitiesCache()
|
||||
|
||||
|
||||
def get_capabilities_cache() -> _CapabilitiesCache:
|
||||
return _cache
|
||||
|
||||
|
||||
def _empty_capabilities_payload(site_url: str, reason: str) -> dict[str, Any]:
|
||||
"""Stable response shape for the missing-companion case."""
|
||||
# Local import avoids a module-import cycle at startup time
|
||||
# (capabilities → _companion_hint → capabilities via __init__).
|
||||
from plugins.wordpress.handlers._companion_hint import companion_install_hint
|
||||
|
||||
return {
|
||||
"site_url": site_url,
|
||||
"companion_available": False,
|
||||
"reason": reason,
|
||||
"plugin_version": None,
|
||||
"user": None,
|
||||
"features": None,
|
||||
"routes": dict.fromkeys(_EXPECTED_ROUTES, False),
|
||||
"wordpress": None,
|
||||
"install_hint": companion_install_hint(
|
||||
min_version="2.1.0",
|
||||
required_capability="read",
|
||||
route="airano-mcp/v1/capabilities",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class CapabilitiesHandler:
|
||||
"""Read-only probe of the companion plugin's capability advertisement."""
|
||||
|
||||
def __init__(self, client: WordPressClient, *, cache: _CapabilitiesCache | None = None) -> None:
|
||||
self.client = client
|
||||
self._cache = cache or _cache
|
||||
|
||||
@property
|
||||
def _cache_key(self) -> tuple[str, str]:
|
||||
return (self.client.site_url, self.client.username)
|
||||
|
||||
async def probe_capabilities(self) -> str:
|
||||
cached = await self._cache.get(self._cache_key)
|
||||
if cached is not None:
|
||||
cached["cached"] = True
|
||||
return json.dumps(cached, indent=2)
|
||||
|
||||
result = await self._fetch_capabilities()
|
||||
await self._cache.set(self._cache_key, result)
|
||||
result_out = dict(result)
|
||||
result_out["cached"] = False
|
||||
return json.dumps(result_out, indent=2)
|
||||
|
||||
async def _fetch_capabilities(self) -> dict[str, Any]:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/capabilities",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Capabilities probe failed: %s", exc)
|
||||
return _empty_capabilities_payload(
|
||||
self.client.site_url, reason=f"companion_unreachable: {exc}"
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return _empty_capabilities_payload(
|
||||
self.client.site_url, reason="companion_returned_non_dict"
|
||||
)
|
||||
|
||||
# Normalise the shape: fill any missing cap/route with False so
|
||||
# downstream consumers can index without KeyError checks.
|
||||
caps_raw = (payload.get("user") or {}).get("capabilities") or {}
|
||||
caps = {k: bool(caps_raw.get(k, False)) for k in _EXPECTED_CAPS}
|
||||
extra_caps = {k: bool(v) for k, v in caps_raw.items() if k not in _EXPECTED_CAPS}
|
||||
|
||||
routes_raw = payload.get("routes") or {}
|
||||
routes = {k: bool(routes_raw.get(k, False)) for k in _EXPECTED_ROUTES}
|
||||
|
||||
user = payload.get("user") or {}
|
||||
user_out = {
|
||||
"id": user.get("id"),
|
||||
"login": user.get("login"),
|
||||
"roles": list(user.get("roles") or []),
|
||||
"capabilities": caps,
|
||||
"extra_capabilities": extra_caps,
|
||||
}
|
||||
|
||||
return {
|
||||
"site_url": self.client.site_url,
|
||||
"companion_available": True,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
"user": user_out,
|
||||
"features": payload.get("features"),
|
||||
"routes": routes,
|
||||
"wordpress": payload.get("wordpress"),
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_capabilities(
|
||||
client: WordPressClient, *, cache: _CapabilitiesCache | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the cached capability dict for ``client`` without forcing a probe."""
|
||||
c = cache or _cache
|
||||
key = (client.site_url, client.username)
|
||||
return await c.get(key)
|
||||
210
plugins/wordpress/handlers/export.py
Normal file
210
plugins/wordpress/handlers/export.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""F.18.3 — Structured JSON export via companion plugin.
|
||||
|
||||
Wraps ``GET /airano-mcp/v1/export`` (companion plugin v2.3.0+). Returns
|
||||
posts (posts, pages, products, custom post types) plus referenced media,
|
||||
taxonomy terms, and meta in a single JSON envelope, with pagination
|
||||
hints (``has_more`` + ``next_offset``). Intended for offline processing,
|
||||
migrations, and content snapshots.
|
||||
|
||||
Tool: ``wordpress_export_content(post_type="post", status="publish", ...)``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.export")
|
||||
|
||||
# Matches EXPORT_MAX_LIMIT in airano-mcp-bridge.php.
|
||||
EXPORT_MAX_LIMIT = 500
|
||||
EXPORT_DEFAULT_LIMIT = 100
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "export_content",
|
||||
"method_name": "export_content",
|
||||
"description": (
|
||||
"Export posts/pages/products as structured JSON via the "
|
||||
"airano-mcp-bridge companion plugin (v2.3.0+). Includes "
|
||||
"referenced media, taxonomy terms, and post_meta. Paginates "
|
||||
"via offset/limit; response contains has_more + next_offset. "
|
||||
"Not a WXR dump — intended for AI-pipeline processing, not "
|
||||
"WP-to-WP import."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated list of post types "
|
||||
"(e.g. 'post', 'post,page', 'product'). Default 'post'."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Comma-separated list of statuses, or 'any'. " "Default 'publish'."
|
||||
),
|
||||
},
|
||||
"since": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Only return posts modified after this ISO8601 " "timestamp. Optional."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": (f"1..{EXPORT_MAX_LIMIT}, default {EXPORT_DEFAULT_LIMIT}."),
|
||||
},
|
||||
"offset": {"type": "integer", "description": "Default 0."},
|
||||
"include_media": {
|
||||
"type": "boolean",
|
||||
"description": "Include featured media objects (default true).",
|
||||
},
|
||||
"include_terms": {
|
||||
"type": "boolean",
|
||||
"description": "Include taxonomy terms per post (default true).",
|
||||
},
|
||||
"include_meta": {
|
||||
"type": "boolean",
|
||||
"description": "Include post_meta (default true).",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _normalise_bool(v: Any, default: bool) -> bool:
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
return bool(v)
|
||||
s = str(v).strip().lower()
|
||||
if s in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if s in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _build_query_params(
|
||||
*,
|
||||
post_type: str | None,
|
||||
status: str | None,
|
||||
since: str | None,
|
||||
limit: int | None,
|
||||
offset: int | None,
|
||||
include_media: Any,
|
||||
include_terms: Any,
|
||||
include_meta: Any,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"post_type": post_type or "post",
|
||||
"status": status or "publish",
|
||||
}
|
||||
if since:
|
||||
params["since"] = since
|
||||
|
||||
if limit is None:
|
||||
params["limit"] = EXPORT_DEFAULT_LIMIT
|
||||
else:
|
||||
lim = int(limit)
|
||||
if lim <= 0:
|
||||
lim = EXPORT_DEFAULT_LIMIT
|
||||
if lim > EXPORT_MAX_LIMIT:
|
||||
lim = EXPORT_MAX_LIMIT
|
||||
params["limit"] = lim
|
||||
|
||||
params["offset"] = max(0, int(offset or 0))
|
||||
|
||||
# Pass booleans as "true"/"false" strings so the PHP side's
|
||||
# bool_param() helper can parse them uniformly.
|
||||
params["include_media"] = "true" if _normalise_bool(include_media, True) else "false"
|
||||
params["include_terms"] = "true" if _normalise_bool(include_terms, True) else "false"
|
||||
params["include_meta"] = "true" if _normalise_bool(include_meta, True) else "false"
|
||||
|
||||
return params
|
||||
|
||||
|
||||
class ExportHandler:
|
||||
"""Structured JSON export via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def export_content(
|
||||
self,
|
||||
post_type: str | None = None,
|
||||
status: str | None = None,
|
||||
since: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
include_media: Any = True,
|
||||
include_terms: Any = True,
|
||||
include_meta: Any = True,
|
||||
) -> str:
|
||||
params = _build_query_params(
|
||||
post_type=post_type,
|
||||
status=status,
|
||||
since=since,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include_media=include_media,
|
||||
include_terms=include_terms,
|
||||
include_meta=include_meta,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/export",
|
||||
params=params,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("export_content companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.3.0+. "
|
||||
"Run wordpress_probe_capabilities to verify availability."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.3.0",
|
||||
required_capability="edit_posts",
|
||||
route="airano-mcp/v1/export",
|
||||
),
|
||||
"params": params,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"params": params,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {"ok": True, **payload}
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -1,11 +1,23 @@
|
||||
"""Media Handler - manages WordPress media library operations"""
|
||||
|
||||
import base64 as _b64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from core.media_audit import log_media_upload
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_core import (
|
||||
fetch_url_bytes,
|
||||
wp_raw_upload,
|
||||
wp_set_featured_media,
|
||||
wp_update_media_metadata,
|
||||
)
|
||||
from plugins.wordpress.handlers._media_security import (
|
||||
DEFAULT_MAX_BYTES,
|
||||
UploadError,
|
||||
ssrf_check,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -61,13 +73,17 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
{
|
||||
"name": "upload_media_from_url",
|
||||
"method_name": "upload_media_from_url",
|
||||
"description": "Upload media from URL to media library (sideload). Downloads file from public URL and uploads to WordPress.",
|
||||
"description": "Upload media from a public URL to the WordPress media library (sideload). Downloads the file with SSRF protection, sniffs MIME, and uploads via raw-binary POST.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Public URL of the media file to upload (image, video, document, etc.)",
|
||||
"description": "Public HTTPS URL of the media file",
|
||||
},
|
||||
"filename": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Override filename (default: derived from URL)",
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
@@ -75,17 +91,96 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"alt_text": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Alternative text for accessibility (important for images)",
|
||||
"description": "Alternative text for accessibility",
|
||||
},
|
||||
"caption": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Media caption (displayed below image when inserted into content)",
|
||||
"description": "Media caption",
|
||||
},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Attach uploaded media to this post/page ID",
|
||||
},
|
||||
"set_featured": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "If true and attach_to_post is set, also set as the post's featured image",
|
||||
},
|
||||
"skip_optimize": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Skip server-side image optimization (F.5a.2)",
|
||||
},
|
||||
"convert_to": {
|
||||
"anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
|
||||
"description": (
|
||||
"F.5a.8.1: re-encode the image in a modern format before upload. "
|
||||
"'webp' or 'avif'. Falls back to WebP if AVIF is unavailable. "
|
||||
"Leave null to keep source format (or use WP_MEDIA_CONVERT_TO env default)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_from_base64",
|
||||
"method_name": "upload_media_from_base64",
|
||||
"description": "Upload a base64-encoded file directly to the WordPress media library. For chat-attached images/files smaller than ~10 MB. Use upload_media_from_url for larger files or chunked path later.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded file bytes (no data: URL prefix required; prefix will be stripped if present)",
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Filename including extension (e.g. 'cover.jpg')",
|
||||
},
|
||||
"mime": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Client-supplied MIME hint; ignored if magic-byte sniff says otherwise",
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Media title",
|
||||
},
|
||||
"alt_text": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Alternative text",
|
||||
},
|
||||
"caption": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Caption",
|
||||
},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Attach to this post/page ID",
|
||||
},
|
||||
"set_featured": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Also set as the post's featured image",
|
||||
},
|
||||
"skip_optimize": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Skip server-side image optimization",
|
||||
},
|
||||
"convert_to": {
|
||||
"anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
|
||||
"description": (
|
||||
"F.5a.8.1: re-encode the image in a modern format before upload. "
|
||||
"'webp' or 'avif'. Falls back to WebP if AVIF is unavailable."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["data", "filename"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_media",
|
||||
"method_name": "update_media",
|
||||
@@ -157,17 +252,162 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _decode_base64(data: str) -> bytes:
|
||||
"""Accept raw base64 or data: URL prefix; return decoded bytes."""
|
||||
s = (data or "").strip()
|
||||
if s.startswith("data:") and "," in s:
|
||||
s = s.split(",", 1)[1]
|
||||
s = s.replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
try:
|
||||
return _b64.b64decode(s, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
raise UploadError("BAD_BASE64", f"Invalid base64 payload: {e}") from e
|
||||
|
||||
|
||||
def _maybe_optimize(
|
||||
data: bytes,
|
||||
mime_hint: str | None,
|
||||
*,
|
||||
skip: bool,
|
||||
convert_to: str | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Route image bytes through the F.5a.2 optimize pipeline.
|
||||
|
||||
F.5a.8.1: ``convert_to`` is forwarded to the optimizer so callers can
|
||||
force WebP/AVIF output. A falsy value defers to the ``WP_MEDIA_CONVERT_TO``
|
||||
env var.
|
||||
"""
|
||||
if skip:
|
||||
return data, mime_hint
|
||||
try:
|
||||
from plugins.wordpress.handlers._media_optimize import optimize # type: ignore
|
||||
except ImportError:
|
||||
return data, mime_hint
|
||||
return optimize(data, mime_hint, convert_to=convert_to)
|
||||
|
||||
|
||||
async def _apply_metadata_and_attach(
|
||||
client: WordPressClient,
|
||||
media: dict[str, Any],
|
||||
*,
|
||||
title: str | None,
|
||||
alt_text: str | None,
|
||||
caption: str | None,
|
||||
attach_to_post: int | None,
|
||||
set_featured: bool,
|
||||
wc_client: WordPressClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply metadata + attach to post / featured-image.
|
||||
|
||||
F.5a.8.5 — when the companion's single-call ``upload-and-attach``
|
||||
route already applied these fields, skip everything (saves 1-2
|
||||
redundant round-trips).
|
||||
|
||||
F.X.fix-pass6 — return a status dict so callers can surface
|
||||
"media uploaded but featured failed" as partial success instead
|
||||
of a misleading GENERATION_FAILED. When ``set_featured`` targets
|
||||
a WooCommerce product (CPT, not addressable via /wp/v2/posts/{id}),
|
||||
route through the WC products endpoint via ``wc_client``. Both
|
||||
"metadata applied" and "featured set" steps are reported
|
||||
independently in the result dict.
|
||||
"""
|
||||
status: dict[str, Any] = {
|
||||
"metadata_applied": False,
|
||||
"featured_set": False,
|
||||
"featured_context": None,
|
||||
"warnings": [],
|
||||
}
|
||||
if media.get("_upload_route") == "companion_unified":
|
||||
# Companion did metadata + attach + featured atomically.
|
||||
status["metadata_applied"] = True
|
||||
status["featured_set"] = bool(set_featured and attach_to_post)
|
||||
status["featured_context"] = "companion_unified"
|
||||
return status
|
||||
|
||||
if any(v is not None for v in (title, alt_text, caption)) or attach_to_post is not None:
|
||||
try:
|
||||
await wp_update_media_metadata(
|
||||
client,
|
||||
media["id"],
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
post=attach_to_post,
|
||||
)
|
||||
status["metadata_applied"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"metadata_failed: {exc}")
|
||||
|
||||
if set_featured and attach_to_post is not None:
|
||||
# 1. WC product first (CPT). Falls through to /wp/v2/posts on miss.
|
||||
wc = wc_client or client
|
||||
wc_product = None
|
||||
try:
|
||||
wc_product = await wc.get(f"products/{attach_to_post}", use_woocommerce=True)
|
||||
except Exception:
|
||||
wc_product = None
|
||||
|
||||
if isinstance(wc_product, dict) and wc_product.get("id"):
|
||||
try:
|
||||
existing_images = list(wc_product.get("images") or [])
|
||||
# Featured = images[0]; preserve gallery via the same
|
||||
# merge primitive used by attach_media_to_product.
|
||||
from plugins.wordpress.handlers.media_attach import _merge_product_images
|
||||
|
||||
new_images = _merge_product_images(
|
||||
existing=existing_images,
|
||||
new_ids=[media["id"]],
|
||||
role="main",
|
||||
mode="replace",
|
||||
)
|
||||
await wc.put(
|
||||
f"products/{attach_to_post}",
|
||||
json_data={"images": new_images},
|
||||
use_woocommerce=True,
|
||||
)
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "product"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (product): {exc}")
|
||||
else:
|
||||
try:
|
||||
await wp_set_featured_media(client, attach_to_post, media["id"])
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "post"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (post): {exc}")
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
|
||||
title = media.get("title")
|
||||
rendered_title = title.get("rendered") if isinstance(title, dict) else title
|
||||
return {
|
||||
"id": media["id"],
|
||||
"title": rendered_title or "",
|
||||
"url": media.get("source_url", ""),
|
||||
"mime_type": media.get("mime_type", ""),
|
||||
"media_type": media.get("media_type", ""),
|
||||
"size_bytes": media.get("media_details", {}).get("filesize"),
|
||||
"source": source,
|
||||
"message": f"Media uploaded successfully (id={media['id']}).",
|
||||
}
|
||||
|
||||
|
||||
class MediaHandler:
|
||||
"""Handle media-related operations for WordPress"""
|
||||
|
||||
def __init__(self, client: WordPressClient):
|
||||
def __init__(self, client: WordPressClient, *, user_id: str | None = None):
|
||||
"""
|
||||
Initialize media handler.
|
||||
|
||||
Args:
|
||||
client: WordPress API client instance
|
||||
user_id: Calling user id, used for audit logging (None = admin/env)
|
||||
"""
|
||||
self.client = client
|
||||
self.user_id = user_id
|
||||
|
||||
# === MEDIA ===
|
||||
|
||||
@@ -207,6 +447,11 @@ class MediaHandler:
|
||||
"date": m["date"],
|
||||
"alt_text": m.get("alt_text", ""),
|
||||
"link": m.get("link", ""),
|
||||
# F.X.fix #6: expose post_parent so callers can
|
||||
# verify "is media X attached to post Y" without
|
||||
# a second WP REST round trip. WP returns 0 for
|
||||
# unattached media; we preserve that.
|
||||
"post_parent": m.get("post") or 0,
|
||||
}
|
||||
for m in media
|
||||
],
|
||||
@@ -244,6 +489,9 @@ class MediaHandler:
|
||||
"modified": media.get("modified", ""),
|
||||
"link": media.get("link", ""),
|
||||
"media_details": media.get("media_details", {}),
|
||||
# F.X.fix #6: expose post_parent so attach-verification
|
||||
# doesn't need a second round trip via posts/{id}.
|
||||
"post_parent": media.get("post") or 0,
|
||||
}
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -255,82 +503,123 @@ class MediaHandler:
|
||||
async def upload_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
filename: str | None = None,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
skip_optimize: bool = False,
|
||||
convert_to: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Upload media from URL to media library (sideload).
|
||||
|
||||
Downloads file from a public URL and uploads it to WordPress media library.
|
||||
|
||||
Args:
|
||||
url: Public URL of the media file to upload
|
||||
title: Media title (used in media library)
|
||||
alt_text: Alternative text for accessibility
|
||||
caption: Media caption (displayed below image)
|
||||
|
||||
Returns:
|
||||
JSON string with uploaded media data
|
||||
"""
|
||||
"""Upload media from a public URL to the WordPress media library."""
|
||||
try:
|
||||
# Download file from URL
|
||||
async with aiohttp.ClientSession() as session, session.get(url) as response:
|
||||
if response.status >= 400:
|
||||
raise Exception(f"Failed to download from URL: HTTP {response.status}")
|
||||
ssrf = ssrf_check(url)
|
||||
if not ssrf.allowed:
|
||||
raise UploadError(
|
||||
"SSRF", ssrf.reason or "URL rejected by SSRF guard.", {"url": url}
|
||||
)
|
||||
|
||||
file_content = await response.read()
|
||||
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
||||
data, declared_ct, fname_guess = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
|
||||
data, mime_hint = _maybe_optimize(
|
||||
data, declared_ct, skip=skip_optimize, convert_to=convert_to
|
||||
)
|
||||
|
||||
# Extract filename from URL
|
||||
filename = url.split("/")[-1].split("?")[0]
|
||||
if not filename:
|
||||
filename = "downloaded_file"
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
data,
|
||||
filename=filename or fname_guess,
|
||||
mime_hint=mime_hint or declared_ct,
|
||||
# F.5a.8.5: forward metadata so the companion's single-
|
||||
# call route (when advertised) bundles upload + attach +
|
||||
# featured in one PHP request. ``_apply_metadata_and_attach``
|
||||
# below is a no-op in that case.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
await _apply_metadata_and_attach(
|
||||
self.client,
|
||||
media,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
)
|
||||
|
||||
# Create FormData for upload
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("file", file_content, filename=filename, content_type=content_type)
|
||||
|
||||
# Upload to WordPress using client's upload method
|
||||
# Note: We need to use the client's base_url and auth directly for file upload
|
||||
upload_url = f"{self.client.base_url}/media"
|
||||
headers = {
|
||||
"Authorization": self.client.auth_header,
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(upload_url, data=form, headers=headers) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"Upload failed (HTTP {response.status}): {error_text}")
|
||||
|
||||
media = await response.json()
|
||||
|
||||
# Update metadata if provided
|
||||
if title or alt_text or caption:
|
||||
update_data = {}
|
||||
if title:
|
||||
update_data["title"] = title
|
||||
if alt_text:
|
||||
update_data["alt_text"] = alt_text
|
||||
if caption:
|
||||
update_data["caption"] = caption
|
||||
|
||||
await self.client.post(f"media/{media['id']}", json_data=update_data)
|
||||
|
||||
result = {
|
||||
"id": media["id"],
|
||||
"title": media["title"]["rendered"],
|
||||
"url": media["source_url"],
|
||||
"mime_type": media["mime_type"],
|
||||
"message": f"Media uploaded from URL successfully with ID {media['id']}",
|
||||
}
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
mime=media.get("mime_type") or mime_hint or declared_ct,
|
||||
size_bytes=len(data),
|
||||
source="url",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source=url), indent=2)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error": str(e), "message": f"Failed to upload media from URL: {str(e)}"}, indent=2
|
||||
{"error_code": "INTERNAL", "message": f"Upload from URL failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def upload_media_from_base64(
|
||||
self,
|
||||
data: str,
|
||||
filename: str,
|
||||
mime: str | None = None,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
skip_optimize: bool = False,
|
||||
convert_to: str | None = None,
|
||||
) -> str:
|
||||
"""Upload a base64-encoded file to the WordPress media library."""
|
||||
try:
|
||||
raw = _decode_base64(data)
|
||||
raw, mime_hint = _maybe_optimize(raw, mime, skip=skip_optimize, convert_to=convert_to)
|
||||
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
raw,
|
||||
filename=filename,
|
||||
mime_hint=mime_hint or mime,
|
||||
# F.5a.8.5: single-call path when companion advertises it.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
await _apply_metadata_and_attach(
|
||||
self.client,
|
||||
media,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
)
|
||||
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id,
|
||||
mime=media.get("mime_type") or mime_hint or mime,
|
||||
size_bytes=len(raw),
|
||||
source="base64",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source="base64"), indent=2)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Base64 upload failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def update_media(
|
||||
|
||||
397
plugins/wordpress/handlers/media_attach.py
Normal file
397
plugins/wordpress/handlers/media_attach.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""F.5a.3: WooCommerce product image attachment + WP featured-image tool.
|
||||
|
||||
WooCommerce `PUT /products/{id}` with `images: []` REPLACES the whole gallery.
|
||||
For additive behaviour we GET-merge-PUT. We only ever reference existing
|
||||
media by `id` (never pass external `src` — WC would re-download it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_core import (
|
||||
fetch_url_bytes,
|
||||
wp_raw_upload,
|
||||
wp_set_featured_media,
|
||||
wp_update_media_metadata,
|
||||
)
|
||||
from plugins.wordpress.handlers._media_security import (
|
||||
DEFAULT_MAX_BYTES,
|
||||
UploadError,
|
||||
ssrf_check,
|
||||
)
|
||||
from plugins.wordpress.handlers.media import _decode_base64, _maybe_optimize
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "attach_media_to_product",
|
||||
"method_name": "attach_media_to_product",
|
||||
"description": "Attach existing media library items to a WooCommerce product. Use role='main' for the primary image (gallery index 0) or role='gallery' for extra images. mode='append' preserves the existing gallery; mode='replace' wipes and rewrites it.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_id": {"type": "integer", "minimum": 1},
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"minItems": 1,
|
||||
},
|
||||
"role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["append", "replace"],
|
||||
"default": "append",
|
||||
},
|
||||
},
|
||||
"required": ["product_id", "media_ids"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_and_attach_to_product",
|
||||
"method_name": "upload_and_attach_to_product",
|
||||
"description": "Upload a single image (from base64 or URL) and attach it to a WooCommerce product in one call.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_id": {"type": "integer", "minimum": 1},
|
||||
"source": {"type": "string", "enum": ["base64", "url"]},
|
||||
"data": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Base64 payload (when source=base64)",
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Public URL (when source=url)",
|
||||
},
|
||||
"filename": {"type": "string"},
|
||||
"mime": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["append", "replace"],
|
||||
"default": "append",
|
||||
},
|
||||
"skip_optimize": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["product_id", "source", "filename"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "set_featured_image",
|
||||
"method_name": "set_featured_image",
|
||||
"description": "Set a WordPress post's featured image to an existing media library item.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"media_id": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"required": ["post_id", "media_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class MediaAttachHandler:
|
||||
"""Media-to-product and media-to-post attachment helpers.
|
||||
|
||||
F.X.fix-pass4 — Two clients, one purpose:
|
||||
|
||||
* ``self.client`` authenticates WC REST (``/wc/v3/*``) — typically
|
||||
a Consumer Key / Secret pair on a WC site.
|
||||
* ``self.wp_media_client`` authenticates WP core REST
|
||||
(``/wp/v2/media``, ``/wp/v2/posts/{id}``) — must be a WP user +
|
||||
Application Password. WC keys do NOT work for /wp/v2/.
|
||||
|
||||
Resolution logic at call time:
|
||||
|
||||
* If a dedicated ``wp_media_client`` was passed in, use it for
|
||||
/wp/v2/* — this is the explicit "user configured WP App Password
|
||||
on a WC site" path.
|
||||
* Else, if ``self.client.username`` starts with ``ck_`` we know
|
||||
the primary is a WC-keys pair that cannot hit /wp/v2/* — surface
|
||||
the structured ``WP_CREDENTIALS_MISSING`` error so the user
|
||||
knows where to add credentials.
|
||||
* Else, fall back to ``self.client`` — for the legacy
|
||||
single-credential WC mode (Application Password only) and for
|
||||
tests instantiating the handler with a plain WordPressClient.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: WordPressClient,
|
||||
*,
|
||||
wp_media_client: WordPressClient | None = None,
|
||||
):
|
||||
self.client = client
|
||||
self.wp_media_client = wp_media_client
|
||||
|
||||
def _require_wp_media_client(self) -> WordPressClient | None:
|
||||
"""Pick the right client for /wp/v2/* calls, or None if no
|
||||
viable credential set is available."""
|
||||
if self.wp_media_client is not None:
|
||||
return self.wp_media_client
|
||||
primary_user = getattr(self.client, "username", "") or ""
|
||||
if primary_user.startswith("ck_"):
|
||||
# Primary is a WC consumer key — won't authenticate WP REST.
|
||||
return None
|
||||
# Primary is an Application Password (WP plugin mode or
|
||||
# legacy single-credential WC mode) — same client works.
|
||||
return self.client
|
||||
|
||||
@staticmethod
|
||||
def _wp_credentials_missing_error() -> str:
|
||||
"""Standard error when wp_media_client is None on WC sites."""
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "WP_CREDENTIALS_MISSING",
|
||||
"message": (
|
||||
"This tool needs a WordPress Application Password to upload "
|
||||
"media via /wp/v2/media. WooCommerce Consumer Key + Secret "
|
||||
"do NOT authenticate the WP core REST API. Open the site in "
|
||||
"the dashboard and fill 'WordPress Username' + 'WordPress "
|
||||
"Application Password' under Connection Settings (advanced)."
|
||||
),
|
||||
"remediation": {
|
||||
"where": "Dashboard → site → Connection Settings",
|
||||
"fields": ["wp_username", "wp_app_password"],
|
||||
"wp_admin_path": "Users → Profile → Application Passwords",
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def attach_media_to_product(
|
||||
self,
|
||||
product_id: int,
|
||||
media_ids: list[int],
|
||||
role: str = "gallery",
|
||||
mode: str = "append",
|
||||
) -> str:
|
||||
try:
|
||||
if role not in ("main", "gallery"):
|
||||
raise UploadError("BAD_ROLE", f"role must be 'main' or 'gallery', got '{role}'.")
|
||||
if mode not in ("append", "replace"):
|
||||
raise UploadError("BAD_MODE", f"mode must be 'append' or 'replace', got '{mode}'.")
|
||||
|
||||
# /wp/v2/media GET to validate media_ids needs WP creds.
|
||||
if self._require_wp_media_client() is None:
|
||||
return self._wp_credentials_missing_error()
|
||||
|
||||
await self._validate_media_ids(media_ids)
|
||||
|
||||
new_images = _merge_product_images(
|
||||
existing=await self._get_product_images(product_id),
|
||||
new_ids=media_ids,
|
||||
role=role,
|
||||
mode=mode,
|
||||
)
|
||||
updated = await self.client.put(
|
||||
f"products/{product_id}",
|
||||
json_data={"images": new_images},
|
||||
use_woocommerce=True,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"product_id": product_id,
|
||||
"images": [
|
||||
{"id": i.get("id"), "src": i.get("src")} for i in updated.get("images", [])
|
||||
],
|
||||
"message": f"Attached {len(media_ids)} media item(s) to product {product_id}.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Attach failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def upload_and_attach_to_product(
|
||||
self,
|
||||
product_id: int,
|
||||
source: str,
|
||||
filename: str,
|
||||
data: str | None = None,
|
||||
url: str | None = None,
|
||||
mime: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
role: str = "gallery",
|
||||
mode: str = "append",
|
||||
skip_optimize: bool = False,
|
||||
) -> str:
|
||||
try:
|
||||
wp_client = self._require_wp_media_client()
|
||||
if wp_client is None:
|
||||
return self._wp_credentials_missing_error()
|
||||
if source == "base64":
|
||||
if not data:
|
||||
raise UploadError("MISSING_FIELD", "source=base64 requires 'data'.")
|
||||
raw = _decode_base64(data)
|
||||
mime_hint = mime
|
||||
elif source == "url":
|
||||
if not url:
|
||||
raise UploadError("MISSING_FIELD", "source=url requires 'url'.")
|
||||
ssrf = ssrf_check(url)
|
||||
if not ssrf.allowed:
|
||||
raise UploadError("SSRF", ssrf.reason or "URL rejected.", {"url": url})
|
||||
raw, declared_ct, _ = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
|
||||
mime_hint = mime or declared_ct
|
||||
else:
|
||||
raise UploadError(
|
||||
"BAD_SOURCE", f"source must be 'base64' or 'url', got '{source}'."
|
||||
)
|
||||
|
||||
raw, mime_hint = _maybe_optimize(raw, mime_hint, skip=skip_optimize)
|
||||
media = await wp_raw_upload(wp_client, raw, filename=filename, mime_hint=mime_hint)
|
||||
if alt_text is not None:
|
||||
await wp_update_media_metadata(wp_client, media["id"], alt_text=alt_text)
|
||||
|
||||
# Chain into attach
|
||||
attach_json = await self.attach_media_to_product(
|
||||
product_id=product_id, media_ids=[media["id"]], role=role, mode=mode
|
||||
)
|
||||
attach = json.loads(attach_json)
|
||||
return json.dumps(
|
||||
{
|
||||
"media_id": media["id"],
|
||||
"media_url": media.get("source_url"),
|
||||
"product_id": product_id,
|
||||
"attach_result": attach,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Upload+attach failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def set_featured_image(self, post_id: int, media_id: int) -> str:
|
||||
"""Set the featured image of a WC product OR a WP post.
|
||||
|
||||
F.X.fix-pass5 — auto-detect routing: WC products (CPT) are not
|
||||
addressable via ``/wp/v2/posts/{id}``; their featured image is
|
||||
the first entry of the ``images`` array on the WC product
|
||||
record. We try the WC ``products/{id}`` endpoint first because
|
||||
this tool is WC-namespaced; if that 404s we fall through to
|
||||
the legacy WP REST behaviour for regular posts/pages.
|
||||
"""
|
||||
try:
|
||||
wp_client = self._require_wp_media_client()
|
||||
if wp_client is None:
|
||||
return self._wp_credentials_missing_error()
|
||||
await self._validate_media_ids([media_id])
|
||||
|
||||
# 1. Try WC product first.
|
||||
wc_product: dict[str, Any] | None = None
|
||||
try:
|
||||
wc_product = await self.client.get(f"products/{post_id}", use_woocommerce=True)
|
||||
except Exception:
|
||||
wc_product = None
|
||||
|
||||
if isinstance(wc_product, dict) and wc_product.get("id"):
|
||||
# Featured = images[0]; preserve gallery via the existing
|
||||
# role="main" mode="replace" merge logic.
|
||||
new_images = _merge_product_images(
|
||||
existing=list(wc_product.get("images") or []),
|
||||
new_ids=[media_id],
|
||||
role="main",
|
||||
mode="replace",
|
||||
)
|
||||
updated = await self.client.put(
|
||||
f"products/{post_id}",
|
||||
json_data={"images": new_images},
|
||||
use_woocommerce=True,
|
||||
)
|
||||
featured_id = (updated.get("images") or [{}])[0].get("id", media_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"post_id": post_id,
|
||||
"featured_media": featured_id,
|
||||
"context": "product",
|
||||
"message": (
|
||||
f"Set media {media_id} as featured image of WooCommerce "
|
||||
f"product {post_id}."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# 2. Fall back to WP REST for regular posts/pages.
|
||||
result = await wp_set_featured_media(wp_client, post_id, media_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"post_id": post_id,
|
||||
"featured_media": result.get("featured_media", media_id),
|
||||
"context": "post",
|
||||
"message": f"Set media {media_id} as featured image of post {post_id}.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Set featured failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
# --- internals ----------------------------------------------------------
|
||||
|
||||
async def _get_product_images(self, product_id: int) -> list[dict[str, Any]]:
|
||||
product = await self.client.get(f"products/{product_id}", use_woocommerce=True)
|
||||
return list(product.get("images", []))
|
||||
|
||||
async def _validate_media_ids(self, media_ids: list[int]) -> None:
|
||||
if not media_ids:
|
||||
raise UploadError("MISSING_FIELD", "media_ids must not be empty.")
|
||||
# /wp/v2/media GET — needs WP creds. Caller has already
|
||||
# validated wp_media_client is present.
|
||||
wp_client = self.wp_media_client or self.client
|
||||
for mid in media_ids:
|
||||
try:
|
||||
await wp_client.get(f"media/{mid}")
|
||||
except Exception as e:
|
||||
raise UploadError(
|
||||
"MEDIA_NOT_FOUND",
|
||||
f"Media id {mid} not found in the library.",
|
||||
{"media_id": mid, "error": str(e)},
|
||||
) from e
|
||||
|
||||
|
||||
def _merge_product_images(
|
||||
existing: list[dict[str, Any]],
|
||||
new_ids: list[int],
|
||||
*,
|
||||
role: str,
|
||||
mode: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Pure function — easy to unit-test."""
|
||||
new_entries = [{"id": mid} for mid in new_ids]
|
||||
|
||||
if mode == "replace":
|
||||
if role == "main":
|
||||
# First = main, rest = gallery
|
||||
return new_entries
|
||||
# role=gallery + mode=replace → keep existing main (index 0), replace the rest
|
||||
main = existing[:1]
|
||||
return main + new_entries
|
||||
|
||||
# mode == "append"
|
||||
if role == "main":
|
||||
# New first image becomes main; existing main demoted into gallery
|
||||
return new_entries + existing
|
||||
# role=gallery + mode=append → keep existing, add new to the end (dedupe by id)
|
||||
seen_ids = {i.get("id") for i in existing if i.get("id") is not None}
|
||||
tail = [e for e in new_entries if e["id"] not in seen_ids]
|
||||
return existing + tail
|
||||
250
plugins/wordpress/handlers/media_bulk.py
Normal file
250
plugins/wordpress/handlers/media_bulk.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""F.5a.8.3 — Bulk media library operations.
|
||||
|
||||
Wraps stock ``wp/v2/media`` REST endpoints with two batch tools:
|
||||
|
||||
* ``wordpress_bulk_delete_media`` — delete (trash or force-delete) a list of
|
||||
attachments in one call, collecting per-item results.
|
||||
* ``wordpress_bulk_reassign_media`` — change the ``post`` parent of a list
|
||||
of attachments in one call (useful for moving media between posts, or
|
||||
detaching from a deleted parent).
|
||||
|
||||
Both tools use the existing authenticated REST client. They iterate
|
||||
serially with a small concurrency cap so a large batch doesn't flood a
|
||||
shared-hosting WP backend, but the whole call returns a single JSON
|
||||
envelope with ``processed`` / ``errors`` / ``total`` — matching the
|
||||
shape used by F.18.2 bulk-meta.
|
||||
|
||||
Per-call cap: 100 attachment IDs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.media_bulk")
|
||||
|
||||
_MAX_IDS_PER_CALL = 100
|
||||
_CONCURRENCY = 4 # parallel in-flight REST calls
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "bulk_delete_media",
|
||||
"method_name": "bulk_delete_media",
|
||||
"description": (
|
||||
"Delete (trash or permanently remove) a list of media "
|
||||
"attachments in a single call. Max 100 IDs per request. "
|
||||
"Returns processed / errors / total. Uses stock "
|
||||
"/wp/v2/media/{id} DELETE so no companion plugin is "
|
||||
"required, but issues N requests (with a small concurrency "
|
||||
"cap); for 1000+ attachments, paginate client-side."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"minItems": 1,
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
"description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"true = permanently delete (bypass trash). "
|
||||
"false = move to trash. Media in trash > 30 "
|
||||
"days is removed by WP cron by default."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["media_ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "bulk_reassign_media",
|
||||
"method_name": "bulk_reassign_media",
|
||||
"description": (
|
||||
"Reassign the parent post of a list of media attachments "
|
||||
"in a single call. Useful for moving attachments between "
|
||||
"posts, or detaching (use target_post=0). Max 100 IDs. "
|
||||
"Returns processed / errors / total."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"minItems": 1,
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
"description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
|
||||
},
|
||||
"target_post": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Post ID to assign as the new parent. " "0 = detach (orphan the media)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["media_ids", "target_post"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class MediaBulkHandler:
|
||||
"""Batch delete + reassign for WP media attachments."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Input normalization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize_ids(self, media_ids: list[Any]) -> list[int]:
|
||||
"""Dedup, coerce to int, drop non-positive, cap at _MAX_IDS_PER_CALL.
|
||||
|
||||
Order is preserved so the caller can correlate request and response
|
||||
by index if they care.
|
||||
"""
|
||||
out: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for raw in media_ids or []:
|
||||
try:
|
||||
i = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i > 0 and i not in seen:
|
||||
out.append(i)
|
||||
seen.add(i)
|
||||
if len(out) >= _MAX_IDS_PER_CALL:
|
||||
break
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def bulk_delete_media(
|
||||
self,
|
||||
media_ids: list[int],
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
ids = self._normalize_ids(media_ids or [])
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "media_ids must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
params = {"force": "true" if force else "false"}
|
||||
sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
|
||||
processed: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async def _one(mid: int) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.delete(f"media/{mid}", params=params)
|
||||
processed.append({"id": mid})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append({"id": mid, "error": str(exc)})
|
||||
|
||||
await asyncio.gather(*[_one(i) for i in ids])
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": not errors,
|
||||
"total": len(ids),
|
||||
"processed": len(processed),
|
||||
"errors": errors,
|
||||
"force": force,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk reassign
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def bulk_reassign_media(
|
||||
self,
|
||||
media_ids: list[int],
|
||||
target_post: int,
|
||||
) -> str:
|
||||
try:
|
||||
target = int(target_post)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "target_post must be an integer (0 to detach).",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
if target < 0:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "target_post must be >= 0.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
ids = self._normalize_ids(media_ids or [])
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "media_ids must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(_CONCURRENCY)
|
||||
processed: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async def _one(mid: int) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.post(
|
||||
f"media/{mid}",
|
||||
json_data={"post": target},
|
||||
)
|
||||
processed.append({"id": mid})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append({"id": mid, "error": str(exc)})
|
||||
|
||||
await asyncio.gather(*[_one(i) for i in ids])
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": not errors,
|
||||
"total": len(ids),
|
||||
"processed": len(processed),
|
||||
"errors": errors,
|
||||
"target_post": target,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
322
plugins/wordpress/handlers/media_chunked.py
Normal file
322
plugins/wordpress/handlers/media_chunked.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Chunked media upload tools (F.5a.5).
|
||||
|
||||
Exposes four tools that wrap `core.upload_sessions.UploadSessionStore`:
|
||||
|
||||
- `upload_media_chunked_start(filename, total_bytes, mime?, sha256?)`
|
||||
- `upload_media_chunked_chunk(session_id, index, data_b64)`
|
||||
- `upload_media_chunked_finish(session_id, title?, alt_text?, caption?,
|
||||
attach_to_post?, set_featured?, skip_optimize?)`
|
||||
- `upload_media_chunked_abort(session_id)`
|
||||
|
||||
At `finish`, reuses the existing F.5a.1/.2 primitives: assembled bytes →
|
||||
optional Pillow optimization → `wp_raw_upload` → metadata/attach/featured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64 as _b64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from core.media_audit import log_media_upload
|
||||
from core.upload_sessions import (
|
||||
UploadSessionError,
|
||||
UploadSessionStore,
|
||||
get_upload_session_store,
|
||||
)
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._media_core import wp_raw_upload
|
||||
from plugins.wordpress.handlers._media_security import UploadError
|
||||
from plugins.wordpress.handlers.media import (
|
||||
_apply_metadata_and_attach,
|
||||
_format_upload_result,
|
||||
_maybe_optimize,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for ToolGenerator."""
|
||||
return [
|
||||
{
|
||||
"name": "upload_media_chunked_start",
|
||||
"method_name": "upload_media_chunked_start",
|
||||
"description": (
|
||||
"Start a chunked upload session for a large media file. "
|
||||
"Returns a session_id to use for subsequent chunk/finish/abort "
|
||||
"calls. TTL 1h, hard cap 500 MB per session."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {"type": "string", "description": "Filename with extension."},
|
||||
"total_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Declared total size in bytes.",
|
||||
},
|
||||
"mime": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "MIME hint; still sniffed at finish.",
|
||||
},
|
||||
"sha256": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional hex sha256 of the full payload; verified at finish.",
|
||||
},
|
||||
},
|
||||
"required": ["filename", "total_bytes"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_chunk",
|
||||
"method_name": "upload_media_chunked_chunk",
|
||||
"description": (
|
||||
"Append a single base64-encoded chunk to a chunked upload session. "
|
||||
"Chunks must arrive in order starting at index 0."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
"data_b64": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded chunk bytes.",
|
||||
},
|
||||
"chunk_sha256": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Optional hex sha256 of this chunk.",
|
||||
},
|
||||
},
|
||||
"required": ["session_id", "index", "data_b64"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_finish",
|
||||
"method_name": "upload_media_chunked_finish",
|
||||
"description": (
|
||||
"Finalize a chunked upload: assemble + optimize + upload to "
|
||||
"WordPress. Verifies sha256 if supplied at start."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"attach_to_post": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
},
|
||||
"set_featured": {"type": "boolean", "default": False},
|
||||
"skip_optimize": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_abort",
|
||||
"method_name": "upload_media_chunked_abort",
|
||||
"description": "Abort a chunked upload session and delete its spill file.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string"},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "upload_media_chunked_status",
|
||||
"method_name": "upload_media_chunked_status",
|
||||
"description": (
|
||||
"Query the current state of a chunked-upload session — returns "
|
||||
"``received_bytes`` and ``next_chunk`` so the caller can resume "
|
||||
"after a disconnect. The session survives for 1 h after the "
|
||||
"last activity; re-start the session with ``upload_media_chunked_start`` "
|
||||
"if it has expired or been aborted. Returns ``{error_code: "
|
||||
"NOT_FOUND}`` when the session is unknown."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Session ID returned by upload_media_chunked_start.",
|
||||
},
|
||||
},
|
||||
"required": ["session_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _decode_chunk(data_b64: str) -> bytes:
|
||||
s = (data_b64 or "").strip()
|
||||
if s.startswith("data:") and "," in s:
|
||||
s = s.split(",", 1)[1]
|
||||
s = s.replace("\n", "").replace("\r", "").replace(" ", "")
|
||||
try:
|
||||
return _b64.b64decode(s, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
raise UploadSessionError("BAD_BASE64", f"Invalid base64 chunk: {e}") from e
|
||||
|
||||
|
||||
class MediaChunkedHandler:
|
||||
"""Chunked-upload tool handler for the WordPress plugin."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: WordPressClient,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
store: UploadSessionStore | None = None,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.user_id = user_id or "admin"
|
||||
self._store = store
|
||||
|
||||
@property
|
||||
def store(self) -> UploadSessionStore:
|
||||
return self._store or get_upload_session_store()
|
||||
|
||||
async def upload_media_chunked_start(
|
||||
self,
|
||||
filename: str,
|
||||
total_bytes: int,
|
||||
mime: str | None = None,
|
||||
sha256: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
sess = await self.store.start(
|
||||
user_id=self.user_id,
|
||||
filename=filename,
|
||||
total_bytes=total_bytes,
|
||||
mime=mime,
|
||||
sha256=sha256,
|
||||
)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
async def upload_media_chunked_chunk(
|
||||
self,
|
||||
session_id: str,
|
||||
index: int,
|
||||
data_b64: str,
|
||||
chunk_sha256: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
data = _decode_chunk(data_b64)
|
||||
sess = await self.store.append_chunk(session_id, index, data, chunk_sha256=chunk_sha256)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
async def upload_media_chunked_finish(
|
||||
self,
|
||||
session_id: str,
|
||||
title: str | None = None,
|
||||
alt_text: str | None = None,
|
||||
caption: str | None = None,
|
||||
attach_to_post: int | None = None,
|
||||
set_featured: bool = False,
|
||||
skip_optimize: bool = False,
|
||||
) -> str:
|
||||
from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
|
||||
|
||||
try:
|
||||
get_tool_rate_limiter().check(
|
||||
"wordpress_upload_media_chunked_finish",
|
||||
self.user_id if self.user_id != "admin" else None,
|
||||
)
|
||||
except ToolRateLimitError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
|
||||
try:
|
||||
sess, assembled = await self.store.finalize(session_id)
|
||||
data, mime_hint = _maybe_optimize(assembled, sess.mime, skip=skip_optimize)
|
||||
media = await wp_raw_upload(
|
||||
self.client,
|
||||
data,
|
||||
filename=sess.filename,
|
||||
mime_hint=mime_hint or sess.mime,
|
||||
# F.5a.8.5: single-call upload+metadata+attach+featured
|
||||
# when the companion advertises it.
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
)
|
||||
await _apply_metadata_and_attach(
|
||||
self.client,
|
||||
media,
|
||||
title=title,
|
||||
alt_text=alt_text,
|
||||
caption=caption,
|
||||
attach_to_post=attach_to_post,
|
||||
set_featured=set_featured,
|
||||
)
|
||||
log_media_upload(
|
||||
site=self.client.site_url,
|
||||
user_id=self.user_id if self.user_id != "admin" else None,
|
||||
mime=media.get("mime_type") or mime_hint or sess.mime,
|
||||
size_bytes=len(data),
|
||||
source="chunked",
|
||||
media_id=media.get("id"),
|
||||
)
|
||||
return json.dumps(_format_upload_result(media, source="chunked"), indent=2)
|
||||
except UploadSessionError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except UploadError as e:
|
||||
return json.dumps(e.to_dict(), indent=2)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return json.dumps(
|
||||
{"error_code": "INTERNAL", "message": f"Chunked finish failed: {e}"}, indent=2
|
||||
)
|
||||
|
||||
async def upload_media_chunked_status(self, session_id: str) -> str:
|
||||
"""Return the current received_bytes / next_chunk for a session.
|
||||
|
||||
Enables a resume-after-disconnect flow: the client keeps the
|
||||
session_id from the original ``start`` call, then queries this
|
||||
tool to discover how many bytes the server already has before
|
||||
resuming ``chunk`` calls at the reported ``next_chunk`` index.
|
||||
Aborted sessions (spill file gone) still return their last
|
||||
known status, clearly marked by the ``status`` field.
|
||||
"""
|
||||
try:
|
||||
sess = await self.store.get(session_id)
|
||||
if sess is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "NO_SESSION",
|
||||
"message": (
|
||||
"Session not found. Either it has never existed, "
|
||||
"already been finalised, or been aborted. Start a "
|
||||
"new session with upload_media_chunked_start."
|
||||
),
|
||||
"session_id": session_id,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return json.dumps(sess.to_public_dict(), indent=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps(
|
||||
{
|
||||
"error_code": "INTERNAL",
|
||||
"message": f"Status lookup failed: {exc}",
|
||||
"session_id": session_id,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def upload_media_chunked_abort(self, session_id: str) -> str:
|
||||
removed = await self.store.abort(session_id)
|
||||
return json.dumps({"session_id": session_id, "aborted": bool(removed)}, indent=2)
|
||||
229
plugins/wordpress/handlers/media_probe.py
Normal file
229
plugins/wordpress/handlers/media_probe.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""F.5a.6.3 — Probe WordPress upload limits with 24 h cache.
|
||||
|
||||
Tries the airano-mcp-bridge companion endpoint first (which can read
|
||||
PHP ini values directly); falls back to whatever the standard WP REST
|
||||
index publishes. Results are cached in-memory per site for 24 h.
|
||||
|
||||
The cache is keyed by ``(site_url, username)`` so admin and per-user
|
||||
clients don't poison each other's view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.media_probe")
|
||||
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "probe_upload_limits",
|
||||
"method_name": "probe_upload_limits",
|
||||
"description": (
|
||||
"Probe a WordPress site for its effective upload limits "
|
||||
"(upload_max_filesize, post_max_size, memory_limit, "
|
||||
"max_input_time, wp_max_upload_size). Uses the "
|
||||
"airano-mcp-bridge companion plugin if present, else "
|
||||
"best-effort from the WP REST index. Cached 24 h per site."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
fetched_at: float
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ProbeCache:
|
||||
"""Process-local TTL cache keyed by (site_url, username)."""
|
||||
|
||||
ttl: float = CACHE_TTL_SECONDS
|
||||
_entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
|
||||
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if (time.time() - entry.fetched_at) > self.ttl:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return dict(entry.data)
|
||||
|
||||
async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
_cache = _ProbeCache()
|
||||
|
||||
|
||||
def get_probe_cache() -> _ProbeCache:
|
||||
return _cache
|
||||
|
||||
|
||||
_LIMIT_KEYS = (
|
||||
"upload_max_filesize",
|
||||
"post_max_size",
|
||||
"memory_limit",
|
||||
"max_input_time",
|
||||
"wp_max_upload_size",
|
||||
)
|
||||
|
||||
# Byte-valued keys among `_LIMIT_KEYS` — used by F.5a.7 route selection to
|
||||
# decide whether to prefer the companion upload-chunk route over /wp/v2/media.
|
||||
_BYTE_VALUED_KEYS = ("upload_max_filesize", "post_max_size", "wp_max_upload_size")
|
||||
|
||||
|
||||
def _empty_limits() -> dict[str, Any]:
|
||||
return dict.fromkeys(_LIMIT_KEYS)
|
||||
|
||||
|
||||
def parse_php_size(value: Any) -> int | None:
|
||||
"""Parse a PHP ``ini_get`` size string like ``"64M"`` to bytes.
|
||||
|
||||
Accepts an already-numeric value (returns it as-is cast to int), a suffix
|
||||
of K/M/G/T (both upper- and lower-case), or a bare integer string.
|
||||
Returns None if the value can't be parsed or represents "no limit" (``-1``).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return None if value < 0 else value
|
||||
if isinstance(value, float):
|
||||
return None if value < 0 else int(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
if s in {"-1", "0"}:
|
||||
return None
|
||||
unit = s[-1].upper()
|
||||
multipliers = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
|
||||
try:
|
||||
if unit in multipliers:
|
||||
return int(float(s[:-1]) * multipliers[unit])
|
||||
return int(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def effective_upload_ceiling(limits: dict[str, Any] | None) -> int | None:
|
||||
"""Return the smallest relevant size ceiling (in bytes) across the limits.
|
||||
|
||||
For route-selection purposes we take the min of the byte-valued keys that
|
||||
are populated. If nothing is populated, returns None (= "unknown, treat
|
||||
every upload as small enough for the REST route").
|
||||
"""
|
||||
if not limits:
|
||||
return None
|
||||
parsed: list[int] = []
|
||||
for key in _BYTE_VALUED_KEYS:
|
||||
raw = limits.get(key)
|
||||
got = parse_php_size(raw)
|
||||
if got is not None:
|
||||
parsed.append(got)
|
||||
return min(parsed) if parsed else None
|
||||
|
||||
|
||||
class ProbeHandler:
|
||||
"""Read-only probe of a WordPress site's upload limits."""
|
||||
|
||||
def __init__(self, client: WordPressClient, *, cache: _ProbeCache | None = None) -> None:
|
||||
self.client = client
|
||||
self._cache = cache or _cache
|
||||
|
||||
@property
|
||||
def _cache_key(self) -> tuple[str, str]:
|
||||
return (self.client.site_url, self.client.username)
|
||||
|
||||
async def probe_upload_limits(self) -> str:
|
||||
cached = await self._cache.get(self._cache_key)
|
||||
if cached is not None:
|
||||
cached["cached"] = True
|
||||
return json.dumps(cached, indent=2)
|
||||
|
||||
result = await self._fetch_limits()
|
||||
await self._cache.set(self._cache_key, result)
|
||||
result_out = dict(result)
|
||||
result_out["cached"] = False
|
||||
return json.dumps(result_out, indent=2)
|
||||
|
||||
async def _fetch_limits(self) -> dict[str, Any]:
|
||||
limits = _empty_limits()
|
||||
source = "unknown"
|
||||
# Companion plugin first.
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/upload-limits",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
for k in _LIMIT_KEYS:
|
||||
if payload.get(k) is not None:
|
||||
limits[k] = payload[k]
|
||||
source = "companion"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Companion probe failed (%s); falling back to REST index.", exc)
|
||||
|
||||
# Fallback / supplement: REST index may expose wp_max_upload_size.
|
||||
if all(v is None for v in limits.values()):
|
||||
try:
|
||||
root = await self.client.get("", use_custom_namespace=True) # /wp-json/
|
||||
if isinstance(root, dict):
|
||||
if "wp_max_upload_size" in root:
|
||||
limits["wp_max_upload_size"] = root["wp_max_upload_size"]
|
||||
source = "rest_index"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("REST index probe failed: %s", exc)
|
||||
|
||||
# F.5a.7: expose byte-parsed ceiling so _media_core can pick the
|
||||
# best upload route without re-parsing PHP ini strings.
|
||||
ceiling = effective_upload_ceiling(limits)
|
||||
return {
|
||||
"site_url": self.client.site_url,
|
||||
"source": source,
|
||||
"companion_available": source == "companion",
|
||||
"limits": limits,
|
||||
"limits_bytes": {
|
||||
"upload_max_filesize": parse_php_size(limits.get("upload_max_filesize")),
|
||||
"post_max_size": parse_php_size(limits.get("post_max_size")),
|
||||
"wp_max_upload_size": parse_php_size(limits.get("wp_max_upload_size")),
|
||||
"effective_ceiling": ceiling,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_limits(
|
||||
client: WordPressClient, *, cache: _ProbeCache | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the cached limits dict for ``client`` without forcing a probe.
|
||||
|
||||
Used by `_media_core.wp_raw_upload` to decide whether to prefer the
|
||||
companion upload-chunk route. Returns None when no probe has been cached
|
||||
yet — callers treat that as "no hints available; take the standard path".
|
||||
"""
|
||||
c = cache or _cache
|
||||
key = (client.site_url, client.username)
|
||||
return await c.get(key)
|
||||
@@ -565,60 +565,89 @@ class PostsHandler:
|
||||
|
||||
Args:
|
||||
post_id: Post ID to retrieve
|
||||
fields: Comma-separated list of fields to return (e.g., 'id,title,status')
|
||||
fields: Comma-separated list of fields to return (e.g., 'id,title,status').
|
||||
When set, behaves as a STRICT allow-list — only the
|
||||
requested names (plus ``id``) are returned. Unknown
|
||||
names are ignored.
|
||||
|
||||
Returns:
|
||||
JSON string with post data
|
||||
JSON string with post data. Default projection includes
|
||||
``featured_media`` (int or 0), ``slug`` (str), and
|
||||
``featured_media_url`` (derived from
|
||||
``_embedded['wp:featuredmedia'][0].source_url``; empty
|
||||
string when no featured image is set) so callers can
|
||||
verify a post's featured image was set without a separate
|
||||
media-fetch round trip.
|
||||
"""
|
||||
try:
|
||||
# F.X.fix #4: default projection includes the fields every
|
||||
# caller asked for during F.X.test (featured_media, slug,
|
||||
# featured_media_url). When ``fields`` is supplied we honour
|
||||
# it as a STRICT allow-list — callers must opt in to each
|
||||
# name explicitly, no hidden always-on subset beyond id.
|
||||
params = {"_embed": "true"}
|
||||
field_map = {
|
||||
"id": "id",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"excerpt": "excerpt",
|
||||
"status": "status",
|
||||
"date": "date",
|
||||
"modified": "modified",
|
||||
"author": "_embedded",
|
||||
"categories": "categories",
|
||||
"tags": "tags",
|
||||
"link": "link",
|
||||
"slug": "slug",
|
||||
"featured_media": "featured_media",
|
||||
"featured_media_url": "_embedded",
|
||||
"word_count": "content",
|
||||
}
|
||||
if fields:
|
||||
# Map our field names to WordPress API _fields
|
||||
wp_fields = set()
|
||||
requested = {f.strip().lower() for f in fields.split(",")}
|
||||
field_map = {
|
||||
"id": "id",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"excerpt": "excerpt",
|
||||
"status": "status",
|
||||
"date": "date",
|
||||
"modified": "modified",
|
||||
"author": "_embedded",
|
||||
"categories": "categories",
|
||||
"tags": "tags",
|
||||
"link": "link",
|
||||
"slug": "slug",
|
||||
}
|
||||
wp_fields: set[str] = set()
|
||||
requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
|
||||
for f in requested:
|
||||
if f in field_map:
|
||||
wp_fields.add(field_map[f])
|
||||
# Always include id and title for basic identification
|
||||
wp_fields.update({"id", "title"})
|
||||
wp_fields.add("id")
|
||||
params["_fields"] = ",".join(wp_fields)
|
||||
|
||||
post = await self.client.get(f"posts/{post_id}", params=params)
|
||||
|
||||
# Derive featured_media_url from the embedded media entry.
|
||||
# WP returns the embedded resource under
|
||||
# ``_embedded['wp:featuredmedia']`` (hyphenated key, list of
|
||||
# one). Empty string when no featured image is attached.
|
||||
featured_media_url = ""
|
||||
embedded = post.get("_embedded") or {}
|
||||
media_entries = embedded.get("wp:featuredmedia") or []
|
||||
if isinstance(media_entries, list) and media_entries:
|
||||
first = media_entries[0] or {}
|
||||
if isinstance(first, dict):
|
||||
featured_media_url = first.get("source_url") or ""
|
||||
|
||||
# Build full result
|
||||
full_result = {
|
||||
"id": post["id"],
|
||||
"title": post.get("title", {}).get("rendered", ""),
|
||||
"slug": post.get("slug", ""),
|
||||
"content": post.get("content", {}).get("rendered", ""),
|
||||
"excerpt": post.get("excerpt", {}).get("rendered", ""),
|
||||
"status": post.get("status", ""),
|
||||
"date": post.get("date", ""),
|
||||
"modified": post.get("modified", ""),
|
||||
"author": post.get("_embedded", {}).get("author", [{}])[0].get("name", "Unknown"),
|
||||
"author": embedded.get("author", [{}])[0].get("name", "Unknown"),
|
||||
"categories": post.get("categories", []),
|
||||
"tags": post.get("tags", []),
|
||||
"link": post.get("link", ""),
|
||||
"featured_media": post.get("featured_media", 0) or 0,
|
||||
"featured_media_url": featured_media_url,
|
||||
"word_count": _count_words(post.get("content", {}).get("rendered", "")),
|
||||
}
|
||||
|
||||
# Filter to requested fields only
|
||||
# Strict allow-list filtering when ``fields`` is provided.
|
||||
if fields:
|
||||
requested = {f.strip().lower() for f in fields.split(",")}
|
||||
# Always include id
|
||||
requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
|
||||
requested.add("id")
|
||||
result = {k: v for k, v in full_result.items() if k in requested}
|
||||
else:
|
||||
|
||||
@@ -255,6 +255,18 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": (
|
||||
"Optional. Pass the global attribute "
|
||||
"id from list_product_attributes to "
|
||||
"link this product to an existing "
|
||||
"global attribute (e.g. pa_color). "
|
||||
"Omit (or set 0) to create an inline "
|
||||
"custom attribute on this product only."
|
||||
),
|
||||
},
|
||||
"name": {"type": "string"},
|
||||
"options": {"type": "array", "items": {"type": "string"}},
|
||||
"visible": {"type": "boolean"},
|
||||
@@ -264,7 +276,12 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": 'Product attributes for variable products (e.g., [{"name": "Color", "options": ["Red", "Blue"], "variation": true}])',
|
||||
"description": (
|
||||
"Product attributes for variable products. "
|
||||
'Custom (per-product): [{"name": "Color", "options": ["Red", "Blue"], "variation": true}]. '
|
||||
'Global (linked, recommended): [{"id": 1, "options": ["Red", "Blue"], "variation": true}] — '
|
||||
"use list_product_attributes to find existing ids."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
|
||||
220
plugins/wordpress/handlers/regenerate_thumbnails.py
Normal file
220
plugins/wordpress/handlers/regenerate_thumbnails.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""F.5a.8.2 — Regenerate attachment thumbnails via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/regenerate-thumbnails`` (companion plugin v2.8.0+).
|
||||
Rebuilds the registered WP image sub-sizes via ``wp_generate_attachment_metadata``
|
||||
after an upload, a format conversion (F.5a.8.1), or when new sizes are
|
||||
registered by the active theme. Two modes:
|
||||
|
||||
1. ``ids=[...]`` — regenerate a specific list (up to 50 per call).
|
||||
2. ``all=True`` — iterate over ``image/*`` attachments in ID order with
|
||||
``offset``/``limit`` paging.
|
||||
|
||||
Tool: ``wordpress_regenerate_thumbnails``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.regenerate_thumbnails")
|
||||
|
||||
_MAX_IDS_PER_CALL = 50
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "regenerate_thumbnails",
|
||||
"method_name": "regenerate_thumbnails",
|
||||
"description": (
|
||||
"Rebuild attachment sub-sizes (the registered WP image sizes "
|
||||
"plus any from add_image_size() in the active theme). Use "
|
||||
"this after upload_media_from_url / _from_base64 with "
|
||||
"convert_to=webp|avif, after a theme switch that adds new "
|
||||
"sizes, or for legacy attachments missing thumbnails. Routes "
|
||||
"through the airano-mcp-bridge companion plugin v2.8.0+. "
|
||||
"Body shapes: either an 'ids' list to target specific "
|
||||
f"attachments (max {_MAX_IDS_PER_CALL} per call); or "
|
||||
"all=true with offset/limit for paged batch. Returns "
|
||||
"has_more + next_offset in batch mode so callers can continue."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
"maxItems": _MAX_IDS_PER_CALL,
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": (
|
||||
f"Attachment IDs (max {_MAX_IDS_PER_CALL}). "
|
||||
"Mutually exclusive with 'all'."
|
||||
),
|
||||
},
|
||||
"all": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Batch mode: iterate image attachments in ID order. "
|
||||
"Use with offset/limit for pagination."
|
||||
),
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"description": "Batch offset (for 'all' mode).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _MAX_IDS_PER_CALL,
|
||||
"default": _MAX_IDS_PER_CALL,
|
||||
"description": (
|
||||
f"Max attachments per batch page (capped at "
|
||||
f"{_MAX_IDS_PER_CALL} server-side)."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "write",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class RegenerateThumbnailsHandler:
|
||||
"""Rebuild attachment sub-sizes via the companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def regenerate_thumbnails(
|
||||
self,
|
||||
ids: list[int] | None = None,
|
||||
all: bool = False,
|
||||
offset: int = 0,
|
||||
limit: int = _MAX_IDS_PER_CALL,
|
||||
) -> str:
|
||||
"""Proxy to ``POST /airano-mcp/v1/regenerate-thumbnails``.
|
||||
|
||||
Exactly one of ``ids`` or ``all`` must be truthy. Input is validated
|
||||
client-side so obvious errors don't consume a round-trip.
|
||||
"""
|
||||
if not ids and not all:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": (
|
||||
"Provide either 'ids' (list of attachment IDs) or "
|
||||
"'all': true for batch mode."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if ids and all:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "'ids' and 'all' are mutually exclusive.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
if ids:
|
||||
# Deduplicate + cap + coerce.
|
||||
uniq: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for raw in ids:
|
||||
try:
|
||||
i = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i > 0 and i not in seen:
|
||||
uniq.append(i)
|
||||
seen.add(i)
|
||||
body["ids"] = uniq[:_MAX_IDS_PER_CALL]
|
||||
if not body["ids"]:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_request",
|
||||
"message": "'ids' must contain at least one positive integer.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
else:
|
||||
body["all"] = True
|
||||
body["offset"] = max(0, int(offset))
|
||||
body["limit"] = min(_MAX_IDS_PER_CALL, max(1, int(limit)))
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/regenerate-thumbnails",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("regenerate_thumbnails companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.8.0+. "
|
||||
"Run wordpress_probe_capabilities to verify the route "
|
||||
"is advertised."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.8.0",
|
||||
required_capability="upload_files",
|
||||
route="airano-mcp/v1/regenerate-thumbnails",
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ok": bool(
|
||||
(payload.get("processed") or 0) > 0
|
||||
or (not payload.get("errors") and not payload.get("attempted"))
|
||||
),
|
||||
"mode": payload.get("mode"),
|
||||
"attempted": payload.get("attempted", 0),
|
||||
"processed": payload.get("processed", 0),
|
||||
"skipped": list(payload.get("skipped") or []),
|
||||
"errors": list(payload.get("errors") or []),
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
}
|
||||
if payload.get("mode") == "all":
|
||||
result["offset"] = payload.get("offset")
|
||||
result["limit"] = payload.get("limit")
|
||||
result["has_more"] = bool(payload.get("has_more"))
|
||||
result["next_offset"] = payload.get("next_offset")
|
||||
result["total"] = payload.get("total")
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -188,7 +188,7 @@ class SEOHandler:
|
||||
# First, try to use the new health check endpoint (v1.1.0+)
|
||||
try:
|
||||
status_result = await self.client.get(
|
||||
"airano-mcp-seo-bridge/v1/status", use_custom_namespace=True
|
||||
"airano-mcp-bridge/v1/status", use_custom_namespace=True
|
||||
)
|
||||
|
||||
if status_result and isinstance(status_result, dict):
|
||||
@@ -234,12 +234,17 @@ class SEOHandler:
|
||||
first_item = result[0] if isinstance(result, list) and len(result) > 0 else {}
|
||||
meta = first_item.get("meta", {})
|
||||
|
||||
# Check for Rank Math fields
|
||||
# Check for Rank Math fields. Note: Rank Math stores the
|
||||
# meta title under ``rank_math_title`` (NOT
|
||||
# ``rank_math_seo_title``). The SEO API Bridge companion
|
||||
# plugin reads/writes the canonical key; this detection
|
||||
# must match or the plugin goes undetected even when
|
||||
# active.
|
||||
rank_math_active = any(
|
||||
key in meta
|
||||
for key in [
|
||||
"rank_math_focus_keyword",
|
||||
"rank_math_seo_title",
|
||||
"rank_math_title",
|
||||
"rank_math_description",
|
||||
]
|
||||
)
|
||||
@@ -312,7 +317,12 @@ class SEOHandler:
|
||||
seo_data.update(
|
||||
{
|
||||
"focus_keyword": meta.get("rank_math_focus_keyword", ""),
|
||||
"seo_title": meta.get("rank_math_seo_title", ""),
|
||||
# F.X.fix #2: Rank Math's canonical meta-title key
|
||||
# is ``rank_math_title``. The previous reader
|
||||
# used ``rank_math_seo_title`` which does not
|
||||
# exist in the Rank Math schema, so seo_title
|
||||
# was always blank.
|
||||
"seo_title": meta.get("rank_math_title", ""),
|
||||
"meta_description": meta.get("rank_math_description", ""),
|
||||
"additional_keywords": meta.get("rank_math_additional_keywords", ""),
|
||||
"canonical_url": meta.get("rank_math_canonical_url", ""),
|
||||
@@ -383,7 +393,7 @@ class SEOHandler:
|
||||
try:
|
||||
# Use SEO API Bridge endpoint for products (same as update_product_seo)
|
||||
result = await self.client.get(
|
||||
f"airano-mcp-seo-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
|
||||
f"airano-mcp-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
|
||||
)
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -452,11 +462,14 @@ class SEOHandler:
|
||||
meta = {}
|
||||
|
||||
if seo_check.get("rank_math", {}).get("active"):
|
||||
# Use Rank Math field names
|
||||
# Use Rank Math field names. F.X.fix #2: canonical meta
|
||||
# title key is ``rank_math_title`` (NOT
|
||||
# ``rank_math_seo_title``) — the latter was silently
|
||||
# written to an unread key so seo_title never appeared.
|
||||
if focus_keyword is not None:
|
||||
meta["rank_math_focus_keyword"] = focus_keyword
|
||||
if seo_title is not None:
|
||||
meta["rank_math_seo_title"] = seo_title
|
||||
meta["rank_math_title"] = seo_title
|
||||
if meta_description is not None:
|
||||
meta["rank_math_description"] = meta_description
|
||||
if additional_keywords is not None:
|
||||
@@ -592,7 +605,7 @@ class SEOHandler:
|
||||
|
||||
# Use SEO API Bridge endpoint for products
|
||||
await self.client.post(
|
||||
f"airano-mcp-seo-bridge/v1/products/{product_id}/seo",
|
||||
f"airano-mcp-bridge/v1/products/{product_id}/seo",
|
||||
json_data=data,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
101
plugins/wordpress/handlers/site_health.py
Normal file
101
plugins/wordpress/handlers/site_health.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""F.18.6 — Unified site-health snapshot via companion plugin.
|
||||
|
||||
Wraps ``GET /airano-mcp/v1/site-health`` (companion plugin v2.6.0+).
|
||||
Returns WordPress / PHP / server / database / plugins / theme /
|
||||
writability info in a single JSON envelope. Replaces the existing
|
||||
``get_site_health`` (which called the stock ``/wp-site-health`` REST
|
||||
endpoints) with a richer single-round-trip snapshot.
|
||||
|
||||
The legacy ``wordpress_get_site_health`` tool (site.py) is left intact;
|
||||
this adds a new ``wordpress_site_health`` tool that prefers the
|
||||
companion and gracefully reports ``companion_available: false`` when
|
||||
the plugin is missing or outdated.
|
||||
|
||||
Tool: ``wordpress_site_health()``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.site_health")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "site_health",
|
||||
"method_name": "site_health",
|
||||
"description": (
|
||||
"Unified site-health snapshot via the airano-mcp-bridge "
|
||||
"companion plugin (v2.6.0+). Single request returns WP + PHP + "
|
||||
"MySQL versions, loaded PHP extensions, server software + disk "
|
||||
"free, active plugins with versions, active theme, and "
|
||||
"writability checks. Falls back to `companion_available: false` "
|
||||
"when the plugin is missing. Requires manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class SiteHealthHandler:
|
||||
"""Unified site-health snapshot via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def site_health(self) -> str:
|
||||
try:
|
||||
payload = await self.client.get(
|
||||
"airano-mcp/v1/site-health",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("site_health companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"companion_available": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.6.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify. For the legacy "
|
||||
"path use wordpress_get_site_health (stock WP endpoints)."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.6.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/site-health",
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"companion_available": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": bool(payload.get("ok", True)),
|
||||
"companion_available": True,
|
||||
**payload,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
184
plugins/wordpress/handlers/transient_flush.py
Normal file
184
plugins/wordpress/handlers/transient_flush.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""F.18.5 — Transient flush via companion plugin.
|
||||
|
||||
Wraps ``POST /airano-mcp/v1/transient-flush`` (companion plugin v2.5.0+).
|
||||
Native PHP transient cleanup. Scopes:
|
||||
|
||||
- ``expired`` (default): runs ``delete_expired_transients()``; reports
|
||||
the number of expired rows removed.
|
||||
- ``all``: deletes every ``_transient_%`` row (both regular and
|
||||
optionally site transients on multisite).
|
||||
- ``pattern``: shell-glob match, e.g. ``rank_math_*``.
|
||||
|
||||
Tool: ``wordpress_transient_flush(scope="expired", pattern=None, ...)``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.handlers._companion_hint import (
|
||||
companion_install_hint as _companion_install_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mcphub.wordpress.transient_flush")
|
||||
|
||||
_VALID_SCOPES = ("expired", "all", "pattern")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "transient_flush",
|
||||
"method_name": "transient_flush",
|
||||
"description": (
|
||||
"Flush WordPress transients via the airano-mcp-bridge "
|
||||
"companion plugin (v2.5.0+). Scopes: 'expired' (default, "
|
||||
"delete_expired_transients), 'all' (every transient), "
|
||||
"'pattern' (shell glob like 'rank_math_*'). On multisite, "
|
||||
"`include_site_transients=true` also purges "
|
||||
"_site_transient_* rows. Requires manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": list(_VALID_SCOPES),
|
||||
"description": "Default 'expired'.",
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Shell-style glob (e.g. 'rank_math_*'). Required "
|
||||
"when scope='pattern'."
|
||||
),
|
||||
},
|
||||
"include_site_transients": {
|
||||
"type": "boolean",
|
||||
"description": "Default true. Only relevant on multisite.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _validate(scope: str | None, pattern: str | None) -> dict[str, Any] | None:
|
||||
if scope is None:
|
||||
scope = "expired"
|
||||
if scope not in _VALID_SCOPES:
|
||||
return {
|
||||
"error": "invalid_scope",
|
||||
"message": (f"scope must be one of {list(_VALID_SCOPES)}; got {scope!r}."),
|
||||
}
|
||||
if scope == "pattern" and not pattern:
|
||||
return {
|
||||
"error": "pattern_required",
|
||||
"message": "pattern is required when scope='pattern'.",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class TransientFlushHandler:
|
||||
"""Native PHP transient cleanup via companion plugin."""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def transient_flush(
|
||||
self,
|
||||
scope: str | None = "expired",
|
||||
pattern: str | None = None,
|
||||
include_site_transients: Any = True,
|
||||
) -> str:
|
||||
err = _validate(scope, pattern)
|
||||
if err is not None:
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
**err,
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Normalise include_site_transients to a real bool for the PHP side;
|
||||
# WP's parser is lenient but the aiohttp JSON filter elsewhere in
|
||||
# the client may drop "false"-y strings.
|
||||
include_flag: bool
|
||||
if isinstance(include_site_transients, bool):
|
||||
include_flag = include_site_transients
|
||||
elif isinstance(include_site_transients, (int, float)):
|
||||
include_flag = bool(include_site_transients)
|
||||
elif isinstance(include_site_transients, str):
|
||||
include_flag = include_site_transients.strip().lower() not in {
|
||||
"false",
|
||||
"0",
|
||||
"no",
|
||||
"off",
|
||||
"",
|
||||
}
|
||||
else:
|
||||
include_flag = True
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"scope": scope or "expired",
|
||||
"include_site_transients": include_flag,
|
||||
}
|
||||
if scope == "pattern":
|
||||
body["pattern"] = pattern
|
||||
|
||||
try:
|
||||
payload = await self.client.post(
|
||||
"airano-mcp/v1/transient-flush",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("transient_flush companion call failed: %s", exc)
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "companion_unreachable",
|
||||
"message": str(exc),
|
||||
"hint": (
|
||||
"Requires airano-mcp-bridge companion plugin v2.5.0+ "
|
||||
"and manage_options capability. Run "
|
||||
"wordpress_probe_capabilities to verify."
|
||||
),
|
||||
"install_hint": _companion_install_hint(
|
||||
min_version="2.5.0",
|
||||
required_capability="manage_options",
|
||||
route="airano-mcp/v1/transient-flush",
|
||||
),
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "invalid_response",
|
||||
"message": "companion returned a non-object payload",
|
||||
"scope": scope,
|
||||
"pattern": pattern,
|
||||
"deleted_count": 0,
|
||||
"deleted_sample": [],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
result = {"ok": bool(payload.get("ok", True)), **payload}
|
||||
return json.dumps(result, indent=2)
|
||||
@@ -65,7 +65,40 @@ class WordPressPlugin(BasePlugin):
|
||||
|
||||
# Initialize core WordPress handlers
|
||||
self.posts = handlers.PostsHandler(self.client)
|
||||
self.media = handlers.MediaHandler(self.client)
|
||||
self.media = handlers.MediaHandler(self.client, user_id=config.get("user_id"))
|
||||
# F.5a.4 / F.5a.9.x: AI image handler receives user_id AND site_id.
|
||||
# site_id is the primary input for resolving per-site provider API
|
||||
# keys; user_id is retained for audit / rate-limit context and for
|
||||
# the admin/env-fallback path when no site is known.
|
||||
self.ai_media = handlers.AIMediaHandler(
|
||||
self.client,
|
||||
user_id=config.get("user_id"),
|
||||
site_id=config.get("site_id"),
|
||||
)
|
||||
# F.5a.5: chunked media uploads (session store + disk spill)
|
||||
self.media_chunked = handlers.MediaChunkedHandler(
|
||||
self.client, user_id=config.get("user_id")
|
||||
)
|
||||
# F.5a.6.3: probe WP upload limits (24h cache)
|
||||
self.media_probe = handlers.ProbeHandler(self.client)
|
||||
# F.18.1: probe companion-plugin capabilities (24h cache)
|
||||
self.capabilities = handlers.CapabilitiesHandler(self.client)
|
||||
# F.18.2: batch post_meta writes via companion plugin
|
||||
self.bulk_meta = handlers.BulkMetaHandler(self.client)
|
||||
# F.18.3: structured JSON export via companion plugin
|
||||
self.export = handlers.ExportHandler(self.client)
|
||||
# F.18.4: cache purge via companion plugin
|
||||
self.cache_purge_handler = handlers.CachePurgeHandler(self.client)
|
||||
# F.18.5: native transient flush via companion plugin
|
||||
self.transient_flush_handler = handlers.TransientFlushHandler(self.client)
|
||||
# F.18.6: unified site-health snapshot via companion plugin
|
||||
self.site_health_handler = handlers.SiteHealthHandler(self.client)
|
||||
# F.18.7: audit-hook configuration + query
|
||||
self.audit_hook_handler = handlers.AuditHookHandler(self.client)
|
||||
# F.5a.8.2: regenerate attachment sub-sizes via companion plugin
|
||||
self.regenerate_thumbnails_handler = handlers.RegenerateThumbnailsHandler(self.client)
|
||||
# F.5a.8.3: bulk delete / reassign attachments via stock WP REST
|
||||
self.media_bulk = handlers.MediaBulkHandler(self.client)
|
||||
self.taxonomy = handlers.TaxonomyHandler(self.client)
|
||||
self.comments = handlers.CommentsHandler(self.client)
|
||||
self.users = handlers.UsersHandler(self.client)
|
||||
@@ -103,7 +136,19 @@ class WordPressPlugin(BasePlugin):
|
||||
|
||||
# Core WordPress handlers
|
||||
specs.extend(handlers.get_posts_specs()) # 13 tools
|
||||
specs.extend(handlers.get_media_specs()) # 5 tools
|
||||
specs.extend(handlers.get_media_specs()) # 6 tools (F.5a.1: added base64 upload)
|
||||
specs.extend(handlers.get_ai_media_specs()) # 1 tool (F.5a.4: generate+upload)
|
||||
specs.extend(handlers.get_media_chunked_specs()) # 5 tools (F.5a.5 + F.5a.8.4 status)
|
||||
specs.extend(handlers.get_media_probe_specs()) # 1 tool (F.5a.6.3: probe limits)
|
||||
specs.extend(handlers.get_capabilities_specs()) # 1 tool (F.18.1: probe capabilities)
|
||||
specs.extend(handlers.get_bulk_meta_specs()) # 1 tool (F.18.2: bulk meta write)
|
||||
specs.extend(handlers.get_export_specs()) # 1 tool (F.18.3: structured export)
|
||||
specs.extend(handlers.get_cache_purge_specs()) # 1 tool (F.18.4: cache purge)
|
||||
specs.extend(handlers.get_transient_flush_specs()) # 1 tool (F.18.5: transient flush)
|
||||
specs.extend(handlers.get_site_health_specs()) # 1 tool (F.18.6: site health)
|
||||
specs.extend(handlers.get_audit_hook_specs()) # 3 tools (F.18.7: audit hook)
|
||||
specs.extend(handlers.get_regenerate_thumbnails_specs()) # 1 tool (F.5a.8.2)
|
||||
specs.extend(handlers.get_media_bulk_specs()) # 2 tools (F.5a.8.3: bulk delete+reassign)
|
||||
specs.extend(handlers.get_taxonomy_specs()) # 11 tools
|
||||
specs.extend(handlers.get_comments_specs()) # 5 tools
|
||||
specs.extend(handlers.get_users_specs()) # 2 tools
|
||||
@@ -128,6 +173,68 @@ class WordPressPlugin(BasePlugin):
|
||||
"""
|
||||
return await self.site.health_check()
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — return the capabilities the saved app_password grants.
|
||||
|
||||
Delegates to the companion-plugin-backed ``CapabilitiesHandler``
|
||||
(F.18.1), which reads ``GET /airano-mcp/v1/capabilities`` — the
|
||||
WordPress user's effective capability map. When the companion
|
||||
isn't installed we return an empty-granted payload marked as
|
||||
unavailable so the caller can show a "companion plugin needed"
|
||||
hint rather than falsely claim the key is under-privileged.
|
||||
|
||||
F.X.fix #3 — fast-fail on unreachable sites. When the low-level
|
||||
client raises :class:`SiteUnreachableError` (DNS/TCP/timeout),
|
||||
we short-circuit to a structured ``probe_available=False``
|
||||
payload carrying the ``install_hint`` so the dashboard renders
|
||||
the "check your URL / install companion" prompt in <10s instead
|
||||
of hanging on the 30s total timeout.
|
||||
"""
|
||||
from plugins.wordpress.client import SiteUnreachableError
|
||||
from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
|
||||
|
||||
try:
|
||||
payload = await self.capabilities._fetch_capabilities()
|
||||
except SiteUnreachableError as exc:
|
||||
out: dict[str, Any] = {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "wordpress_companion",
|
||||
"reason": f"site_unreachable: {exc.reason}",
|
||||
}
|
||||
if exc.install_hint:
|
||||
out["install_hint"] = exc.install_hint
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
payload = _empty_capabilities_payload(
|
||||
self.client.site_url, reason=f"probe_failed: {exc}"
|
||||
)
|
||||
|
||||
if not payload.get("companion_available"):
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "wordpress_companion",
|
||||
"reason": (payload.get("reason") or "companion_not_installed"),
|
||||
}
|
||||
|
||||
user_caps = (payload.get("user") or {}).get("capabilities") or {}
|
||||
granted = sorted(k for k, v in user_caps.items() if v)
|
||||
roles = list((payload.get("user") or {}).get("roles") or [])
|
||||
# F.X.fix-pass3 — surface routes + features so the central
|
||||
# tool-prerequisites resolver in core/tool_access can decide
|
||||
# which tools to auto-disable (SEO needs Rank Math/Yoast,
|
||||
# cache_purge/etc. need the matching companion route, …).
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": granted,
|
||||
"source": "wordpress_companion",
|
||||
"roles": roles,
|
||||
"plugin_version": payload.get("plugin_version"),
|
||||
"routes": dict(payload.get("routes") or {}),
|
||||
"features": dict(payload.get("features") or {}),
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# Method Delegation to Handlers
|
||||
# ========================================
|
||||
@@ -187,12 +294,84 @@ class WordPressPlugin(BasePlugin):
|
||||
async def upload_media_from_url(self, **kwargs):
|
||||
return await self.media.upload_media_from_url(**kwargs)
|
||||
|
||||
async def upload_media_from_base64(self, **kwargs):
|
||||
return await self.media.upload_media_from_base64(**kwargs)
|
||||
|
||||
async def update_media(self, **kwargs):
|
||||
return await self.media.update_media(**kwargs)
|
||||
|
||||
async def delete_media(self, **kwargs):
|
||||
return await self.media.delete_media(**kwargs)
|
||||
|
||||
# === AI Image Generation (F.5a.4) ===
|
||||
async def generate_and_upload_image(self, **kwargs):
|
||||
return await self.ai_media.generate_and_upload_image(**kwargs)
|
||||
|
||||
# === Chunked Media Upload (F.5a.5) ===
|
||||
async def upload_media_chunked_start(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_start(**kwargs)
|
||||
|
||||
async def upload_media_chunked_chunk(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_chunk(**kwargs)
|
||||
|
||||
async def upload_media_chunked_finish(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_finish(**kwargs)
|
||||
|
||||
async def upload_media_chunked_abort(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_abort(**kwargs)
|
||||
|
||||
async def upload_media_chunked_status(self, **kwargs):
|
||||
return await self.media_chunked.upload_media_chunked_status(**kwargs)
|
||||
|
||||
# === Probe Upload Limits (F.5a.6.3) ===
|
||||
async def probe_upload_limits(self, **kwargs):
|
||||
return await self.media_probe.probe_upload_limits(**kwargs)
|
||||
|
||||
# === Probe Companion Capabilities (F.18.1) ===
|
||||
async def probe_capabilities(self, **kwargs):
|
||||
return await self.capabilities.probe_capabilities(**kwargs)
|
||||
|
||||
# === Bulk Meta Write (F.18.2) ===
|
||||
async def bulk_update_meta(self, **kwargs):
|
||||
return await self.bulk_meta.bulk_update_meta(**kwargs)
|
||||
|
||||
# === Structured Export (F.18.3) ===
|
||||
async def export_content(self, **kwargs):
|
||||
return await self.export.export_content(**kwargs)
|
||||
|
||||
# === Cache Purge (F.18.4) ===
|
||||
async def cache_purge(self, **kwargs):
|
||||
return await self.cache_purge_handler.cache_purge(**kwargs)
|
||||
|
||||
# === Regenerate Thumbnails (F.5a.8.2) ===
|
||||
async def regenerate_thumbnails(self, **kwargs):
|
||||
return await self.regenerate_thumbnails_handler.regenerate_thumbnails(**kwargs)
|
||||
|
||||
# === Bulk Media Operations (F.5a.8.3) ===
|
||||
async def bulk_delete_media(self, **kwargs):
|
||||
return await self.media_bulk.bulk_delete_media(**kwargs)
|
||||
|
||||
async def bulk_reassign_media(self, **kwargs):
|
||||
return await self.media_bulk.bulk_reassign_media(**kwargs)
|
||||
|
||||
# === Transient Flush (F.18.5) ===
|
||||
async def transient_flush(self, **kwargs):
|
||||
return await self.transient_flush_handler.transient_flush(**kwargs)
|
||||
|
||||
# === Unified Site Health (F.18.6) ===
|
||||
async def site_health(self, **kwargs):
|
||||
return await self.site_health_handler.site_health(**kwargs)
|
||||
|
||||
# === Audit Hook (F.18.7) ===
|
||||
async def audit_hook_status(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_status(**kwargs)
|
||||
|
||||
async def audit_hook_configure(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_configure(**kwargs)
|
||||
|
||||
async def audit_hook_disable(self, **kwargs):
|
||||
return await self.audit_hook_handler.audit_hook_disable(**kwargs)
|
||||
|
||||
# === Taxonomy (Categories & Tags) ===
|
||||
async def list_categories(self, **kwargs):
|
||||
return await self.taxonomy.list_categories(**kwargs)
|
||||
|
||||
Reference in New Issue
Block a user