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:
airano
2026-03-11 03:21:28 +03:30
parent a806671e2d
commit c0d55f9061
16 changed files with 669 additions and 140 deletions

View File

@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
"no_sites": "No sites added yet",
"add_first_site": "Add your first site to get started",
"site_added": "Site added successfully",
"site_updated": "Site updated successfully",
"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_failed": "Connection failed",
"credentials": "Credentials",
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
"no_sites": "هنوز سایتی اضافه نشده",
"add_first_site": "اولین سایت خود را اضافه کنید",
"site_added": "سایت با موفقیت اضافه شد",
"site_updated": "سایت با موفقیت بروزرسانی شد",
"site_deleted": "سایت حذف شد",
"edit_site": "ویرایش سایت",
"updating_site": "در حال بروزرسانی...",
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
"connection_ok": "اتصال برقرار",
"connection_failed": "اتصال ناموفق",
"credentials": "مشخصات دسترسی",
@@ -2811,6 +2819,82 @@ async def api_test_site(request: Request) -> Response:
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:
"""POST /api/keys — Create a new user API key."""
user_session, redirect = _require_user_session(request)
@@ -3090,6 +3174,7 @@ def register_dashboard_routes(mcp):
# Site Management routes (E.3)
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/{id}/edit", methods=["GET"])(dashboard_sites_edit)
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
# 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/{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=["PATCH"])(api_update_site)
# User API Key routes (E.3)
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)

View File

@@ -530,6 +530,30 @@ class Database:
(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:
"""Get a site by user ID and alias.

View File

@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Service Role Key",
"type": "password",
"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",
"label": "Anon Key",
"label": "Anon Key (Optional)",
"type": "password",
"required": True,
"hint": "Supabase Dashboard → Settings → API → anon key",
"required": False,
"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": [
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
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]:
"""Test connectivity to an existing site.

View 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 %}

View File

@@ -73,6 +73,10 @@
id="test-btn-{{ site.id }}">
{{ t.test_connection }}
</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 }}')"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
{{ t.delete }}