sync: v3.2.0 — Supabase fixes, site edit, WP plugin rename, black formatting
- Supabase: fix filters, count_rows, upsert, anon_key optional, meta_url field - Dashboard: site edit page + PATCH update route - WP Plugin: rename SEO API Bridge → Airano MCP SEO Bridge - supabase/client.py: black formatting fix - .gitignore: exclude BUILD_REPORT.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ ENV/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
pytest-out.txt
|
pytest-out.txt
|
||||||
|
scripts/community-build/BUILD_REPORT.md
|
||||||
.nox/
|
.nox/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"no_sites": "No sites added yet",
|
"no_sites": "No sites added yet",
|
||||||
"add_first_site": "Add your first site to get started",
|
"add_first_site": "Add your first site to get started",
|
||||||
"site_added": "Site added successfully",
|
"site_added": "Site added successfully",
|
||||||
|
"site_updated": "Site updated successfully",
|
||||||
"site_deleted": "Site deleted",
|
"site_deleted": "Site deleted",
|
||||||
|
"edit_site": "Edit Site",
|
||||||
|
"updating_site": "Updating site...",
|
||||||
|
"keep_existing": "Leave blank to keep current value",
|
||||||
"connection_ok": "Connection OK",
|
"connection_ok": "Connection OK",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
|
|||||||
"no_sites": "هنوز سایتی اضافه نشده",
|
"no_sites": "هنوز سایتی اضافه نشده",
|
||||||
"add_first_site": "اولین سایت خود را اضافه کنید",
|
"add_first_site": "اولین سایت خود را اضافه کنید",
|
||||||
"site_added": "سایت با موفقیت اضافه شد",
|
"site_added": "سایت با موفقیت اضافه شد",
|
||||||
|
"site_updated": "سایت با موفقیت بروزرسانی شد",
|
||||||
"site_deleted": "سایت حذف شد",
|
"site_deleted": "سایت حذف شد",
|
||||||
|
"edit_site": "ویرایش سایت",
|
||||||
|
"updating_site": "در حال بروزرسانی...",
|
||||||
|
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
|
||||||
"connection_ok": "اتصال برقرار",
|
"connection_ok": "اتصال برقرار",
|
||||||
"connection_failed": "اتصال ناموفق",
|
"connection_failed": "اتصال ناموفق",
|
||||||
"credentials": "مشخصات دسترسی",
|
"credentials": "مشخصات دسترسی",
|
||||||
@@ -2811,6 +2819,82 @@ async def api_test_site(request: Request) -> Response:
|
|||||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def dashboard_sites_edit(request: Request) -> Response:
|
||||||
|
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
|
||||||
|
site_id = request.path_params.get("id", "")
|
||||||
|
|
||||||
|
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||||
|
|
||||||
|
site = await get_user_site(site_id, user_session["user_id"])
|
||||||
|
if site is None:
|
||||||
|
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language")
|
||||||
|
query_lang = request.query_params.get("lang")
|
||||||
|
lang = detect_language(accept_language, query_lang)
|
||||||
|
t = get_translations(lang)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
plugin_fields = get_user_credential_fields()
|
||||||
|
plugin_names = get_user_plugin_names()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard/sites/edit.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"lang": lang,
|
||||||
|
"t": t,
|
||||||
|
"session": user_session,
|
||||||
|
"site": site,
|
||||||
|
"plugin_fields": plugin_fields,
|
||||||
|
"plugin_fields_json": json.dumps(plugin_fields),
|
||||||
|
"plugin_names": plugin_names,
|
||||||
|
"current_page": "my_sites",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_update_site(request: Request) -> Response:
|
||||||
|
"""PATCH /api/sites/{id} — Update site URL and credentials."""
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect:
|
||||||
|
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
site_id = request.path_params.get("id", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||||
|
|
||||||
|
from core.site_api import update_user_site
|
||||||
|
|
||||||
|
try:
|
||||||
|
site = await update_user_site(
|
||||||
|
site_id=site_id,
|
||||||
|
user_id=user_session["user_id"],
|
||||||
|
url=body.get("url", "").strip().rstrip("/"),
|
||||||
|
credentials=body.get("credentials", {}),
|
||||||
|
)
|
||||||
|
return JSONResponse({"site": site, "message": "Site updated"})
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=400)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("Site update failed (runtime): %s", e)
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Site storage is not configured. Contact the administrator."},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update site: %s", e, exc_info=True)
|
||||||
|
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
async def api_create_key(request: Request) -> Response:
|
async def api_create_key(request: Request) -> Response:
|
||||||
"""POST /api/keys — Create a new user API key."""
|
"""POST /api/keys — Create a new user API key."""
|
||||||
user_session, redirect = _require_user_session(request)
|
user_session, redirect = _require_user_session(request)
|
||||||
@@ -3090,6 +3174,7 @@ def register_dashboard_routes(mcp):
|
|||||||
# Site Management routes (E.3)
|
# Site Management routes (E.3)
|
||||||
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
||||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||||
|
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
||||||
|
|
||||||
# Site Management API (E.3)
|
# Site Management API (E.3)
|
||||||
@@ -3097,6 +3182,7 @@ def register_dashboard_routes(mcp):
|
|||||||
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)
|
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)
|
||||||
mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site)
|
mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site)
|
||||||
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
|
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
|
||||||
|
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
|
||||||
|
|
||||||
# User API Key routes (E.3)
|
# User API Key routes (E.3)
|
||||||
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
||||||
|
|||||||
@@ -530,6 +530,30 @@ class Database:
|
|||||||
(status, status_msg, site_id),
|
(status, status_msg, site_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def update_site_credentials(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
url: str,
|
||||||
|
credentials: bytes,
|
||||||
|
) -> bool:
|
||||||
|
"""Update URL and credentials for an existing site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
url: New base URL for the site.
|
||||||
|
credentials: New AES-256-GCM encrypted credentials blob.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if site not found or not owned by user.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"UPDATE sites SET url = ?, credentials = ?, status = 'pending' WHERE id = ? AND user_id = ?",
|
||||||
|
(url, credentials, site_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||||
"""Get a site by user ID and alias.
|
"""Get a site by user ID and alias.
|
||||||
|
|
||||||
|
|||||||
121
core/site_api.py
121
core/site_api.py
@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
|||||||
"label": "Service Role Key",
|
"label": "Service Role Key",
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"required": True,
|
"required": True,
|
||||||
"hint": "Supabase Dashboard → Settings → API → service_role key",
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → service_role key. "
|
||||||
|
"Note: On supabase.com cloud, postgres-meta tools "
|
||||||
|
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
|
||||||
|
"they only work on self-hosted Supabase."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "anon_key",
|
"name": "anon_key",
|
||||||
"label": "Anon Key",
|
"label": "Anon Key (Optional)",
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"required": True,
|
"required": False,
|
||||||
"hint": "Supabase Dashboard → Settings → API → anon key",
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → anon key. "
|
||||||
|
"Optional — if omitted, service_role_key is used for all calls."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta_url",
|
||||||
|
"label": "postgres-meta URL (Optional)",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"hint": (
|
||||||
|
"Only needed when /pg/ is not exposed through Kong (common on Coolify). "
|
||||||
|
"Enter the direct postgres-meta container URL, e.g.: "
|
||||||
|
"http://supabase-meta-<id>:8080 — "
|
||||||
|
"Leave blank to use the default Kong /pg/ route."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"openpanel": [
|
"openpanel": [
|
||||||
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_site(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update URL and credentials for an existing site.
|
||||||
|
|
||||||
|
Password fields left blank are preserved from the existing encrypted credentials.
|
||||||
|
Re-validates the connection after update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
url: New base URL (required).
|
||||||
|
credentials: Credential dict — blank password fields keep their current value.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found, validation fails, or connection test fails.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Verify site ownership
|
||||||
|
existing_site = await db.get_site(site_id, user_id)
|
||||||
|
if existing_site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
plugin_type = existing_site["plugin_type"]
|
||||||
|
|
||||||
|
# Validate URL
|
||||||
|
url = url.strip().rstrip("/")
|
||||||
|
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||||
|
raise ValueError("URL must start with http:// or https://")
|
||||||
|
|
||||||
|
# Merge new credentials with existing ones — blank fields keep existing values
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
|
||||||
|
|
||||||
|
merged: dict[str, str] = dict(existing_credentials)
|
||||||
|
for key, value in credentials.items():
|
||||||
|
# Only override if the new value is non-empty
|
||||||
|
if value and value.strip():
|
||||||
|
merged[key] = value.strip()
|
||||||
|
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
|
||||||
|
else:
|
||||||
|
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
|
||||||
|
if key in field_defs and not field_defs[key].get("required", True):
|
||||||
|
merged[key] = ""
|
||||||
|
|
||||||
|
# Strip empty optional values before storing (keep storage clean)
|
||||||
|
merged = {k: v for k, v in merged.items() if v}
|
||||||
|
|
||||||
|
# Validate required fields are still present
|
||||||
|
valid, errors = validate_credentials(plugin_type, merged)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, merged)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt merged credentials
|
||||||
|
encrypted = encryptor.encrypt_credentials(merged, site_id)
|
||||||
|
|
||||||
|
# Persist
|
||||||
|
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
|
||||||
|
if not updated:
|
||||||
|
raise RuntimeError(f"Failed to update site {site_id}")
|
||||||
|
|
||||||
|
# Mark active after successful connection test
|
||||||
|
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
|
||||||
|
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
|
||||||
|
|
||||||
|
result = await db.get_site(site_id, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back updated site {site_id}")
|
||||||
|
|
||||||
|
site_dict = dict(result)
|
||||||
|
site_dict.pop("credentials", None)
|
||||||
|
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
|
||||||
|
return site_dict
|
||||||
|
|
||||||
|
|
||||||
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||||
"""Test connectivity to an existing site.
|
"""Test connectivity to an existing site.
|
||||||
|
|
||||||
|
|||||||
134
core/templates/dashboard/sites/edit.html
Normal file
134
core/templates/dashboard/sites/edit.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.edit_site }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.edit_site }}: {{ site.alias }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info notice about password fields -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{{ t.keep_existing }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||||
|
|
||||||
|
<form id="edit-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||||
|
|
||||||
|
<!-- Plugin Type (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||||
|
<input type="url" id="url" name="url" required value="{{ site.url }}"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alias (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ site.alias }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credential Fields -->
|
||||||
|
<div id="credential-fields" class="space-y-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||||
|
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||||
|
{% for field in fields %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{{ field.label }}{% if field.required %} *{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||||
|
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %}
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex items-center gap-4 pt-4">
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||||
|
{{ t.save }}
|
||||||
|
</button>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
{{ t.cancel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteId = "{{ site.id }}";
|
||||||
|
const pluginType = "{{ site.plugin_type }}";
|
||||||
|
const pluginFields = {{ plugin_fields_json | safe }};
|
||||||
|
|
||||||
|
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const errorEl = document.getElementById('error-msg');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
btn.textContent = '{{ t.updating_site }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
|
||||||
|
// Collect credentials — only include non-empty values
|
||||||
|
const creds = {};
|
||||||
|
if (pluginFields[pluginType]) {
|
||||||
|
pluginFields[pluginType].forEach(field => {
|
||||||
|
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||||
|
if (input) creds[field.name] = input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url, credentials: creds }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Failed to update site';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Network error: ' + err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -73,6 +73,10 @@
|
|||||||
id="test-btn-{{ site.id }}">
|
id="test-btn-{{ site.id }}">
|
||||||
{{ t.test_connection }}
|
{{ t.test_connection }}
|
||||||
</button>
|
</button>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{{ t.edit }}
|
||||||
|
</a>
|
||||||
<button onclick="deleteSite('{{ site.id }}')"
|
<button onclick="deleteSite('{{ site.id }}')"
|
||||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||||
{{ t.delete }}
|
{{ t.delete }}
|
||||||
|
|||||||
23
env.example
23
env.example
@@ -98,11 +98,26 @@ WORDPRESS_SITE1_ALIAS=mysite
|
|||||||
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
||||||
# N8N_INSTANCE1_ALIAS=myn8n
|
# N8N_INSTANCE1_ALIAS=myn8n
|
||||||
|
|
||||||
# --- Supabase ---
|
# --- Supabase (Self-Hosted or Cloud) ---
|
||||||
# SUPABASE_PROJECT1_URL=https://xxxxx.supabase.co
|
# Works with both self-hosted Supabase and supabase.com cloud projects.
|
||||||
# SUPABASE_PROJECT1_API_KEY=your_supabase_api_key
|
# For cloud: URL is https://xxxx.supabase.co
|
||||||
# SUPABASE_PROJECT1_SERVICE_ROLE=your_service_role_key
|
# For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
|
||||||
|
#
|
||||||
|
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
|
||||||
|
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
|
||||||
|
#
|
||||||
|
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
|
||||||
|
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
|
||||||
|
# on supabase.com cloud (postgres-meta is internal only on cloud).
|
||||||
|
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
|
||||||
|
#
|
||||||
|
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
|
||||||
|
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
|
||||||
|
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
|
||||||
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
||||||
|
#
|
||||||
|
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
|
||||||
|
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
|
||||||
|
|
||||||
# --- OpenPanel ---
|
# --- OpenPanel ---
|
||||||
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
||||||
|
|||||||
@@ -27,18 +27,30 @@ class SupabaseClient:
|
|||||||
Uses JWT-based authentication with anon_key or service_role_key.
|
Uses JWT-based authentication with anon_key or service_role_key.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_url: str, anon_key: str, service_role_key: str):
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
anon_key: str,
|
||||||
|
service_role_key: str,
|
||||||
|
meta_url: str | None = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize Supabase API client.
|
Initialize Supabase API client.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_url: Supabase instance URL (Kong gateway)
|
base_url: Supabase instance URL (Kong gateway or supabase.co)
|
||||||
anon_key: Public API key (RLS protected)
|
anon_key: Public API key (RLS protected). Optional — if empty,
|
||||||
service_role_key: Admin API key (bypasses RLS)
|
service_role_key is used for all calls.
|
||||||
|
service_role_key: Admin API key (bypasses RLS). Required.
|
||||||
|
meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555).
|
||||||
|
When provided, postgres-meta calls hit this URL directly instead of
|
||||||
|
the Kong /pg/ route. Useful when /pg/ is not exposed through Kong.
|
||||||
"""
|
"""
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.anon_key = anon_key
|
self.anon_key = anon_key
|
||||||
self.service_role_key = service_role_key
|
self.service_role_key = service_role_key
|
||||||
|
# postgres-meta base: custom URL or Kong /pg/ prefix
|
||||||
|
self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/")
|
||||||
|
|
||||||
# Initialize logger
|
# Initialize logger
|
||||||
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
||||||
@@ -56,7 +68,8 @@ class SupabaseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict: Headers with authentication
|
Dict: Headers with authentication
|
||||||
"""
|
"""
|
||||||
key = self.service_role_key if use_service_role else self.anon_key
|
# If anon_key is not configured, fall back to service_role_key for all calls
|
||||||
|
key = self.service_role_key if (use_service_role or not self.anon_key) else self.anon_key
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"apikey": key,
|
"apikey": key,
|
||||||
@@ -70,15 +83,41 @@ class SupabaseClient:
|
|||||||
|
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
async def _head_request_headers(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
headers_override: dict | None = None,
|
||||||
|
use_service_role: bool = False,
|
||||||
|
base_url_override: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Make HEAD request and return response headers.
|
||||||
|
|
||||||
|
Used for operations like count_rows where the result is in a response
|
||||||
|
header (Content-Range) rather than the body.
|
||||||
|
"""
|
||||||
|
url = f"{base_url_override or self.base_url}{endpoint}"
|
||||||
|
headers = self._get_headers(use_service_role, headers_override)
|
||||||
|
if params:
|
||||||
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.head(url, params=params or None, headers=headers) as response:
|
||||||
|
# Normalise to lowercase so callers can use consistent keys
|
||||||
|
# (aiohttp CIMultiDictProxy is case-insensitive but dict() is not)
|
||||||
|
return {k.lower(): v for k, v in response.headers.items()}
|
||||||
|
|
||||||
async def request(
|
async def request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
json_data: dict | None = None,
|
json_data: Any = None,
|
||||||
data: bytes | None = None,
|
data: bytes | None = None,
|
||||||
headers_override: dict | None = None,
|
headers_override: dict | None = None,
|
||||||
use_service_role: bool = False,
|
use_service_role: bool = False,
|
||||||
|
base_url_override: str | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Make authenticated request to Supabase API.
|
Make authenticated request to Supabase API.
|
||||||
@@ -87,10 +126,11 @@ class SupabaseClient:
|
|||||||
method: HTTP method
|
method: HTTP method
|
||||||
endpoint: API endpoint (with leading /)
|
endpoint: API endpoint (with leading /)
|
||||||
params: Query parameters
|
params: Query parameters
|
||||||
json_data: JSON body data
|
json_data: JSON body data (dict or list)
|
||||||
data: Raw binary data (for file uploads)
|
data: Raw binary data (for file uploads)
|
||||||
headers_override: Override/add headers
|
headers_override: Override/add headers
|
||||||
use_service_role: Use service_role_key
|
use_service_role: Use service_role_key
|
||||||
|
base_url_override: Override base URL (used for postgres-meta calls)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
API response
|
API response
|
||||||
@@ -98,7 +138,7 @@ class SupabaseClient:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: On API errors
|
Exception: On API errors
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{endpoint}"
|
url = f"{base_url_override or self.base_url}{endpoint}"
|
||||||
|
|
||||||
headers = self._get_headers(use_service_role, headers_override)
|
headers = self._get_headers(use_service_role, headers_override)
|
||||||
|
|
||||||
@@ -106,10 +146,10 @@ class SupabaseClient:
|
|||||||
if data is not None:
|
if data is not None:
|
||||||
headers.pop("Content-Type", None)
|
headers.pop("Content-Type", None)
|
||||||
|
|
||||||
# Filter None values
|
# Filter None values from params and dict json bodies
|
||||||
if params:
|
if params:
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
if json_data:
|
if json_data and isinstance(json_data, dict):
|
||||||
json_data = {k: v for k, v in json_data.items() if v is not None}
|
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||||
|
|
||||||
self.logger.debug(f"{method} {url}")
|
self.logger.debug(f"{method} {url}")
|
||||||
@@ -167,18 +207,70 @@ class SupabaseClient:
|
|||||||
def _extract_error_message(self, response_data: Any) -> str:
|
def _extract_error_message(self, response_data: Any) -> str:
|
||||||
"""Extract error message from various response formats."""
|
"""Extract error message from various response formats."""
|
||||||
if isinstance(response_data, dict):
|
if isinstance(response_data, dict):
|
||||||
# PostgREST error format
|
# PostgREST error format — includes code, details, hint
|
||||||
if "message" in response_data:
|
if "message" in response_data:
|
||||||
return response_data["message"]
|
parts = [response_data["message"]]
|
||||||
|
if response_data.get("code"):
|
||||||
|
parts.insert(0, f"[{response_data['code']}]")
|
||||||
|
if response_data.get("details"):
|
||||||
|
parts.append(f"Details: {response_data['details']}")
|
||||||
|
if response_data.get("detail"):
|
||||||
|
parts.append(f"Detail: {response_data['detail']}")
|
||||||
|
if response_data.get("hint"):
|
||||||
|
parts.append(f"Hint: {response_data['hint']}")
|
||||||
|
return " | ".join(parts)
|
||||||
# GoTrue error format
|
# GoTrue error format
|
||||||
if "error_description" in response_data:
|
if "error_description" in response_data:
|
||||||
return response_data["error_description"]
|
return response_data["error_description"]
|
||||||
if "msg" in response_data:
|
if "msg" in response_data:
|
||||||
return response_data["msg"]
|
return response_data["msg"]
|
||||||
|
# postgres-meta error format — "error" key with optional PG fields
|
||||||
if "error" in response_data:
|
if "error" in response_data:
|
||||||
return response_data["error"]
|
parts = [str(response_data["error"])]
|
||||||
|
if response_data.get("code"):
|
||||||
|
parts.insert(0, f"[{response_data['code']}]")
|
||||||
|
if response_data.get("hint"):
|
||||||
|
parts.append(f"Hint: {response_data['hint']}")
|
||||||
|
if response_data.get("detail"):
|
||||||
|
parts.append(f"Detail: {response_data['detail']}")
|
||||||
|
if response_data.get("position"):
|
||||||
|
parts.append(f"Position: {response_data['position']}")
|
||||||
|
return " | ".join(parts)
|
||||||
return str(response_data)
|
return str(response_data)
|
||||||
|
|
||||||
|
def _build_filter_params(self, filters: list[dict]) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Convert a filter list to PostgREST query parameters.
|
||||||
|
|
||||||
|
Handles special cases:
|
||||||
|
- ``is`` operator with Python ``None`` → ``is.null``
|
||||||
|
- ``is`` operator with Python bool → ``is.true`` / ``is.false``
|
||||||
|
- ``in`` operator with a list → ``in.(a,b,c)``
|
||||||
|
- Any other operator with ``None`` value is skipped (prevents sending
|
||||||
|
a bare ``col=eq.None`` which PostgREST would reject).
|
||||||
|
"""
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
for f in filters:
|
||||||
|
col = f.get("column")
|
||||||
|
op = f.get("operator", "eq")
|
||||||
|
val = f.get("value")
|
||||||
|
if not col:
|
||||||
|
continue
|
||||||
|
if val is None:
|
||||||
|
# Only ``is`` / ``not.is`` accept null
|
||||||
|
if op in ("is", "not.is"):
|
||||||
|
val = "null"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
elif isinstance(val, bool):
|
||||||
|
# Convert Python bool to lowercase string for PostgREST
|
||||||
|
val = "true" if val else "false"
|
||||||
|
elif op == "in" and isinstance(val, list):
|
||||||
|
# PostgREST ``in`` format: status=in.(active,inactive)
|
||||||
|
val = f"({','.join(str(v) for v in val)})"
|
||||||
|
params[col] = f"{op}.{val}"
|
||||||
|
return params
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# POSTGREST (Database)
|
# POSTGREST (Database)
|
||||||
# =====================
|
# =====================
|
||||||
@@ -204,20 +296,15 @@ class SupabaseClient:
|
|||||||
limit: Maximum rows
|
limit: Maximum rows
|
||||||
offset: Offset for pagination
|
offset: Offset for pagination
|
||||||
"""
|
"""
|
||||||
params = {"select": select, "limit": limit, "offset": offset}
|
params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
if order:
|
if order:
|
||||||
params["order"] = order
|
params["order"] = order
|
||||||
|
|
||||||
# Build filter query string
|
|
||||||
headers = {}
|
|
||||||
if filters:
|
if filters:
|
||||||
for f in filters:
|
params.update(self._build_filter_params(filters))
|
||||||
col = f.get("column")
|
|
||||||
op = f.get("operator", "eq")
|
headers = {}
|
||||||
val = f.get("value")
|
|
||||||
if col and val is not None:
|
|
||||||
params[col] = f"{op}.{val}"
|
|
||||||
|
|
||||||
# Request single objects as array
|
# Request single objects as array
|
||||||
headers["Accept"] = "application/json"
|
headers["Accept"] = "application/json"
|
||||||
@@ -240,15 +327,18 @@ class SupabaseClient:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Insert rows into a table."""
|
"""Insert rows into a table."""
|
||||||
headers = {"Prefer": "return=representation"}
|
headers = {"Prefer": "return=representation"}
|
||||||
|
upsert_params: dict[str, str] = {}
|
||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
headers["Prefer"] = "return=representation,resolution=merge-duplicates"
|
headers["Prefer"] = "return=representation,resolution=merge-duplicates"
|
||||||
if on_conflict:
|
if on_conflict:
|
||||||
headers["on-conflict"] = on_conflict
|
# PostgREST expects on_conflict as a query parameter, not a header
|
||||||
|
upsert_params["on_conflict"] = on_conflict
|
||||||
|
|
||||||
return await self.request(
|
return await self.request(
|
||||||
"POST",
|
"POST",
|
||||||
f"/rest/v1/{table}",
|
f"/rest/v1/{table}",
|
||||||
|
params=upsert_params or None,
|
||||||
json_data=rows if isinstance(rows, list) else [rows],
|
json_data=rows if isinstance(rows, list) else [rows],
|
||||||
headers_override=headers,
|
headers_override=headers,
|
||||||
use_service_role=use_service_role,
|
use_service_role=use_service_role,
|
||||||
@@ -258,14 +348,7 @@ class SupabaseClient:
|
|||||||
self, table: str, data: dict, filters: list[dict], use_service_role: bool = False
|
self, table: str, data: dict, filters: list[dict], use_service_role: bool = False
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Update rows matching filters."""
|
"""Update rows matching filters."""
|
||||||
params = {}
|
params = self._build_filter_params(filters)
|
||||||
for f in filters:
|
|
||||||
col = f.get("column")
|
|
||||||
op = f.get("operator", "eq")
|
|
||||||
val = f.get("value")
|
|
||||||
if col and val is not None:
|
|
||||||
params[col] = f"{op}.{val}"
|
|
||||||
|
|
||||||
headers = {"Prefer": "return=representation"}
|
headers = {"Prefer": "return=representation"}
|
||||||
|
|
||||||
return await self.request(
|
return await self.request(
|
||||||
@@ -281,14 +364,7 @@ class SupabaseClient:
|
|||||||
self, table: str, filters: list[dict], use_service_role: bool = False
|
self, table: str, filters: list[dict], use_service_role: bool = False
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Delete rows matching filters."""
|
"""Delete rows matching filters."""
|
||||||
params = {}
|
params = self._build_filter_params(filters)
|
||||||
for f in filters:
|
|
||||||
col = f.get("column")
|
|
||||||
op = f.get("operator", "eq")
|
|
||||||
val = f.get("value")
|
|
||||||
if col and val is not None:
|
|
||||||
params[col] = f"{op}.{val}"
|
|
||||||
|
|
||||||
headers = {"Prefer": "return=representation"}
|
headers = {"Prefer": "return=representation"}
|
||||||
|
|
||||||
return await self.request(
|
return await self.request(
|
||||||
@@ -313,94 +389,133 @@ class SupabaseClient:
|
|||||||
async def count_rows(
|
async def count_rows(
|
||||||
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
|
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Count rows in a table."""
|
"""Count rows in a table using HEAD + Content-Range header."""
|
||||||
params = {"select": "count"}
|
filter_params = self._build_filter_params(filters) if filters else {}
|
||||||
|
|
||||||
if filters:
|
response_headers = await self._head_request_headers(
|
||||||
for f in filters:
|
|
||||||
col = f.get("column")
|
|
||||||
op = f.get("operator", "eq")
|
|
||||||
val = f.get("value")
|
|
||||||
if col and val is not None:
|
|
||||||
params[col] = f"{op}.{val}"
|
|
||||||
|
|
||||||
headers = {"Accept": "application/json", "Prefer": "count=exact"}
|
|
||||||
|
|
||||||
result = await self.request(
|
|
||||||
"HEAD",
|
|
||||||
f"/rest/v1/{table}",
|
f"/rest/v1/{table}",
|
||||||
params=params,
|
params=filter_params or None,
|
||||||
headers_override=headers,
|
|
||||||
use_service_role=use_service_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Count is in Content-Range header for HEAD requests
|
|
||||||
# Fallback to query approach
|
|
||||||
result = await self.request(
|
|
||||||
"GET",
|
|
||||||
f"/rest/v1/{table}",
|
|
||||||
params={"select": "count", **{k: v for k, v in params.items() if k != "select"}},
|
|
||||||
headers_override={"Prefer": "count=exact"},
|
headers_override={"Prefer": "count=exact"},
|
||||||
use_service_role=use_service_role,
|
use_service_role=use_service_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(result, list) and len(result) > 0:
|
# PostgREST returns count in Content-Range: 0-N/TOTAL or */TOTAL
|
||||||
return result[0].get("count", 0)
|
content_range = response_headers.get("content-range", "")
|
||||||
|
if "/" in content_range:
|
||||||
|
try:
|
||||||
|
total = content_range.split("/")[-1]
|
||||||
|
if total != "*":
|
||||||
|
return int(total)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# POSTGRES-META (Admin)
|
# POSTGRES-META (Admin)
|
||||||
# =====================
|
# =====================
|
||||||
|
# All postgres-meta requests use self.meta_base_url (default: {base_url}/pg).
|
||||||
|
# Set META_URL env var for direct postgres-meta access (e.g., http://host:5555).
|
||||||
|
# Note: postgres-meta /columns, /policies, /triggers do NOT support filtering
|
||||||
|
# by table_name as a query param — we filter the results in Python instead.
|
||||||
|
|
||||||
async def list_tables(self, schema: str = "public") -> list[dict]:
|
async def list_tables(self, schema: str = "public") -> list[dict]:
|
||||||
"""List all tables in a schema."""
|
"""List all tables via postgres-meta."""
|
||||||
return await self.request(
|
return await self.request(
|
||||||
"GET", "/pg/tables", params={"include_system_schemas": "false"}, use_service_role=True
|
"GET",
|
||||||
|
"/tables",
|
||||||
|
params={"include_system_schemas": "false"},
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_table_schema(self, table: str, schema: str = "public") -> dict:
|
async def get_table_schema(self, table: str, schema: str = "public") -> dict:
|
||||||
"""Get table schema/columns."""
|
"""Get table schema/columns via postgres-meta, filtered by table+schema in Python."""
|
||||||
columns = await self.request(
|
all_columns = await self.request(
|
||||||
"GET", "/pg/columns", params={"table_name": table}, use_service_role=True
|
"GET",
|
||||||
|
"/columns",
|
||||||
|
params={"include_system_schemas": "false"},
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
)
|
)
|
||||||
|
# postgres-meta /columns does not support table_name filtering — filter here
|
||||||
|
columns = [
|
||||||
|
col
|
||||||
|
for col in (all_columns if isinstance(all_columns, list) else [])
|
||||||
|
if col.get("table") == table and col.get("schema") == schema
|
||||||
|
]
|
||||||
return {"table": table, "schema": schema, "columns": columns}
|
return {"table": table, "schema": schema, "columns": columns}
|
||||||
|
|
||||||
async def list_schemas(self) -> list[dict]:
|
async def list_schemas(self) -> list[dict]:
|
||||||
"""List all database schemas."""
|
"""List all database schemas via postgres-meta."""
|
||||||
return await self.request("GET", "/pg/schemas", use_service_role=True)
|
return await self.request(
|
||||||
|
"GET",
|
||||||
|
"/schemas",
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
async def list_extensions(self) -> list[dict]:
|
async def list_extensions(self) -> list[dict]:
|
||||||
"""List installed extensions."""
|
"""List installed extensions via postgres-meta."""
|
||||||
return await self.request("GET", "/pg/extensions", use_service_role=True)
|
return await self.request(
|
||||||
|
"GET",
|
||||||
|
"/extensions",
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
async def list_policies(self, table: str | None = None) -> list[dict]:
|
async def list_policies(self, table: str | None = None) -> list[dict]:
|
||||||
"""List RLS policies."""
|
"""List RLS policies via postgres-meta, optionally filtered by table name."""
|
||||||
params = {}
|
all_policies = await self.request(
|
||||||
if table:
|
"GET",
|
||||||
params["table_name"] = table
|
"/policies",
|
||||||
return await self.request("GET", "/pg/policies", params=params, use_service_role=True)
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
|
)
|
||||||
|
# postgres-meta /policies does not support table_name filtering — filter here
|
||||||
|
if table and isinstance(all_policies, list):
|
||||||
|
return [p for p in all_policies if p.get("table") == table]
|
||||||
|
return all_policies
|
||||||
|
|
||||||
async def list_roles(self) -> list[dict]:
|
async def list_roles(self) -> list[dict]:
|
||||||
"""List database roles."""
|
"""List database roles via postgres-meta."""
|
||||||
return await self.request("GET", "/pg/roles", use_service_role=True)
|
return await self.request(
|
||||||
|
"GET",
|
||||||
|
"/roles",
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
async def list_triggers(self, table: str | None = None) -> list[dict]:
|
async def list_triggers(self, table: str | None = None) -> list[dict]:
|
||||||
"""List triggers."""
|
"""List triggers via postgres-meta, optionally filtered by table name."""
|
||||||
params = {}
|
all_triggers = await self.request(
|
||||||
if table:
|
"GET",
|
||||||
params["table_name"] = table
|
"/triggers",
|
||||||
return await self.request("GET", "/pg/triggers", params=params, use_service_role=True)
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
|
)
|
||||||
|
# postgres-meta /triggers does not support table_name filtering — filter here
|
||||||
|
if table and isinstance(all_triggers, list):
|
||||||
|
return [t for t in all_triggers if t.get("table") == table]
|
||||||
|
return all_triggers
|
||||||
|
|
||||||
async def list_functions(self, schema: str = "public") -> list[dict]:
|
async def list_functions(self, schema: str = "public") -> list[dict]:
|
||||||
"""List database functions."""
|
"""List database functions via postgres-meta."""
|
||||||
return await self.request(
|
return await self.request(
|
||||||
"GET", "/pg/functions", params={"schema": schema}, use_service_role=True
|
"GET",
|
||||||
|
"/functions",
|
||||||
|
params={"schema": schema},
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute_sql(self, query: str) -> Any:
|
async def execute_sql(self, query: str) -> Any:
|
||||||
"""Execute raw SQL query."""
|
"""Execute raw SQL query via postgres-meta."""
|
||||||
return await self.request(
|
return await self.request(
|
||||||
"POST", "/pg/query", json_data={"query": query}, use_service_role=True
|
"POST",
|
||||||
|
"/query",
|
||||||
|
json_data={"query": query},
|
||||||
|
use_service_role=True,
|
||||||
|
base_url_override=self.meta_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
@@ -655,6 +770,11 @@ class SupabaseClient:
|
|||||||
await self.request("GET", "/rest/v1/", use_service_role=True)
|
await self.request("GET", "/rest/v1/", use_service_role=True)
|
||||||
results["services"]["postgrest"] = "ok"
|
results["services"]["postgrest"] = "ok"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# A 404 on the PostgREST root still confirms connectivity — some versions
|
||||||
|
# return 404 for the root endpoint while working normally for table queries.
|
||||||
|
if "status 404" in str(e).lower() or "404" in str(e):
|
||||||
|
results["services"]["postgrest"] = "ok"
|
||||||
|
else:
|
||||||
results["services"]["postgrest"] = f"error: {str(e)}"
|
results["services"]["postgrest"] = f"error: {str(e)}"
|
||||||
results["healthy"] = False
|
results["healthy"] = False
|
||||||
|
|
||||||
|
|||||||
@@ -468,11 +468,15 @@ async def query_table(
|
|||||||
use_service_role=use_service_role,
|
use_service_role=use_service_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
row_count = len(result) if isinstance(result, list) else 1
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"table": table,
|
"table": table,
|
||||||
"count": len(result) if isinstance(result, list) else 1,
|
# "returned" = rows in this page; use count_rows tool for total
|
||||||
|
"returned": row_count,
|
||||||
|
# True when returned == limit, meaning there are likely more rows
|
||||||
|
"has_more": row_count == limit,
|
||||||
"data": result,
|
"data": result,
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -500,16 +504,20 @@ async def insert_rows(
|
|||||||
use_service_role=use_service_role,
|
use_service_role=use_service_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
return json.dumps(
|
affected = len(result) if isinstance(result, list) else 1
|
||||||
{
|
response: dict = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"table": table,
|
"table": table,
|
||||||
"inserted": len(result) if isinstance(result, list) else 1,
|
"inserted" if not upsert else "affected": affected,
|
||||||
"data": result,
|
"data": result,
|
||||||
},
|
}
|
||||||
indent=2,
|
if upsert:
|
||||||
ensure_ascii=False,
|
response["operation"] = "upsert"
|
||||||
|
response["note"] = (
|
||||||
|
"PostgREST does not distinguish inserted vs updated rows in upsert mode. "
|
||||||
|
"'affected' = total rows processed (inserted + updated)."
|
||||||
)
|
)
|
||||||
|
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -827,7 +835,16 @@ async def execute_sql(client: SupabaseClient, query: str) -> str:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
error_str = str(e)
|
||||||
|
# Provide an actionable hint when postgres-meta is unreachable
|
||||||
|
if "status 404" in error_str or "status 502" in error_str or "Cannot connect" in error_str:
|
||||||
|
error_str = (
|
||||||
|
f"postgres-meta unreachable at {client.meta_base_url}. "
|
||||||
|
"If /pg/ is not exposed via Kong, set META_URL env var to the direct "
|
||||||
|
"postgres-meta container URL (e.g., http://supabase-meta:8080). "
|
||||||
|
f"Original: {error_str[:300]}"
|
||||||
|
)
|
||||||
|
return json.dumps({"success": False, "error": error_str}, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
async def get_table_indexes(client: SupabaseClient, table: str, schema: str = "public") -> str:
|
async def get_table_indexes(client: SupabaseClient, table: str, schema: str = "public") -> str:
|
||||||
|
|||||||
@@ -41,8 +41,13 @@ class SupabasePlugin(BasePlugin):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_required_config_keys() -> list[str]:
|
def get_required_config_keys() -> list[str]:
|
||||||
"""Return required configuration keys"""
|
"""Return required configuration keys.
|
||||||
return ["url", "anon_key", "service_role_key"]
|
|
||||||
|
anon_key is optional — if not provided, service_role_key is used for all
|
||||||
|
calls (including those that would normally use anon_key). This simplifies
|
||||||
|
configuration for admin/backend use cases where RLS enforcement is not needed.
|
||||||
|
"""
|
||||||
|
return ["url", "service_role_key"]
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||||
"""
|
"""
|
||||||
@@ -50,9 +55,10 @@ class SupabasePlugin(BasePlugin):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary containing:
|
config: Configuration dictionary containing:
|
||||||
- url: Supabase instance URL (Kong gateway)
|
- url: Supabase instance URL (Kong gateway or supabase.co)
|
||||||
- anon_key: Anonymous key (RLS protected)
|
- service_role_key: Admin API key (bypasses RLS). Required.
|
||||||
- service_role_key: Service role key (bypasses RLS)
|
- anon_key: Public API key (RLS protected). Optional.
|
||||||
|
- meta_url: Direct postgres-meta URL. Optional.
|
||||||
project_id: Optional project ID (auto-generated if not provided)
|
project_id: Optional project ID (auto-generated if not provided)
|
||||||
"""
|
"""
|
||||||
super().__init__(config, project_id=project_id)
|
super().__init__(config, project_id=project_id)
|
||||||
@@ -60,8 +66,9 @@ class SupabasePlugin(BasePlugin):
|
|||||||
# Create Supabase API client
|
# Create Supabase API client
|
||||||
self.client = SupabaseClient(
|
self.client = SupabaseClient(
|
||||||
base_url=config["url"],
|
base_url=config["url"],
|
||||||
anon_key=config["anon_key"],
|
anon_key=config.get("anon_key", ""),
|
||||||
service_role_key=config["service_role_key"],
|
service_role_key=config["service_role_key"],
|
||||||
|
meta_url=config.get("meta_url"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from core.dashboard.routes import (
|
|||||||
api_list_keys,
|
api_list_keys,
|
||||||
api_list_sites,
|
api_list_sites,
|
||||||
api_test_site,
|
api_test_site,
|
||||||
|
api_update_site,
|
||||||
# E.2: OAuth Social Login routes
|
# E.2: OAuth Social Login routes
|
||||||
auth_callback,
|
auth_callback,
|
||||||
auth_login_page,
|
auth_login_page,
|
||||||
@@ -107,6 +108,7 @@ from core.dashboard.routes import (
|
|||||||
dashboard_settings_page,
|
dashboard_settings_page,
|
||||||
# E.3: Site Management pages
|
# E.3: Site Management pages
|
||||||
dashboard_sites_add,
|
dashboard_sites_add,
|
||||||
|
dashboard_sites_edit,
|
||||||
dashboard_sites_list,
|
dashboard_sites_list,
|
||||||
# Bug C: User OAuth client routes
|
# Bug C: User OAuth client routes
|
||||||
dashboard_user_oauth_clients_create,
|
dashboard_user_oauth_clients_create,
|
||||||
@@ -4792,6 +4794,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
||||||
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
||||||
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
||||||
|
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
||||||
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
||||||
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
||||||
Route(
|
Route(
|
||||||
@@ -4860,6 +4863,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/api/sites", api_create_site, methods=["POST"]),
|
Route("/api/sites", api_create_site, methods=["POST"]),
|
||||||
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
|
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
|
||||||
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
|
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
|
||||||
|
Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
|
||||||
# User API Key routes (E.3)
|
# User API Key routes (E.3)
|
||||||
Route("/api/keys", api_list_keys, methods=["GET"]),
|
Route("/api/keys", api_list_keys, methods=["GET"]),
|
||||||
Route("/api/keys", api_create_key, methods=["POST"]),
|
Route("/api/keys", api_create_key, methods=["POST"]),
|
||||||
|
|||||||
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
=== SEO API Bridge ===
|
=== Airano MCP SEO Meta Bridge ===
|
||||||
Contributors: airano
|
Contributors: airano
|
||||||
Tags: seo, rest-api, rank-math, yoast, mcp
|
Tags: seo, rest-api, rank-math, yoast, mcp
|
||||||
Requires at least: 5.0
|
Requires at least: 5.0
|
||||||
@@ -12,7 +12,7 @@ Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use w
|
|||||||
|
|
||||||
== Description ==
|
== Description ==
|
||||||
|
|
||||||
SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
|
Airano MCP SEO Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
@@ -26,16 +26,16 @@ SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO me
|
|||||||
|
|
||||||
**REST API Endpoints:**
|
**REST API Endpoints:**
|
||||||
|
|
||||||
* `GET/POST /wp-json/seo-api-bridge/v1/posts/{id}/seo` — Post SEO data
|
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
|
||||||
* `GET/POST /wp-json/seo-api-bridge/v1/pages/{id}/seo` — Page SEO data
|
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
|
||||||
* `GET/POST /wp-json/seo-api-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||||
* `GET /wp-json/seo-api-bridge/v1/status` — Plugin status and SEO detection
|
* `GET /wp-json/airano-mcp-seo-bridge/v1/status` — Plugin status and SEO detection
|
||||||
|
|
||||||
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
|
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
|
||||||
|
|
||||||
== Installation ==
|
== Installation ==
|
||||||
|
|
||||||
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/`
|
1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
|
||||||
2. Activate the plugin through the 'Plugins' menu in WordPress
|
2. Activate the plugin through the 'Plugins' menu in WordPress
|
||||||
3. Ensure either Rank Math SEO or Yoast SEO is active
|
3. Ensure either Rank Math SEO or Yoast SEO is active
|
||||||
4. The REST API endpoints are now available
|
4. The REST API endpoints are now available
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: SEO API Bridge
|
* Plugin Name: Airano MCP SEO Meta Bridge
|
||||||
* Plugin URI: https://github.com/airano-ir/mcphub
|
* Plugin URI: https://github.com/airano-ir/mcphub
|
||||||
* Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations.
|
* Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations.
|
||||||
* Version: 1.3.0
|
* Version: 1.3.0
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
* Requires at least: 5.0
|
* Requires at least: 5.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Text Domain: seo-api-bridge
|
* Text Domain: airano-mcp-seo-bridge
|
||||||
*
|
*
|
||||||
* Changelog:
|
* Changelog:
|
||||||
* 1.3.0 - Added REST API endpoints for posts, pages, and products (GET/POST operations)
|
* 1.3.0 - Added REST API endpoints for posts, pages, and products (GET/POST operations)
|
||||||
@@ -24,7 +24,7 @@ if (!defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEO API Bridge Main Class
|
* Airano MCP SEO Meta Bridge Main Class
|
||||||
*/
|
*/
|
||||||
class SEO_API_Bridge {
|
class SEO_API_Bridge {
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ class SEO_API_Bridge {
|
|||||||
*/
|
*/
|
||||||
public function register_rest_routes() {
|
public function register_rest_routes() {
|
||||||
// Status endpoint
|
// Status endpoint
|
||||||
register_rest_route('seo-api-bridge/v1', '/status', [
|
register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'get_status'],
|
'callback' => [$this, 'get_status'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function() {
|
||||||
@@ -66,7 +66,7 @@ class SEO_API_Bridge {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Post SEO endpoints
|
// Post SEO endpoints
|
||||||
register_rest_route('seo-api-bridge/v1', '/posts/(?P<id>\d+)/seo', [
|
register_rest_route('airano-mcp-seo-bridge/v1', '/posts/(?P<id>\d+)/seo', [
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'get_post_seo'],
|
'callback' => [$this, 'get_post_seo'],
|
||||||
@@ -98,7 +98,7 @@ class SEO_API_Bridge {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Page SEO endpoints
|
// Page SEO endpoints
|
||||||
register_rest_route('seo-api-bridge/v1', '/pages/(?P<id>\d+)/seo', [
|
register_rest_route('airano-mcp-seo-bridge/v1', '/pages/(?P<id>\d+)/seo', [
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'get_page_seo'],
|
'callback' => [$this, 'get_page_seo'],
|
||||||
@@ -130,7 +130,7 @@ class SEO_API_Bridge {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Product SEO endpoints (WooCommerce)
|
// Product SEO endpoints (WooCommerce)
|
||||||
register_rest_route('seo-api-bridge/v1', '/products/(?P<id>\d+)/seo', [
|
register_rest_route('airano-mcp-seo-bridge/v1', '/products/(?P<id>\d+)/seo', [
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'get_product_seo'],
|
'callback' => [$this, 'get_product_seo'],
|
||||||
@@ -170,7 +170,7 @@ class SEO_API_Bridge {
|
|||||||
$yoast_active = $this->is_yoast_active();
|
$yoast_active = $this->is_yoast_active();
|
||||||
|
|
||||||
$response = [
|
$response = [
|
||||||
'plugin' => 'SEO API Bridge',
|
'plugin' => 'Airano MCP SEO Meta Bridge',
|
||||||
'version' => self::VERSION,
|
'version' => self::VERSION,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'seo_plugins' => [
|
'seo_plugins' => [
|
||||||
@@ -202,7 +202,7 @@ class SEO_API_Bridge {
|
|||||||
if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
|
if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
|
||||||
if ($yoast_active) $active_plugins[] = 'Yoast SEO';
|
if ($yoast_active) $active_plugins[] = 'Yoast SEO';
|
||||||
|
|
||||||
return 'SEO API Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
|
return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,8 +438,12 @@ class SEO_API_Bridge {
|
|||||||
private function register_fields($fields) {
|
private function register_fields($fields) {
|
||||||
foreach ($this->supported_post_types as $post_type) {
|
foreach ($this->supported_post_types as $post_type) {
|
||||||
foreach ($fields as $meta_key => $args) {
|
foreach ($fields as $meta_key => $args) {
|
||||||
|
$show_in_rest = $args['type'] === 'array'
|
||||||
|
? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
|
||||||
|
: true;
|
||||||
|
|
||||||
register_post_meta($post_type, $meta_key, [
|
register_post_meta($post_type, $meta_key, [
|
||||||
'show_in_rest' => true,
|
'show_in_rest' => $show_in_rest,
|
||||||
'single' => $args['single'],
|
'single' => $args['single'],
|
||||||
'type' => $args['type'],
|
'type' => $args['type'],
|
||||||
'description' => $args['description'],
|
'description' => $args['description'],
|
||||||
@@ -686,7 +690,7 @@ class SEO_API_Bridge {
|
|||||||
|
|
||||||
if (!$rank_math_active && !$yoast_active) {
|
if (!$rank_math_active && !$yoast_active) {
|
||||||
echo '<div class="notice notice-warning is-dismissible">';
|
echo '<div class="notice notice-warning is-dismissible">';
|
||||||
echo '<p><strong>SEO API Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'seo-api-bridge' ) . '</p>';
|
echo '<p><strong>Airano MCP SEO Meta Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
} else {
|
} else {
|
||||||
$active_plugins = [];
|
$active_plugins = [];
|
||||||
@@ -696,11 +700,11 @@ class SEO_API_Bridge {
|
|||||||
$supported_types = implode(', ', $this->supported_post_types);
|
$supported_types = implode(', ', $this->supported_post_types);
|
||||||
|
|
||||||
echo '<div class="notice notice-success is-dismissible">';
|
echo '<div class="notice notice-success is-dismissible">';
|
||||||
echo '<p><strong>SEO API Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
|
echo '<p><strong>Airano MCP SEO Meta Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
|
||||||
echo '<p><strong>' . esc_html__( 'Supported post types:', 'seo-api-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
echo '<p><strong>' . esc_html__( 'Supported post types:', 'airano-mcp-seo-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
||||||
|
|
||||||
if ($woocommerce_active) {
|
if ($woocommerce_active) {
|
||||||
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'seo-api-bridge' ) . '</p>';
|
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
|
||||||
}
|
}
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
}
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user