feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
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>
This commit is contained in:
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""F.19.7 — Theme dev surface (install + file CRUD).
|
||||
|
||||
Seven tools split across two surfaces. Both ride the same companion
|
||||
plugin (Airano MCP Bridge v2.14.0+) and stay on the existing ``editor``
|
||||
tier introduced by F.19.5 — theme work is the same risk class as page
|
||||
editing, no new tier needed.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Theme management** (3 tools, companion v2.14.0 routes
|
||||
``/admin/themes/*``): ``wp_theme_install_from_zip`` (POST install),
|
||||
``wp_theme_activate``, ``wp_theme_delete``. Install accepts either
|
||||
a remote ``zip_url`` (companion downloads via ``wp_safe_remote_get``)
|
||||
or an inline ``zip_base64`` (cap 50 MB, decoded server-side). All three
|
||||
ride WP core's ``Theme_Upgrader`` so signature checks and the existing
|
||||
filesystem abstraction stay engaged.
|
||||
* **Theme file CRUD** (4 tools, ``/admin/themes/files/*``):
|
||||
``wp_theme_file_list`` (glob walk), ``wp_theme_file_read``,
|
||||
``wp_theme_file_write``, ``wp_theme_file_delete``. Reads/writes go
|
||||
through ``WP_Filesystem_Direct`` server-side; payloads round-trip as
|
||||
base64 so the JSON envelope stays binary-safe (favicons, fonts, MO
|
||||
files, etc.).
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 + F.19.5 S-12…S-14
|
||||
(companion enforces these regardless of MCPHub-side guards):
|
||||
|
||||
* **S-15** — ``theme_slug`` must match a key in ``wp_get_themes()``.
|
||||
Companion rejects anything else with ``theme_not_found`` (404).
|
||||
MCPHub-side: a structural slug guard (alphanumerics, dashes,
|
||||
underscores; no slashes / dots / null bytes / leading dash) so
|
||||
malformed slugs don't reach the wire.
|
||||
* **S-16** — Path canonicalisation. Every file route resolves
|
||||
``wp-content/themes/{slug}/{path}`` via ``realpath()`` and rejects
|
||||
results that escape the slug directory. Blocks ``..``, symlinks
|
||||
pointing outside, absolute paths, null bytes. MCPHub-side does a
|
||||
best-effort structural pre-check (the companion's realpath is the
|
||||
binding gate).
|
||||
* **S-17** — Writing PHP files requires ``current_user_can('edit_themes')``
|
||||
AND ``!defined('DISALLOW_FILE_EDIT') || !DISALLOW_FILE_EDIT``. Non-PHP
|
||||
files (CSS, JSON, MO/PO, JS, images, fonts) skip the
|
||||
``DISALLOW_FILE_EDIT`` check but still require ``edit_themes``.
|
||||
* **S-18** — Per-call caps: 5 MB per file, 1000 files per list, 50 MB
|
||||
per theme install zip. Companion enforces in PHP; MCPHub rejects
|
||||
obviously-oversized payloads before the wire.
|
||||
* **S-19** — Optimistic concurrency. When ``expected_sha256`` is
|
||||
provided on write, the companion compares against the current file
|
||||
sha256 and returns ``sha_mismatch`` (409) if it doesn't match. Lets
|
||||
agents reason about conflicting edits without locking.
|
||||
|
||||
All tools require Airano MCP Bridge v2.14.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Mirrored from the companion's THEME_FILE_MAX_BYTES /
|
||||
# THEME_LIST_MAX_FILES / THEME_ZIP_MAX_BYTES so MCPHub can reject
|
||||
# obviously-oversized payloads before they reach the wire (S-18). The
|
||||
# companion enforces the real limit.
|
||||
_THEME_FILE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
|
||||
_THEME_LIST_MAX_FILES = 1000 # files per list call
|
||||
_THEME_ZIP_MAX_BYTES = 50 * 1024 * 1024 # 50 MB per install zip
|
||||
|
||||
# A theme slug from ``wp_get_themes()`` is the directory name under
|
||||
# ``wp-content/themes`` — WP itself permits letters, digits, hyphens,
|
||||
# underscores. We add a hard structural guard here as defence-in-depth
|
||||
# alongside S-15 (the companion's ``wp_get_themes()`` whitelist is the
|
||||
# binding check).
|
||||
_THEME_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.7 theme dev surface."""
|
||||
return [
|
||||
# ───── Theme management ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_install_from_zip",
|
||||
"method_name": "wp_theme_install_from_zip",
|
||||
"description": (
|
||||
"Install a theme from a remote URL or inline base64 zip. "
|
||||
"Companion runs WP core's Theme_Upgrader so signature checks "
|
||||
"and the WP filesystem abstraction stay engaged. Pass exactly "
|
||||
"one of zip_url (companion fetches via wp_safe_remote_get) or "
|
||||
"zip_base64 (decoded server-side). Capped at 50 MB per zip "
|
||||
"(S-18). Set activate=true to make the new theme active "
|
||||
"after install; overwrite=true permits re-installing a slug "
|
||||
"that already exists. Requires Airano MCP Bridge v2.14.0+ "
|
||||
"and a WordPress user with install_themes."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"zip_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"https URL the companion will download via "
|
||||
"wp_safe_remote_get. Mutually exclusive with "
|
||||
"zip_base64."
|
||||
),
|
||||
},
|
||||
"zip_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded theme zip body. Capped at "
|
||||
"~50 MB after decode (S-18). Mutually exclusive "
|
||||
"with zip_url."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Activate the installed theme on success. "
|
||||
"Activation requires switch_themes — companion "
|
||||
"rejects with rest_forbidden if missing."
|
||||
),
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Permit overwriting an existing theme with the "
|
||||
"same slug. Required if a previous install is "
|
||||
"already on disk."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_activate",
|
||||
"method_name": "wp_theme_activate",
|
||||
"description": (
|
||||
"Switch the active theme to ``slug``. Companion verifies the "
|
||||
"slug exists in wp_get_themes() (S-15) and the caller holds "
|
||||
"switch_themes. Returns the active stylesheet + template "
|
||||
"after the switch — useful when activating a child theme "
|
||||
"(stylesheet differs from template). Requires Airano MCP "
|
||||
"Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme directory name (key in wp_get_themes()). "
|
||||
"Alphanumerics, dashes, underscores only."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_delete",
|
||||
"method_name": "wp_theme_delete",
|
||||
"description": (
|
||||
"Delete an installed theme by slug. Companion refuses to "
|
||||
"delete the active theme (returns ``theme_active``) and the "
|
||||
"current default theme. Caller must hold delete_themes. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name to delete.",
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Theme file CRUD ───────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_file_list",
|
||||
"method_name": "wp_theme_file_list",
|
||||
"description": (
|
||||
"List files inside a theme directory. Walks "
|
||||
"``wp-content/themes/{theme_slug}`` and returns each file's "
|
||||
"relative path, size, mime, sha256, and modified_at (epoch). "
|
||||
"Optional ``glob`` filters by fnmatch pattern (default "
|
||||
"``**/*``). Capped at 1000 files per call (S-18); when the "
|
||||
"walk truncates, the response carries ``truncated: true``. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name (S-15).",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"fnmatch glob (e.g. ``**/*.php``). Defaults to "
|
||||
"``**/*`` — every file."
|
||||
),
|
||||
"default": "**/*",
|
||||
},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _THEME_LIST_MAX_FILES,
|
||||
"default": _THEME_LIST_MAX_FILES,
|
||||
"description": (
|
||||
"Hard cap on entries returned. Companion stops "
|
||||
"the walk and sets truncated=true on overflow."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_read",
|
||||
"method_name": "wp_theme_file_read",
|
||||
"description": (
|
||||
"Read a file inside a theme as base64. Returns "
|
||||
"``{content_base64, mime, size, sha256, modified_at}``. Path "
|
||||
"must resolve under ``wp-content/themes/{theme_slug}`` "
|
||||
"(S-16); ``..``, absolute paths, null bytes, and symlinks "
|
||||
"that escape are rejected by the companion's realpath gate. "
|
||||
"Files larger than 5 MB return ``file_too_large`` (S-18). "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme-relative path. ``style.css``, "
|
||||
"``parts/header.html``, ``functions.php``, etc."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_write",
|
||||
"method_name": "wp_theme_file_write",
|
||||
"description": (
|
||||
"Write a file inside a theme. ``content_base64`` is the "
|
||||
"decoded body (capped at 5 MB, S-18). PHP file writes "
|
||||
"additionally require ``edit_themes`` AND "
|
||||
"``!DISALLOW_FILE_EDIT`` (S-17); non-PHP writes only need "
|
||||
"``edit_themes``. Pass ``expected_sha256`` for optimistic "
|
||||
"concurrency: the companion compares against the current "
|
||||
"file's sha256 and returns ``sha_mismatch`` (409) on drift "
|
||||
"(S-19). When ``create_dirs`` is true (default) any missing "
|
||||
"parent directories are created. Requires Airano MCP Bridge "
|
||||
"v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"content_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded file body. Decoded server-side; "
|
||||
"5 MB hard cap (S-18)."
|
||||
),
|
||||
},
|
||||
"expected_sha256": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional sha256 of the on-disk file the caller "
|
||||
"based their edit on. When supplied, companion "
|
||||
"rejects with sha_mismatch on drift (S-19). "
|
||||
"Omit to perform an unconditional write."
|
||||
),
|
||||
},
|
||||
"create_dirs": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": (
|
||||
"Create any missing parent directories. Set to "
|
||||
"false to require the directory already exists."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path", "content_base64"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_delete",
|
||||
"method_name": "wp_theme_file_delete",
|
||||
"description": (
|
||||
"Delete a file inside a theme. Path resolution is identical "
|
||||
"to wp_theme_file_read (S-16). Refuses to delete "
|
||||
"``style.css`` of the active theme — that would break the "
|
||||
"front-end. Requires Airano MCP Bridge v2.14.0+ and "
|
||||
"``edit_themes``."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
#
|
||||
# These are structural guards so the obvious bad calls don't reach the
|
||||
# wire. The binding security check for both rules is server-side: the
|
||||
# companion canonicalises paths via ``realpath()`` (S-16) and intersects
|
||||
# the slug list with ``wp_get_themes()`` (S-15).
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_theme_slug(slug: Any) -> str:
|
||||
"""S-15 client-side guard.
|
||||
|
||||
Reject obviously-malformed slugs before the wire. The companion
|
||||
still does the real ``wp_get_themes()`` membership check.
|
||||
"""
|
||||
if not isinstance(slug, str) or not slug:
|
||||
raise ValueError("theme_slug must be a non-empty string")
|
||||
if not _THEME_SLUG_RE.match(slug):
|
||||
raise ValueError(
|
||||
f"theme_slug must be alphanumerics + dashes + underscores "
|
||||
f"(<=64 chars, no leading dash); got {slug!r}"
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def _validate_theme_file_path(path: Any) -> str:
|
||||
"""S-16 client-side guard.
|
||||
|
||||
Reject the obvious traversal shapes — ``..`` segments, leading
|
||||
slashes, null bytes, backslashes (Windows-style escapes). The
|
||||
companion's ``realpath()`` is the binding gate.
|
||||
"""
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ValueError("path must be a non-empty string")
|
||||
if "\x00" in path:
|
||||
raise ValueError("path must not contain null bytes")
|
||||
if "\\" in path:
|
||||
raise ValueError("path must use forward slashes only")
|
||||
if path.startswith("/"):
|
||||
raise ValueError("path must be theme-relative (no leading slash)")
|
||||
# Reject any segment equal to ``..`` — accepts ``..foo`` (a real
|
||||
# filename) but blocks the traversal idiom.
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if any(p == ".." for p in parts):
|
||||
raise ValueError("path must not contain `..` segments")
|
||||
if not parts:
|
||||
raise ValueError("path must reference a file, not the theme root")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _quote_path(path: str) -> str:
|
||||
"""Percent-encode a theme-relative path while keeping ``/`` literal."""
|
||||
return quote(path, safe="/")
|
||||
|
||||
|
||||
class ThemesHandler:
|
||||
"""Theme management + theme file CRUD surface (F.19.7).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion. The
|
||||
plugin.py wrapper layer is responsible for serialising the dict for
|
||||
MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Theme management ────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_install_from_zip(
|
||||
self,
|
||||
zip_url: str | None = None,
|
||||
zip_base64: str | None = None,
|
||||
activate: bool = False,
|
||||
overwrite: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
# Exactly one source must be supplied — both the prompt and the
|
||||
# companion route reject the empty + double-supply cases.
|
||||
if not zip_url and not zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip requires zip_url or zip_base64")
|
||||
if zip_url and zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip accepts zip_url OR zip_base64, not both")
|
||||
body: dict[str, Any] = {
|
||||
"activate": bool(activate),
|
||||
"overwrite": bool(overwrite),
|
||||
}
|
||||
if zip_url:
|
||||
if not isinstance(zip_url, str):
|
||||
raise ValueError("zip_url must be a string")
|
||||
body["zip_url"] = zip_url
|
||||
else:
|
||||
if not isinstance(zip_base64, str):
|
||||
raise ValueError("zip_base64 must be a string")
|
||||
# Cheap pre-cap: base64 expands by ~4/3, so ``len * 3 // 4``
|
||||
# is an upper bound on the decoded byte count. This catches
|
||||
# the obviously-too-big payloads without doing a full
|
||||
# decode (which would double the memory usage).
|
||||
decoded_size_upper_bound = len(zip_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_ZIP_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"zip_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_ZIP_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body["zip_base64"] = zip_base64
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/install",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_activate(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/{slug}/activate",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_delete(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/{slug}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Theme file CRUD ─────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_file_list(
|
||||
self,
|
||||
theme_slug: str,
|
||||
glob: str = "**/*",
|
||||
max_files: int = _THEME_LIST_MAX_FILES,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
if not isinstance(glob, str) or not glob:
|
||||
raise ValueError("glob must be a non-empty string")
|
||||
if not isinstance(max_files, int) or isinstance(max_files, bool) or max_files <= 0:
|
||||
raise ValueError("max_files must be a positive integer")
|
||||
if max_files > _THEME_LIST_MAX_FILES:
|
||||
raise ValueError(
|
||||
f"max_files {max_files} exceeds the {_THEME_LIST_MAX_FILES} per-call cap (S-18)"
|
||||
)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}",
|
||||
params={"glob": glob, "max_files": max_files},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_read(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_write(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
content_base64: str,
|
||||
expected_sha256: str | None = None,
|
||||
create_dirs: bool = True,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
if not isinstance(content_base64, str):
|
||||
raise ValueError("content_base64 must be a string")
|
||||
decoded_size_upper_bound = len(content_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_FILE_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"content_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_FILE_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"content_base64": content_base64,
|
||||
"create_dirs": bool(create_dirs),
|
||||
}
|
||||
if expected_sha256 is not None:
|
||||
if not isinstance(expected_sha256, str) or not re.fullmatch(
|
||||
r"[0-9a-fA-F]{64}", expected_sha256
|
||||
):
|
||||
raise ValueError("expected_sha256 must be a 64-char hex string")
|
||||
body["expected_sha256"] = expected_sha256.lower()
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_delete(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
Reference in New Issue
Block a user