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,293 @@
"""F.19.3.2-.3 — Bulk fan-out surface (post + term updates).
Stock-REST-backed: each item dispatches to ``wp/v2/posts/{id}`` or
``wp/v2/{taxonomy}/{id}`` — no companion routes are added for these
because the stock REST endpoints already exist and handle their own
permission checks. Per-item permission gating happens at the WP layer
(``edit_post`` / ``manage_terms`` per id), so partial successes are
the expected shape.
Surface map:
* **wp_bulk_post_update** — ``POST wp/v2/posts/{id}`` per item, fanned
out with concurrency=10 (mirror of the legacy ``wordpress_advanced``
bulk pattern). 50-item cap.
* **wp_bulk_term_update** — ``POST wp/v2/{taxonomy}/{id}`` per item,
same shape. 50-item cap. ``taxonomy`` is the REST base
(``categories`` / ``tags`` / custom rest_base).
Both tools sit on the ``editor`` tier — same risk class as bulk page
edits. Caller needs ``edit_posts`` / ``manage_terms`` (or the
taxonomy-specific cap) on every item; per-item failures are surfaced
in the response, the rest succeed.
Security rules (extending S-1…S-25):
* **S-26** — Bulk operations cap at 50 items per call (mirror of the
S-14 / S-18 family). Bigger payloads return ``bulk_too_large`` 400
client-side without any HTTP traffic. Per-item permission checks
happen one-by-one inside WP REST (``current_user_can('edit_post', $id)``);
the client doesn't aggregate caps.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from plugins.wordpress.client import WordPressClient
# S-26 cap. Server doesn't add its own check — the cap is purely
# client-side because each request is a separate stock-REST call.
_BULK_MAX_ITEMS = 50
# Stock REST uses POST for create/update on the post / taxonomy
# endpoints. PUT also works in modern WP, but POST is the documented
# pattern and matches what WP-CLI emits.
_UPDATE_METHOD = "POST"
# Acceptable taxonomy slug shape — must look like ``sanitize_key``
# would have produced it server-side. Lowercased, digits, ``_``, ``-``.
_TAXONOMY_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]{0,63}$")
# Concurrency bound for the fan-out. Matches the legacy
# ``wordpress_advanced`` pattern; keeps the WP server from getting
# swamped on shared hosting.
_FANOUT_CONCURRENCY = 10
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specs for the F.19.3.2-.3 bulk surface."""
return [
{
"name": "wp_bulk_post_update",
"method_name": "wp_bulk_post_update",
"description": (
"Fan-out update across multiple posts via stock REST "
"``wp/v2/posts/{id}``. Pass ``updates=[{id, ...fields}]`` "
"where each item carries the id plus whichever fields "
"to write (``status``, ``title``, ``content``, ``excerpt``, "
"``categories``, ``tags``, ``author``, ``featured_media``, "
"``meta``, etc — anything stock REST accepts on the "
"``post`` endpoint). Returns "
"``[{id, status:'ok'|'error', error?}]`` — partial "
"successes are the expected shape since per-item "
"``edit_post`` cap checks happen at the WP layer. "
"S-26: 50-item cap per call; bigger payloads return "
"``bulk_too_large`` 400 without any HTTP. Concurrency "
"is bounded to 10 in flight to avoid swamping shared "
"hosts."
),
"schema": {
"type": "object",
"properties": {
"updates": {
"type": "array",
"minItems": 1,
"maxItems": _BULK_MAX_ITEMS,
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"minimum": 1,
"description": "Post id to update.",
},
},
"required": ["id"],
},
"description": (
f"Updates array (1{_BULK_MAX_ITEMS} items). "
"Each item must carry ``id`` plus any "
"post fields stock REST accepts."
),
},
},
"required": ["updates"],
},
"scope": "editor",
},
{
"name": "wp_bulk_term_update",
"method_name": "wp_bulk_term_update",
"description": (
"Fan-out update across multiple terms in a single "
"taxonomy via stock REST ``wp/v2/{taxonomy}/{id}``. "
"``taxonomy`` is the REST base — ``categories``, "
"``tags``, or a custom taxonomy's ``rest_base``. "
"Pass ``updates=[{id, ...fields}]`` where each item "
"carries the id plus whichever term fields to write "
"(``name``, ``slug``, ``description``, ``parent``, "
"``meta``). Returns "
"``[{id, status:'ok'|'error', error?}]``. Per-item "
"``manage_terms`` (or the taxonomy-specific edit cap) "
"is enforced server-side. S-26: 50-item cap per call. "
"Concurrency is bounded to 10 in flight."
),
"schema": {
"type": "object",
"properties": {
"taxonomy": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": (
"REST base of the taxonomy "
"(``categories``, ``tags``, or a custom "
"taxonomy's rest_base)."
),
},
"updates": {
"type": "array",
"minItems": 1,
"maxItems": _BULK_MAX_ITEMS,
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"minimum": 1,
"description": "Term id to update.",
},
},
"required": ["id"],
},
"description": (f"Updates array (1{_BULK_MAX_ITEMS} items)."),
},
},
"required": ["taxonomy", "updates"],
},
"scope": "editor",
},
]
# ─────────────────────────────────────────────────────────────────────
# Client-side validation helpers
# ─────────────────────────────────────────────────────────────────────
def _validate_updates(value: Any) -> list[dict[str, Any]]:
"""Validate the bulk updates array (S-26 client side).
Each item must be a dict with a positive integer ``id``. Other
fields are forwarded untouched — stock REST does its own field
validation. Empty arrays are rejected (no-op), > 50 returns
``bulk_too_large``.
"""
if not isinstance(value, list):
raise ValueError(f"updates must be a list (got {type(value).__name__})")
if not value:
raise ValueError("updates must contain at least one item")
if len(value) > _BULK_MAX_ITEMS:
raise ValueError(
f"bulk_too_large: updates contains {len(value)} items "
f"(max {_BULK_MAX_ITEMS} per call)"
)
for idx, item in enumerate(value):
if not isinstance(item, dict):
raise ValueError(f"updates[{idx}] must be an object")
item_id = item.get("id")
if not isinstance(item_id, int) or isinstance(item_id, bool) or item_id < 1:
raise ValueError(f"updates[{idx}].id must be a positive integer (got {item_id!r})")
return value
def _validate_taxonomy(value: Any) -> str:
"""Validate the taxonomy slug shape (cheap pre-check).
The binding gate is server-side — WP returns 404 for unregistered
taxonomies. This catches obviously bad input (slashes, spaces,
etc.) without a round-trip.
"""
if not isinstance(value, str):
raise ValueError(f"taxonomy must be a string (got {type(value).__name__})")
stripped = value.strip()
if not stripped:
raise ValueError("taxonomy must be a non-empty string")
if not _TAXONOMY_RE.match(stripped):
raise ValueError(f"taxonomy must match [a-z0-9][a-z0-9_-]{{0,63}} (got {stripped!r})")
return stripped
class BulkHandler:
"""Bulk fan-out surface (F.19.3.2-.3) — post + term updates.
Each method runs N stock-REST requests in parallel (bounded at
concurrency=10) and returns the per-item status array. Per-item
failures don't fail the whole call — the caller gets
``{id, status:'error', error}`` for each one and ``status:'ok'``
for the successes.
"""
def __init__(self, client: WordPressClient) -> None:
self.client = client
async def _fanout(
self,
endpoint_template: str,
updates: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Run the per-item fan-out with bounded concurrency.
``endpoint_template`` is a format string that takes the item id —
e.g. ``"posts/{id}"`` or ``"categories/{id}"``. Each item's
non-``id`` fields are forwarded as the JSON body.
"""
sem = asyncio.Semaphore(_FANOUT_CONCURRENCY)
results: list[dict[str, Any]] = [None] * len(updates) # type: ignore[list-item]
async def one(idx: int, item: dict[str, Any]) -> None:
item_id = int(item["id"])
body = {k: v for k, v in item.items() if k != "id"}
async with sem:
try:
await self.client.request(
_UPDATE_METHOD,
endpoint_template.format(id=item_id),
json_data=body if body else None,
)
results[idx] = {"id": item_id, "status": "ok"}
except Exception as exc: # noqa: BLE001 — relay any per-item failure
results[idx] = {
"id": item_id,
"status": "error",
"error": str(exc),
}
await asyncio.gather(*(one(i, u) for i, u in enumerate(updates)))
return results
async def wp_bulk_post_update(
self,
updates: list[dict[str, Any]],
**_: Any,
) -> dict[str, Any]:
clean = _validate_updates(updates)
results = await self._fanout("posts/{id}", clean)
ok = sum(1 for r in results if r["status"] == "ok")
return {
"total": len(results),
"ok": ok,
"errors": len(results) - ok,
"results": results,
}
async def wp_bulk_term_update(
self,
taxonomy: str,
updates: list[dict[str, Any]],
**_: Any,
) -> dict[str, Any]:
tax = _validate_taxonomy(taxonomy)
clean = _validate_updates(updates)
results = await self._fanout(tax + "/{id}", clean)
ok = sum(1 for r in results if r["status"] == "ok")
return {
"taxonomy": tax,
"total": len(results),
"ok": ok,
"errors": len(results) - ok,
"results": results,
}