Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
780 lines
30 KiB
Python
780 lines
30 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:
|
|
# Issue #90 — set_featured used to hit /wp/v2/posts/{id}
|
|
# only, which 404s for pages and other CPTs. Now: try
|
|
# `posts` first (the common case), and on miss, walk the
|
|
# thumbnail-supporting rest_bases (pages, then any other
|
|
# CPT that opted in via add_theme_support('post-thumbnails')
|
|
# / declares ``thumbnail`` in its supports list).
|
|
ctx = await _set_featured_with_fallback(client, attach_to_post, media["id"])
|
|
if ctx is not None:
|
|
status["featured_set"] = True
|
|
status["featured_context"] = ctx
|
|
else:
|
|
status["warnings"].append(
|
|
f"featured_set_failed: post {attach_to_post} not found "
|
|
"in any thumbnail-supporting post type"
|
|
)
|
|
|
|
return status
|
|
|
|
|
|
async def _set_featured_with_fallback(
|
|
client: WordPressClient,
|
|
target_id: int,
|
|
media_id: int,
|
|
) -> str | None:
|
|
"""Set featured image on ``target_id`` across post / page / CPT.
|
|
|
|
Tries ``posts`` first (the 99% case — and the path covered by
|
|
``wp_set_featured_media`` to keep existing test seams stable),
|
|
then falls back to ``pages`` and any other rest_base that declares
|
|
``thumbnail`` support via ``/wp/v2/types``. Returns the rest_base on
|
|
success, ``None`` when nothing accepted the write.
|
|
"""
|
|
try:
|
|
await wp_set_featured_media(client, target_id, media_id)
|
|
return "post"
|
|
except Exception:
|
|
pass
|
|
|
|
body = {"featured_media": media_id}
|
|
|
|
try:
|
|
await client.post(f"pages/{target_id}", json_data=body)
|
|
return "page"
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
types_data = await client.get("types")
|
|
except Exception:
|
|
return None
|
|
if not isinstance(types_data, dict):
|
|
return None
|
|
|
|
for slug, data in types_data.items():
|
|
if not isinstance(data, dict):
|
|
continue
|
|
rest_base = data.get("rest_base") or slug
|
|
if rest_base in ("posts", "pages", "media", "attachment"):
|
|
continue
|
|
supports = data.get("supports") or {}
|
|
# `supports` is sometimes a list (older WP / some plugins) and
|
|
# sometimes a dict keyed by feature. Treat both as truthy when
|
|
# ``thumbnail`` is present.
|
|
if isinstance(supports, dict):
|
|
if not supports.get("thumbnail"):
|
|
continue
|
|
elif isinstance(supports, list):
|
|
if "thumbnail" not in supports:
|
|
continue
|
|
else:
|
|
continue
|
|
try:
|
|
await client.post(f"{rest_base}/{target_id}", json_data=body)
|
|
return rest_base
|
|
except Exception:
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
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,
|
|
)
|