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,170 @@
#!/usr/bin/env bash
# Pre-submission checks for airano-mcp-bridge (wp.org-bound version).
#
# Run from the repo root or from this directory:
# bash wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
#
# Exit code is non-zero on any FAIL. Prints a one-line summary at the end.
set -u
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/airano-mcp-bridge"
PHP_FILE="${PLUGIN_DIR}/airano-mcp-bridge.php"
README_TXT="${PLUGIN_DIR}/readme.txt"
fail=0
pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; }
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
[ -f "$PHP_FILE" ] || { echo "main PHP file not found: $PHP_FILE"; exit 2; }
[ -f "$README_TXT" ] || { echo "readme.txt not found: $README_TXT"; exit 2; }
section "1. PHP syntax (php -l)"
if php -l "$PHP_FILE" > /tmp/phplint.out 2>&1; then
pass "syntax clean"
else
fail "php -l reported errors:"
cat /tmp/phplint.out
fi
section "2. Plugin headers"
ver_php=$(grep -oE '^\s*\*\s*Version:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
ver_const=$(grep -oE "const VERSION\s*=\s*'[^']+'" "$PHP_FILE" | head -1 | grep -oE "'[^']+'" | tr -d "'")
stable=$(grep -oE '^Stable tag:\s*\S+' "$README_TXT" | awk '{print $NF}')
tested=$(grep -oE '^Tested up to:\s*\S+' "$README_TXT" | awk '{print $NF}')
plugin_uri=$(grep -oE '^\s*\*\s*Plugin URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
author_uri=$(grep -oE '^\s*\*\s*Author URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
[ "$ver_php" = "$ver_const" ] && pass "Version header == const VERSION ($ver_php)" || fail "Version mismatch: header=$ver_php const=$ver_const"
[ "$ver_php" = "$stable" ] && pass "Version header == readme.txt Stable tag ($ver_php)" || fail "Stable tag mismatch: $ver_php vs $stable"
case "$tested" in
*.*.*) fail "Tested up to has minor version ($tested) — wp.org rejects this. Use major.minor only." ;;
*.*) pass "Tested up to is major.minor ($tested)" ;;
*) fail "Tested up to has unexpected format: $tested" ;;
esac
[ -n "$plugin_uri" ] && [ -n "$author_uri" ] && [ "$plugin_uri" != "$author_uri" ] \
&& pass "Plugin URI ($plugin_uri) and Author URI ($author_uri) are distinct" \
|| fail "Plugin URI and Author URI are the same or missing"
section "3. wp.org review issues from review email"
# Class name must use the Airano_MCP_ prefix (no generic SEO_API_Bridge)
if grep -q "^class SEO_API_Bridge" "$PHP_FILE"; then
fail "main class still named SEO_API_Bridge — wp.org rejects generic prefixes"
else
pass "main class is not the generic SEO_API_Bridge"
fi
if grep -q "^class Airano_MCP_Bridge" "$PHP_FILE"; then
pass "main class uses the Airano_MCP_ prefix"
else
fail "main class is not Airano_MCP_Bridge"
fi
# (a) media.php must NOT be loaded directly outside of the comment/changelog
includes=$(grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE" | wc -l)
if [ "$includes" -eq 0 ]; then
pass "no direct require of wp-admin/includes/media.php"
else
fail "found $includes direct require(_once) of wp-admin/includes/media.php"
grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE"
fi
# (b) /upload-and-attach must use the new dedicated permission_callback
if grep -q "'permission_callback' => \[\$this, 'require_upload_and_attach_capability'\]" "$PHP_FILE"; then
pass "/upload-and-attach is gated by require_upload_and_attach_capability"
else
fail "/upload-and-attach does not use require_upload_and_attach_capability"
fi
if grep -q "function require_upload_and_attach_capability" "$PHP_FILE"; then
pass "require_upload_and_attach_capability method is defined"
else
fail "require_upload_and_attach_capability method is missing"
fi
# It must call current_user_can('edit_post', $attach_to_post)
if grep -q "current_user_can('edit_post', \$attach_to_post)" "$PHP_FILE"; then
pass "edit_post per-target check is enforced at the route gate"
else
fail "edit_post per-target check is missing"
fi
section "4. REST routes — every register_rest_route has a permission_callback"
# Walk the file and check each register_rest_route(...) block individually.
# A block runs from its register_rest_route line up to the matching closing
# `]);` at the same brace depth. We check each block contains
# `permission_callback`.
audit=$(php -r '
$src = file_get_contents($argv[1]);
$lines = preg_split("/\r?\n/", $src);
$total = 0; $missing = 0; $missing_lines = [];
$inBlock = false; $depth = 0; $hasPerm = false; $startLine = 0;
foreach ($lines as $i => $line) {
$lineNo = $i + 1;
if (!$inBlock && preg_match("/register_rest_route\s*\(/", $line)) {
$inBlock = true; $depth = 0; $hasPerm = false; $startLine = $lineNo; $total++;
}
if ($inBlock) {
if (strpos($line, "permission_callback") !== false) $hasPerm = true;
$depth += substr_count($line, "[") + substr_count($line, "(");
$depth -= substr_count($line, "]") + substr_count($line, ")");
if ($depth <= 0 && $lineNo > $startLine) {
if (!$hasPerm) { $missing++; $missing_lines[] = $startLine; }
$inBlock = false;
}
}
}
echo "$total $missing " . implode(",", $missing_lines);
' "$PHP_FILE")
total=$(echo "$audit" | awk '{print $1}')
missing=$(echo "$audit" | awk '{print $2}')
missing_lines=$(echo "$audit" | awk '{print $3}')
if [ "$missing" -eq 0 ]; then
pass "all $total REST routes have a permission_callback"
else
fail "$missing of $total REST routes are missing permission_callback (lines: $missing_lines)"
fi
# No __return_true on routes that change state (POST/PUT/DELETE)
public_writes=$(awk '/register_rest_route/,/\]\s*\)\s*;/' "$PHP_FILE" \
| grep -B2 "__return_true" \
| grep -E "'methods'\s*=>\s*'(POST|PUT|DELETE|PATCH)'" \
| wc -l)
if [ "$public_writes" -eq 0 ]; then
pass "no public (__return_true) callbacks on POST/PUT/DELETE routes"
else
fail "$public_writes write routes use __return_true (public) — review needed"
fi
section "5. Output escaping & sanitisation (heuristic — manual review still required)"
# Find raw echo/print of REST request params
raw_echo=$(grep -nE "echo\s+\\\$request->|print\s+\\\$request->" "$PHP_FILE" | wc -l)
[ "$raw_echo" -eq 0 ] && pass "no raw echo of \$request-> values" || fail "$raw_echo raw echoes of \$request->"
# Reject sql concatenation patterns
sql_concat=$(grep -nE '\$wpdb->(query|get_results|get_var|get_row).*\.\s*\$' "$PHP_FILE" | wc -l)
[ "$sql_concat" -eq 0 ] && pass "no obvious SQL concatenation (no \$wpdb-> ... . \$var)" || fail "$sql_concat possible SQL concatenations — manual review"
# eval / system / exec must NOT appear
dangerous=$(grep -nE "\b(eval|exec|system|passthru|popen|shell_exec|proc_open)\s*\(" "$PHP_FILE" | grep -v "^\s*\*" | wc -l)
[ "$dangerous" -eq 0 ] && pass "no eval/exec/system/passthru/popen calls" || fail "$dangerous dangerous function calls"
section "6. Text domain"
td_count=$(grep -cE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"]" "$PHP_FILE")
non_plugin_td=$(grep -oE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"][^'\"]*['\"]\s*,\s*['\"][^'\"]+['\"]" "$PHP_FILE" \
| grep -oE "['\"][^'\"]+['\"]\s*\)\s*$" | grep -vc "airano-mcp-bridge" || true)
if [ "$non_plugin_td" -eq 0 ]; then
pass "$td_count translation calls all use 'airano-mcp-bridge' domain"
else
fail "$non_plugin_td translation calls use a non-plugin text domain"
fi
section "7. Direct file access guard"
if grep -q "if (!defined('ABSPATH'))" "$PHP_FILE" && grep -q "exit;" "$PHP_FILE"; then
pass "ABSPATH guard present"
else
fail "missing 'if (!defined(ABSPATH)) exit;' guard"
fi
section "Summary"
if [ "$fail" -eq 0 ]; then
printf '\033[32m✓ all checks passed\033[0m — version %s ready for wp.org review reply\n' "$ver_php"
exit 0
else
printf '\033[31m✗ %d check(s) failed\033[0m\n' "$fail"
exit 1
fi

View File

@@ -0,0 +1,183 @@
<?php
/**
* Standalone tests for require_upload_and_attach_capability without a
* full WordPress bootstrap. Stubs the few WP globals/functions the
* permission method touches and walks every gate.
*
* Run from the repo root:
* php wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
*
* Exit code is non-zero on any FAIL.
*/
// --- Minimal WP shims ----------------------------------------------------
if (!class_exists('WP_Error')) {
class WP_Error {
public $code; public $message; public $data;
public function __construct($code = '', $message = '', $data = '') {
$this->code = $code; $this->message = $message; $this->data = $data;
}
}
}
if (!function_exists('rest_authorization_required_code')) {
function rest_authorization_required_code() { return 401; }
}
// __() / esc_html__ / etc. just return the string unchanged.
foreach (['__', '_e', '_x', 'esc_html__', 'esc_attr__', 'esc_html_e', 'esc_attr_e'] as $fn) {
if (!function_exists($fn)) {
eval("function $fn(\$s, \$d = '') { return \$s; }");
}
}
// Capability + post-edit lookup is driven by globals so each test can
// configure the scenario.
$GLOBALS['__caps'] = []; // ['upload_files' => true, 'manage_options' => false, ...]
$GLOBALS['__edits'] = []; // [42 => true, 99 => false]
if (!function_exists('current_user_can')) {
function current_user_can($cap, $object_id = null) {
if ($cap === 'edit_post' && $object_id !== null) {
return !empty($GLOBALS['__edits'][$object_id]);
}
return !empty($GLOBALS['__caps'][$cap]);
}
}
// Tiny WP_REST_Request stub — only ->get_param() is used by the gate.
if (!class_exists('WP_REST_Request')) {
class WP_REST_Request {
private $params;
public function __construct(array $params = []) { $this->params = $params; }
public function get_param($k) { return $this->params[$k] ?? null; }
}
}
// --- Load just the Airano_MCP_Bridge class -------------------------------
// We can't require the whole plugin (it calls add_action()/add_filter() in
// the global scope on load), so we extract the class body and eval it.
// Strip the two add_action() lines at the bottom.
$src = file_get_contents(__DIR__ . '/../airano-mcp-bridge/airano-mcp-bridge.php');
$src = preg_replace('/<\?php/', '', $src, 1);
$src = preg_replace('/^.*?(if\s*\(!\s*defined.*?ABSPATH.*?\)\s*\{[^}]*\})/s', '', $src, 1);
// Remove any add_action / add_filter calls outside the class.
$src = preg_replace('/^\s*(add_action|add_filter)\s*\([^;]+;\s*$/m', '', $src);
// Remove `new Airano_MCP_Bridge();` invocation.
$src = preg_replace('/^\s*new\s+Airano_MCP_Bridge\s*\(\s*\)\s*;\s*$/m', '', $src);
// WP function shims so the class file can be loaded without bootstrapping
// WordPress. Each returns a benign default — the tests only exercise the
// permission method, not the rest of the plugin.
$_wp_voids = [
'add_action', 'add_filter', 'do_action', 'register_activation_hook',
'register_deactivation_hook', 'register_uninstall_hook',
'load_plugin_textdomain', 'wp_schedule_event', 'wp_clear_scheduled_hook',
];
foreach ($_wp_voids as $fn) {
if (!function_exists($fn)) {
eval("function $fn() { return true; }");
}
}
$_wp_returns_first = ['apply_filters', 'sanitize_text_field', 'sanitize_key', 'wp_unslash'];
foreach ($_wp_returns_first as $fn) {
if (!function_exists($fn)) {
eval("function $fn(\$a) { return \$a; }");
}
}
if (!function_exists('get_option')) { function get_option($k, $d = false) { return $d; } }
if (!function_exists('update_option')) { function update_option() { return true; } }
if (!function_exists('add_option')) { function add_option() { return true; } }
if (!function_exists('delete_option')) { function delete_option() { return true; } }
if (!function_exists('wp_next_scheduled')) { function wp_next_scheduled() { return false; } }
if (!function_exists('plugin_dir_path')) { function plugin_dir_path($f) { return dirname($f); } }
if (!function_exists('plugin_basename')) { function plugin_basename($f) { return basename($f); } }
if (!function_exists('untrailingslashit')) { function untrailingslashit($s) { return rtrim($s, '/\\'); } }
if (!function_exists('trailingslashit')) { function trailingslashit($s) { return rtrim($s, '/\\') . '/'; } }
if (!function_exists('wp_parse_url')) { function wp_parse_url($u, $c = -1) { return $c === -1 ? parse_url($u) : parse_url($u, $c); } }
eval($src);
// --- Tests ---------------------------------------------------------------
$pass = 0; $fail = 0;
function check($label, $ok, $detail = '') {
global $pass, $fail;
if ($ok) { echo " \033[32mPASS\033[0m $label\n"; $pass++; }
else { echo " \033[31mFAIL\033[0m $label" . ($detail ? "$detail" : '') . "\n"; $fail++; }
}
$bridge = new Airano_MCP_Bridge();
echo "\n\033[1mrequire_upload_and_attach_capability — gate semantics\033[0m\n";
// 1. No upload_files + no manage_options → forbidden
$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => false];
$GLOBALS['__edits'] = [];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('caller without upload_files/manage_options is rejected',
$res instanceof WP_Error && $res->code === 'rest_forbidden');
// 2. Has upload_files, no attach_to_post → allowed (no per-target check)
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('upload_files alone, no attach_to_post → true', $res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 3. Has upload_files, attach_to_post=42, no edit_post → forbidden
$GLOBALS['__caps'] = ['upload_files' => true];
$GLOBALS['__edits'] = [42 => false];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
check('attach_to_post supplied, edit_post denied → rest_cannot_edit',
$res instanceof WP_Error && $res->code === 'rest_cannot_edit',
is_object($res) ? "got code: " . $res->code : "got: " . var_export($res, true));
// 4. Has upload_files, attach_to_post=42, edit_post granted → allowed
$GLOBALS['__caps'] = ['upload_files' => true];
$GLOBALS['__edits'] = [42 => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
check('attach_to_post + edit_post → true', $res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 5. set_featured=true without attach_to_post → invalid_param
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'true']));
check('set_featured without attach_to_post → rest_invalid_param',
$res instanceof WP_Error && $res->code === 'rest_invalid_param');
// 6. set_featured=1 without attach_to_post → invalid_param (numeric string)
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => '1']));
check("set_featured='1' without attach_to_post → rest_invalid_param",
$res instanceof WP_Error && $res->code === 'rest_invalid_param');
// 7. set_featured=false → does NOT trigger the invalid_param branch
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'false']));
check("set_featured='false' (no attach_to_post) → allowed",
$res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 8. set_featured=true + attach_to_post=42 + edit_post=true → allowed
$GLOBALS['__edits'] = [42 => true];
$res = $bridge->require_upload_and_attach_capability(
new WP_REST_Request(['attach_to_post' => 42, 'set_featured' => 'true']));
check('set_featured=true + attach_to_post + edit_post → true', $res === true);
// 9. manage_options-only caller (no upload_files) is allowed (admins still pass)
$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => true];
$GLOBALS['__edits'] = [];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('manage_options without upload_files → true', $res === true);
// 10. attach_to_post=0 (defaulted/missing) is treated as "not supplied" — no
// per-target check needed.
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 0]));
check('attach_to_post=0 → no per-target check, allowed', $res === true);
echo "\n";
if ($fail === 0) {
echo "\033[32m✓ $pass tests passed\033[0m\n";
exit(0);
} else {
echo "\033[31m✗ $fail of " . ($pass + $fail) . " tests failed\033[0m\n";
exit(1);
}