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:
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""F.19.6.A — Site config surface (identity + reading + permalinks).
|
||||
|
||||
First consumer of the ``settings`` tier introduced by F.19.2.0.
|
||||
Six tools split across three small surfaces — every one of them is
|
||||
reachable from the WP-Admin Settings menu, no companion magic, just
|
||||
a typed REST face for what an editor would otherwise click through.
|
||||
|
||||
Surface map (all on the ``settings`` tier):
|
||||
|
||||
* **Site identity** (2 tools, ``/admin/site/identity``):
|
||||
``wp_site_identity_get`` reads title / tagline / site_icon /
|
||||
custom_logo / blog_charset / WP version. ``wp_site_identity_set``
|
||||
writes title / tagline / site_icon_id / custom_logo_id.
|
||||
* **Reading** (2 tools, ``/admin/site/reading``):
|
||||
``wp_reading_settings_get`` / ``wp_reading_settings_set`` cover
|
||||
show_on_front (posts vs page), page_on_front, page_for_posts,
|
||||
posts_per_page, blog_public (search-engine visibility). The
|
||||
``blog_public=false`` write surfaces a hint reminding the caller
|
||||
the change asks search engines not to index but is non-binding.
|
||||
* **Permalinks** (2 tools, ``/admin/permalinks``):
|
||||
``wp_permalinks_get`` / ``wp_permalinks_set``. After a write the
|
||||
companion calls ``flush_rewrite_rules()`` so the new structure
|
||||
takes effect immediately — same as the manual save on
|
||||
Settings → Permalinks.
|
||||
|
||||
Security rules: route-level ``manage_options`` only. No new S-rules
|
||||
this round — every operation maps to a stock WP option write that
|
||||
WP-Admin would do in a single click. WP's own ``sanitize_option_*``
|
||||
hooks fire on each ``update_option`` and provide the safe-input gate.
|
||||
|
||||
All tools require Airano MCP Bridge v2.16.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5 /
|
||||
# F.19.7 / F.19.2.1.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Permalink structure tokens WP recognises. Anything else is rejected
|
||||
# client-side as a cheap pre-check (the companion is the binding gate
|
||||
# — it round-trips through WP's own ``permalink_structure`` sanitiser).
|
||||
_PERMALINK_TOKEN_RE = re.compile(r"^(/|%[a-z_]+%|[A-Za-z0-9_\-])+$")
|
||||
_PERMALINK_MAX_LEN = 256
|
||||
|
||||
# Reading-settings ``show_on_front`` accepts only these two strings —
|
||||
# matches WP's own constant enum.
|
||||
_SHOW_ON_FRONT_VALUES = {"posts", "page"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.A site config surface."""
|
||||
return [
|
||||
# ───── Site identity (2) ─────────────────────────────────────
|
||||
{
|
||||
"name": "wp_site_identity_get",
|
||||
"method_name": "wp_site_identity_get",
|
||||
"description": (
|
||||
"Read site identity: title (blogname), tagline "
|
||||
"(blogdescription), site_icon (favicon attachment id), "
|
||||
"custom_logo (theme logo attachment id), blog_charset, "
|
||||
"WP version, and admin email. Companion-backed read so "
|
||||
"responses stay consistent with the writer route. "
|
||||
"Requires Airano MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_site_identity_set",
|
||||
"method_name": "wp_site_identity_set",
|
||||
"description": (
|
||||
"Update site identity. Pass any subset of title / "
|
||||
"tagline / site_icon_id / custom_logo_id (omitted keys "
|
||||
"are left untouched). Attachment ids are validated by "
|
||||
"the companion against WP's media library — invalid "
|
||||
"ids return ``invalid_attachment``. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Site title (option ``blogname``).",
|
||||
"maxLength": 255,
|
||||
},
|
||||
"tagline": {
|
||||
"type": "string",
|
||||
"description": "Site tagline (option ``blogdescription``).",
|
||||
"maxLength": 1024,
|
||||
},
|
||||
"site_icon_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the site icon (favicon). "
|
||||
"Pass 0 to clear; pass an integer >= 1 to set."
|
||||
),
|
||||
},
|
||||
"custom_logo_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the theme custom logo. "
|
||||
"Pass 0 to clear; the active theme must "
|
||||
"declare ``custom-logo`` support."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Reading settings (2) ──────────────────────────────────
|
||||
{
|
||||
"name": "wp_reading_settings_get",
|
||||
"method_name": "wp_reading_settings_get",
|
||||
"description": (
|
||||
"Read the Settings → Reading panel: show_on_front "
|
||||
"(``posts`` or ``page``), page_on_front, "
|
||||
"page_for_posts, posts_per_page, posts_per_rss, and "
|
||||
"blog_public (search-engine visibility flag — 0 means "
|
||||
"WP asks crawlers not to index). Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_reading_settings_set",
|
||||
"method_name": "wp_reading_settings_set",
|
||||
"description": (
|
||||
"Update Settings → Reading values. Pass any subset of "
|
||||
"show_on_front / page_on_front / page_for_posts / "
|
||||
"posts_per_page / posts_per_rss / blog_public. When "
|
||||
"show_on_front=='page', page_on_front must be set to a "
|
||||
"published Page id (companion validates). Setting "
|
||||
"blog_public=false tells crawlers not to index — the "
|
||||
"directive is non-binding, real privacy still belongs "
|
||||
"to httpd auth or membership plugins. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_on_front": {
|
||||
"type": "string",
|
||||
"enum": ["posts", "page"],
|
||||
"description": (
|
||||
"``posts`` shows the latest blog posts on "
|
||||
"the home URL; ``page`` uses two static "
|
||||
"pages (front + posts archive)."
|
||||
),
|
||||
},
|
||||
"page_on_front": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the static front page "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"page_for_posts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the blog posts archive "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"posts_per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Posts shown per page on archives + the home feed.",
|
||||
},
|
||||
"posts_per_rss": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Items emitted in RSS / Atom feeds.",
|
||||
},
|
||||
"blog_public": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"true = invite search engines to index; "
|
||||
"false = ask crawlers not to. Non-binding."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Permalinks (2) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_permalinks_get",
|
||||
"method_name": "wp_permalinks_get",
|
||||
"description": (
|
||||
"Read the current permalink structure (option "
|
||||
"``permalink_structure``) plus the category_base + "
|
||||
"tag_base prefixes. Empty string in ``structure`` "
|
||||
'means "plain" permalinks (?p=N). Requires Airano '
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_permalinks_set",
|
||||
"method_name": "wp_permalinks_set",
|
||||
"description": (
|
||||
"Update the permalink structure. Common safe values: "
|
||||
"``/%postname%/``, ``/%year%/%monthnum%/%postname%/``, "
|
||||
"``/%category%/%postname%/``. Pass an empty string for "
|
||||
"plain permalinks. The companion writes the option "
|
||||
"then calls ``flush_rewrite_rules()`` so the new "
|
||||
"structure takes effect immediately. Optional "
|
||||
"category_base / tag_base override the default "
|
||||
"``category`` / ``tag`` prefixes. Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"structure": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Permalink template using WP tokens "
|
||||
"(``%postname%``, ``%post_id%``, ``%year%``, "
|
||||
"``%monthnum%``, ``%day%``, ``%hour%``, "
|
||||
"``%minute%``, ``%second%``, ``%category%``, "
|
||||
"``%author%``). Empty string = plain."
|
||||
),
|
||||
"maxLength": _PERMALINK_MAX_LEN,
|
||||
},
|
||||
"category_base": {
|
||||
"type": "string",
|
||||
"description": "Category archive prefix (default ``category``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
"tag_base": {
|
||||
"type": "string",
|
||||
"description": "Tag archive prefix (default ``tag``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
},
|
||||
"required": ["structure"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_permalink_structure(structure: Any) -> str:
|
||||
"""Cheap structural pre-check for permalink_structure.
|
||||
|
||||
Empty string ("plain" permalinks) is valid. Otherwise we accept
|
||||
only `/`, `%token%`, alphanumerics, `_`, and `-`. The companion's
|
||||
``sanitize_option_permalink_structure`` is the binding sanitiser.
|
||||
"""
|
||||
if not isinstance(structure, str):
|
||||
raise ValueError("structure must be a string")
|
||||
if structure == "":
|
||||
return ""
|
||||
if len(structure) > _PERMALINK_MAX_LEN:
|
||||
raise ValueError(f"structure exceeds {_PERMALINK_MAX_LEN} char cap")
|
||||
if "\x00" in structure:
|
||||
raise ValueError("structure must not contain null bytes")
|
||||
if not _PERMALINK_TOKEN_RE.match(structure):
|
||||
raise ValueError(
|
||||
"structure may only contain `/`, `%token%`, alnum, `_`, `-` "
|
||||
"(got unexpected character)"
|
||||
)
|
||||
return structure
|
||||
|
||||
|
||||
def _validate_attachment_id(value: Any, field: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
class SiteConfigHandler:
|
||||
"""Site identity + reading + permalinks surface (F.19.6.A).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper is responsible for serialising for MCP
|
||||
transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Identity ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_site_identity_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_site_identity_set(
|
||||
self,
|
||||
title: str | None = None,
|
||||
tagline: str | None = None,
|
||||
site_icon_id: int | None = None,
|
||||
custom_logo_id: int | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError("title must be a string")
|
||||
body["title"] = title
|
||||
if tagline is not None:
|
||||
if not isinstance(tagline, str):
|
||||
raise ValueError("tagline must be a string")
|
||||
body["tagline"] = tagline
|
||||
if site_icon_id is not None:
|
||||
body["site_icon_id"] = _validate_attachment_id(site_icon_id, "site_icon_id")
|
||||
if custom_logo_id is not None:
|
||||
body["custom_logo_id"] = _validate_attachment_id(custom_logo_id, "custom_logo_id")
|
||||
if not body:
|
||||
raise ValueError("wp_site_identity_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Reading ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_reading_settings_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_reading_settings_set(
|
||||
self,
|
||||
show_on_front: str | None = None,
|
||||
page_on_front: int | None = None,
|
||||
page_for_posts: int | None = None,
|
||||
posts_per_page: int | None = None,
|
||||
posts_per_rss: int | None = None,
|
||||
blog_public: bool | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if show_on_front is not None:
|
||||
if show_on_front not in _SHOW_ON_FRONT_VALUES:
|
||||
raise ValueError(
|
||||
f"show_on_front must be one of {sorted(_SHOW_ON_FRONT_VALUES)} "
|
||||
f"(got {show_on_front!r})"
|
||||
)
|
||||
body["show_on_front"] = show_on_front
|
||||
if page_on_front is not None:
|
||||
body["page_on_front"] = _validate_attachment_id(page_on_front, "page_on_front")
|
||||
if page_for_posts is not None:
|
||||
body["page_for_posts"] = _validate_attachment_id(page_for_posts, "page_for_posts")
|
||||
if posts_per_page is not None:
|
||||
if not isinstance(posts_per_page, int) or isinstance(posts_per_page, bool):
|
||||
raise ValueError("posts_per_page must be an integer")
|
||||
if posts_per_page < 1 or posts_per_page > 100:
|
||||
raise ValueError("posts_per_page must be between 1 and 100")
|
||||
body["posts_per_page"] = posts_per_page
|
||||
if posts_per_rss is not None:
|
||||
if not isinstance(posts_per_rss, int) or isinstance(posts_per_rss, bool):
|
||||
raise ValueError("posts_per_rss must be an integer")
|
||||
if posts_per_rss < 1 or posts_per_rss > 100:
|
||||
raise ValueError("posts_per_rss must be between 1 and 100")
|
||||
body["posts_per_rss"] = posts_per_rss
|
||||
if blog_public is not None:
|
||||
if not isinstance(blog_public, bool):
|
||||
raise ValueError("blog_public must be a boolean")
|
||||
body["blog_public"] = blog_public
|
||||
if not body:
|
||||
raise ValueError("wp_reading_settings_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Permalinks ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_permalinks_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_permalinks_set(
|
||||
self,
|
||||
structure: str,
|
||||
category_base: str | None = None,
|
||||
tag_base: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"structure": _validate_permalink_structure(structure)}
|
||||
if category_base is not None:
|
||||
if not isinstance(category_base, str) or len(category_base) > 64:
|
||||
raise ValueError("category_base must be a string up to 64 chars")
|
||||
body["category_base"] = category_base
|
||||
if tag_base is not None:
|
||||
if not isinstance(tag_base, str) or len(tag_base) > 64:
|
||||
raise ValueError("tag_base must be a string up to 64 chars")
|
||||
body["tag_base"] = tag_base
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
Reference in New Issue
Block a user