From c0d55f906116a41f785300bf82e0c839e3117364 Mon Sep 17 00:00:00 2001 From: airano Date: Wed, 11 Mar 2026 03:21:28 +0330 Subject: [PATCH] =?UTF-8?q?sync:=20v3.2.0=20=E2=80=94=20Supabase=20fixes,?= =?UTF-8?q?=20site=20edit,=20WP=20plugin=20rename,=20black=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + core/dashboard/routes.py | 86 +++++ core/database.py | 24 ++ core/site_api.py | 121 ++++++- core/templates/dashboard/sites/edit.html | 134 ++++++++ core/templates/dashboard/sites/list.html | 4 + env.example | 23 +- plugins/supabase/client.py | 306 ++++++++++++------ plugins/supabase/handlers/database.py | 41 ++- plugins/supabase/plugin.py | 19 +- server.py | 4 + wordpress-plugin/airano-mcp-seo-bridge.zip | Bin 0 -> 9338 bytes .../README.md | 0 .../readme.txt | 14 +- .../seo-api-bridge.php | 32 +- wordpress-plugin/seo-api-bridge.zip | Bin 8789 -> 0 bytes 16 files changed, 669 insertions(+), 140 deletions(-) create mode 100644 core/templates/dashboard/sites/edit.html create mode 100644 wordpress-plugin/airano-mcp-seo-bridge.zip rename wordpress-plugin/{seo-api-bridge => airano-mcp-seo-bridge}/README.md (100%) rename wordpress-plugin/{seo-api-bridge => airano-mcp-seo-bridge}/readme.txt (76%) rename wordpress-plugin/{seo-api-bridge => airano-mcp-seo-bridge}/seo-api-bridge.php (94%) delete mode 100644 wordpress-plugin/seo-api-bridge.zip diff --git a/.gitignore b/.gitignore index c85e206..c7aacf4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ ENV/ htmlcov/ .tox/ pytest-out.txt +scripts/community-build/BUILD_REPORT.md .nox/ coverage.xml *.cover diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index b25a994..0a0da53 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -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) diff --git a/core/database.py b/core/database.py index 78c1bc9..8c28088 100644 --- a/core/database.py +++ b/core/database.py @@ -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. diff --git a/core/site_api.py b/core/site_api.py index 2b536b6..b7eb3d1 100644 --- a/core/site_api.py +++ b/core/site_api.py @@ -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-: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. diff --git a/core/templates/dashboard/sites/edit.html b/core/templates/dashboard/sites/edit.html new file mode 100644 index 0000000..48bc9cd --- /dev/null +++ b/core/templates/dashboard/sites/edit.html @@ -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 %} +
+
+ + + + + +

{{ t.edit_site }}: {{ site.alias }}

+
+ + +
+ {{ t.keep_existing }} +
+ + + + +
+ + +
+ +
+ {{ plugin_names.get(site.plugin_type, site.plugin_type) }} +
+
+ + +
+ + +
+ + +
+ +
+ {{ site.alias }} +
+
+ + +
+ + {% set fields = plugin_fields.get(site.plugin_type, []) %} + {% for field in fields %} +
+ + + {% if field.hint %} +

{{ field.hint }}

+ {% endif %} +
+ {% endfor %} +
+ + +
+ + + {{ t.cancel }} + +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/core/templates/dashboard/sites/list.html b/core/templates/dashboard/sites/list.html index daa8cd0..417a0eb 100644 --- a/core/templates/dashboard/sites/list.html +++ b/core/templates/dashboard/sites/list.html @@ -73,6 +73,10 @@ id="test-btn-{{ site.id }}"> {{ t.test_connection }} + + {{ t.edit }} +