Files
mcphub/plugins/wordpress/handlers/media.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
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>
2026-04-25 16:25:58 +02:00

710 lines
27 KiB
Python

"""Media Handler - manages WordPress media library operations"""
import base64 as _b64
import binascii
import json
from typing import Any
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]]:
"""Return tool specifications for ToolGenerator"""
return [
# === MEDIA ===
{
"name": "list_media",
"method_name": "list_media",
"description": "List media library items. Returns images, videos, documents with URLs and metadata.",
"schema": {
"type": "object",
"properties": {
"per_page": {
"type": "integer",
"description": "Number of media items per page",
"default": 20,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number for pagination",
"default": 1,
"minimum": 1,
},
"media_type": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Filter by media type (image, video, audio, or application)",
"enum": ["image", "video", "audio", "application"],
},
},
},
"scope": "read",
},
{
"name": "get_media",
"method_name": "get_media",
"description": "Get detailed information about a media item. Returns full metadata including URLs, dimensions, and MIME type.",
"schema": {
"type": "object",
"properties": {
"media_id": {
"type": "integer",
"description": "Media ID to retrieve",
"minimum": 1,
}
},
"required": ["media_id"],
},
"scope": "read",
},
{
"name": "upload_media_from_url",
"method_name": "upload_media_from_url",
"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 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"}],
"description": "Media title (used in media library)",
},
"alt_text": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Alternative text for accessibility",
},
"caption": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"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",
"description": "Update media metadata. Supports title, description, slug, alt text, caption, status, and associated post.",
"schema": {
"type": "object",
"properties": {
"media_id": {
"type": "integer",
"description": "Media ID to update",
"minimum": 1,
},
"title": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Media title (displayed in media library)",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Media description (full text content, displayed in attachment page)",
},
"slug": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Media URL slug (e.g., 'my-image-name')",
},
"alt_text": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Alternative text for accessibility (important for images)",
},
"caption": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Media caption (shown below image in content)",
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Publication status of the media",
"enum": ["publish", "draft", "private"],
},
"post": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "ID of the post/page to attach this media to",
},
},
"required": ["media_id"],
},
"scope": "write",
},
{
"name": "delete_media",
"method_name": "delete_media",
"description": "Delete media from library. Can permanently delete or move to trash.",
"schema": {
"type": "object",
"properties": {
"media_id": {
"type": "integer",
"description": "Media ID to delete",
"minimum": 1,
},
"force": {
"type": "boolean",
"description": "Permanently delete (true) or move to trash (false)",
"default": False,
},
},
"required": ["media_id"],
},
"scope": "write",
},
]
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, *, 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 ===
async def list_media(
self, per_page: int = 20, page: int = 1, media_type: str | None = None
) -> str:
"""
List media library items.
Args:
per_page: Number of media items per page (1-100)
page: Page number
media_type: Filter by media type (image, video, audio, application)
Returns:
JSON string with media list
"""
try:
params = {"per_page": per_page, "page": page}
if media_type:
params["media_type"] = media_type
media = await self.client.get("media", params=params)
# Format response
result = {
"total": len(media),
"page": page,
"per_page": per_page,
"media": [
{
"id": m["id"],
"title": m["title"]["rendered"],
"mime_type": m["mime_type"],
"media_type": m.get("media_type", ""),
"url": m["source_url"],
"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
],
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list media: {str(e)}"}, indent=2
)
async def get_media(self, media_id: int) -> str:
"""
Get detailed information about a specific media item.
Args:
media_id: Media ID to retrieve
Returns:
JSON string with media data
"""
try:
media = await self.client.get(f"media/{media_id}")
result = {
"id": media["id"],
"title": media["title"]["rendered"],
"mime_type": media["mime_type"],
"media_type": media.get("media_type", ""),
"url": media["source_url"],
"alt_text": media.get("alt_text", ""),
"caption": media.get("caption", {}).get("rendered", ""),
"description": media.get("description", {}).get("rendered", ""),
"date": media["date"],
"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)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to get media {media_id}: {str(e)}"}, indent=2
)
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 a public URL to the WordPress media library."""
try:
ssrf = ssrf_check(url)
if not ssrf.allowed:
raise UploadError(
"SSRF", ssrf.reason or "URL rejected by SSRF guard.", {"url": url}
)
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
)
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,
)
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_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(
self,
media_id: int,
title: str | None = None,
description: str | None = None,
slug: str | None = None,
alt_text: str | None = None,
caption: str | None = None,
status: str | None = None,
post: int | None = None,
) -> str:
"""
Update media metadata.
Args:
media_id: Media ID to update
title: Media title
description: Media description
slug: Media URL slug
alt_text: Alternative text for accessibility
caption: Media caption
status: Publication status (publish, draft, private)
post: ID of post/page to attach media to
Returns:
JSON string with updated media data
"""
try:
# Build data dict with only provided values
data = {}
if title is not None:
data["title"] = title
if description is not None:
data["description"] = description
if slug is not None:
data["slug"] = slug
if alt_text is not None:
data["alt_text"] = alt_text
if caption is not None:
data["caption"] = caption
if status is not None:
data["status"] = status
if post is not None:
data["post"] = post
media = await self.client.post(f"media/{media_id}", json_data=data)
result = {
"id": media["id"],
"title": media["title"]["rendered"],
"alt_text": media.get("alt_text", ""),
"caption": media.get("caption", {}).get("rendered", ""),
"url": media["source_url"],
"message": f"Media {media_id} updated successfully",
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to update media {media_id}: {str(e)}"},
indent=2,
)
async def delete_media(self, media_id: int, force: bool = False) -> str:
"""
Delete or trash media from library.
Args:
media_id: Media ID to delete
force: Permanently delete (True) or move to trash (False)
Returns:
JSON string with deletion result
"""
try:
params = {"force": "true" if force else "false"}
result = await self.client.delete(f"media/{media_id}", params=params)
message = f"Media {media_id} {'permanently deleted' if force else 'moved to trash'}"
return json.dumps({"success": True, "message": message, "result": result}, indent=2)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to delete media {media_id}: {str(e)}"},
indent=2,
)