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:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -0,0 +1,249 @@
"""F.19.3.2-.3 — Database inspection surface (db/size + db/tables + db/search).
Closes the database-introspection gap on ``wordpress_specialist`` so the
deprecated ``wordpress_advanced`` (Docker-socket / WP-CLI) plugin can
sunset cleanly. All three tools are read-only (scope=``read``) and
companion-backed via Airano MCP Bridge v2.18.0+.
Surface map:
* **wp_db_size** (``GET /admin/db/size``) — single
``information_schema.TABLES`` aggregation. Returns
``{database_bytes, table_count, row_count_estimate, database_name,
table_prefix}``. No SQL exposure: caller doesn't pick the query.
* **wp_db_tables** (``GET /admin/db/tables``) — per-table breakdown
from the same source. One row per WP table with name / engine /
rows / data_bytes / index_bytes / total_bytes / collation.
* **wp_db_search** (``POST /admin/db/search``) — search wrapper
around ``WP_Query`` with ``s=$query``. NEVER raw SQL.
Security rules (extending S-1…S-24):
* **S-25** — ``wp_db_search`` uses ``WP_Query`` with ``s=``, not raw
SQL. ``query`` is sanitised via ``sanitize_text_field`` server-side
and length-capped at 200 chars client-side. Refusal to return non-
readable posts (private/draft from other authors) is enforced by
``WP_Query``'s own ``posts_clauses`` filter — the same gate the WP
search page uses.
All routes gated on ``manage_options`` at the companion level. Pulling
``information_schema.TABLES`` requires the DB user to have access to it
— the standard WordPress install has this for the same MySQL user that
runs WP itself, so this is safe in practice.
"""
from __future__ import annotations
from typing import Any
from plugins.wordpress.client import WordPressClient
# Companion admin namespace — same prefix used by every F.19.* surface.
_ADMIN_NS = "airano-mcp/v1/admin"
# S-25 client-side cap. Server enforces the same limit; this is purely
# a fast-fail before the round-trip.
_QUERY_MAX_LEN = 200
# Limit cap on db/search. Mirrors the server-side ceiling.
_SEARCH_LIMIT_MAX = 100
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specs for the F.19.3.2-.3 database surface."""
return [
{
"name": "wp_db_size",
"method_name": "wp_db_size",
"description": (
"Read aggregate database size for the WordPress install. "
"Returns ``{database_bytes, table_count, "
"row_count_estimate, database_name, table_prefix}``. "
"Source is a single ``information_schema.TABLES`` "
"aggregation scoped to the WP table prefix — no SQL is "
"exposed to the caller (S-25). InnoDB row counts are "
"estimates, not exact, mirroring MySQL's own caveat. "
"Use to answer 'how big is this site?' before deciding "
"whether to migrate / archive. Requires Airano MCP "
"Bridge v2.18.0+ and manage_options."
),
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
{
"name": "wp_db_tables",
"method_name": "wp_db_tables",
"description": (
"Read per-table size + row breakdown. Returns "
"``{database_name, table_prefix, tables: [{name, engine, "
"rows, data_bytes, index_bytes, total_bytes, collation}]}`` "
"ordered by total_bytes descending. Same source as "
"``wp_db_size`` — one row per WP table. Useful for "
"'which table is the bloat?' debugging (commonly "
"options, postmeta, comments, or a plugin's log table). "
"Requires Airano MCP Bridge v2.18.0+ and manage_options."
),
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
{
"name": "wp_db_search",
"method_name": "wp_db_search",
"description": (
"Full-text search across post / product content using "
"``WP_Query`` with ``s=$query``. NEVER raw SQL (S-25). "
"Returns ``{query, limit, count, hits: [{id, post_type, "
"status, title, snippet, url, modified}]}``. Sanitises "
"``query`` via ``sanitize_text_field`` server-side; "
"client caps it at 200 chars. ``limit`` defaults to 20, "
"max 100. Optional ``post_type`` (string or array) and "
"``status`` (string or array) filters. Non-readable "
"posts (private / draft from other authors) are filtered "
"out by ``WP_Query``'s own ``posts_clauses`` — same gate "
"the WP search page uses. Requires Airano MCP Bridge "
"v2.18.0+ and manage_options."
),
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": _QUERY_MAX_LEN,
"description": (
"Search term. Sanitised server-side via "
"``sanitize_text_field``; client caps at "
f"{_QUERY_MAX_LEN} chars."
),
},
"post_type": {
"description": (
"Optional post type filter. String "
"(``post``, ``page``, ``product`` ...) or "
"array of strings. Defaults to ``any``."
),
},
"status": {
"description": (
"Optional post status filter. String "
"(``publish``, ``draft`` ...) or array of "
"strings. Defaults to ``any``."
),
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": _SEARCH_LIMIT_MAX,
"default": 20,
"description": (
f"Max hits to return (1{_SEARCH_LIMIT_MAX}). " "Defaults to 20."
),
},
},
"required": ["query"],
},
"scope": "read",
},
]
# ─────────────────────────────────────────────────────────────────────
# Client-side validation helpers
# ─────────────────────────────────────────────────────────────────────
def _normalise_query(value: Any) -> str:
"""Validate + length-cap the search query (S-25 client side).
The server re-runs ``sanitize_text_field`` and applies the same
length cap; this is a fast-fail before the round-trip. Empty
queries are rejected — ``WP_Query`` with ``s=''`` matches every
post and would return a meaningless dump.
"""
if not isinstance(value, str):
raise ValueError(f"query must be a string (got {type(value).__name__})")
stripped = value.strip()
if not stripped:
raise ValueError("query must be a non-empty string")
if len(stripped) > _QUERY_MAX_LEN:
stripped = stripped[:_QUERY_MAX_LEN]
return stripped
def _normalise_limit(value: Any) -> int:
"""Cap the search limit at 100 (mirror of the server-side ceiling)."""
if value is None:
return 20
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"limit must be an integer (got {type(value).__name__})")
if value < 1:
raise ValueError(f"limit must be >= 1 (got {value})")
if value > _SEARCH_LIMIT_MAX:
return _SEARCH_LIMIT_MAX
return value
def _normalise_filter(value: Any, field: str) -> str | list[str] | None:
"""Accept a string or list of strings; reject anything else.
The server sanitises with ``sanitize_key`` per item; we only check
structural shape here.
"""
if value is None:
return None
if isinstance(value, str):
return value if value else None
if isinstance(value, list):
if not all(isinstance(v, str) for v in value):
raise ValueError(f"{field} array must contain only strings")
return value
raise ValueError(f"{field} must be a string or array of strings")
class DatabaseHandler:
"""Database inspection surface (F.19.3.2-.3) — db/size + db/tables + db/search.
Each method returns the parsed JSON envelope from the companion.
The plugin.py wrapper serialises for MCP transport. Server errors
(500 db_size_query_failed, 400 invalid_query, etc.) are relayed
untouched — the companion is the binding gate.
"""
def __init__(self, client: WordPressClient) -> None:
self.client = client
async def wp_db_size(self, **_: Any) -> dict[str, Any]:
return await self.client.get(
f"{_ADMIN_NS}/db/size",
use_custom_namespace=True,
)
async def wp_db_tables(self, **_: Any) -> dict[str, Any]:
return await self.client.get(
f"{_ADMIN_NS}/db/tables",
use_custom_namespace=True,
)
async def wp_db_search(
self,
query: str,
post_type: str | list[str] | None = None,
status: str | list[str] | None = None,
limit: int | None = None,
**_: Any,
) -> dict[str, Any]:
body: dict[str, Any] = {
"query": _normalise_query(query),
"limit": _normalise_limit(limit),
}
pt = _normalise_filter(post_type, "post_type")
if pt is not None:
body["post_type"] = pt
st = _normalise_filter(status, "status")
if st is not None:
body["status"] = st
return await self.client.post(
f"{_ADMIN_NS}/db/search",
json_data=body,
use_custom_namespace=True,
)