Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
934 lines
32 KiB
Python
934 lines
32 KiB
Python
"""Site management logic for the Live Platform (Track E.3).
|
|
|
|
Provides site CRUD operations, connection validation, and credential
|
|
field definitions for all 9 plugin types. Coordinates between the
|
|
database, encryption, and plugin health check layers.
|
|
|
|
Usage:
|
|
from core.site_api import create_user_site, get_user_sites, validate_site_connection
|
|
|
|
ok, msg = await validate_site_connection("wordpress", "https://example.com", creds)
|
|
site = await create_user_site(user_id, "wordpress", "myblog", url, creds)
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Fallback used when the settings DB is unavailable at import time
|
|
_MAX_SITES_FALLBACK = int(os.getenv("MAX_SITES_PER_USER", "10"))
|
|
|
|
# Plugin credential field definitions — drives the dynamic "Add Site" form
|
|
# and server-side validation. Each field has:
|
|
# name: form input name (matches credential JSON key)
|
|
# label: display label
|
|
# type: "text" or "password"
|
|
# required: whether the field is mandatory
|
|
PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
|
"wordpress": [
|
|
{
|
|
"name": "username",
|
|
"label": "Username",
|
|
"type": "text",
|
|
"required": True,
|
|
"hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).",
|
|
},
|
|
{
|
|
"name": "app_password",
|
|
"label": "Application Password",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": (
|
|
"WordPress Admin → Users → Profile → Application Passwords. "
|
|
"This IS the API credential — no separate API key is needed. "
|
|
"Paste the value WP shows once after creation (spaces can stay or be removed)."
|
|
),
|
|
},
|
|
],
|
|
"woocommerce": [
|
|
{
|
|
"name": "consumer_key",
|
|
"label": "Consumer Key",
|
|
"type": "text",
|
|
"required": True,
|
|
"hint": (
|
|
"WooCommerce → Settings → Advanced → REST API → Add Key. "
|
|
"Read/Write permission. This pair IS the API auth — no extra API key field exists."
|
|
),
|
|
},
|
|
{
|
|
"name": "consumer_secret",
|
|
"label": "Consumer Secret",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": (
|
|
"Shown once when the REST API key is created. "
|
|
"Starts with ``cs_``. Save it immediately — WooCommerce will not display it again."
|
|
),
|
|
},
|
|
# F.X.fix-pass4 — optional WP Application Password, only used by
|
|
# media tools (upload_and_attach_to_product / attach_media_to_
|
|
# product / set_featured_image). WooCommerce's Consumer Key +
|
|
# Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``;
|
|
# WordPress core REST media uploads require an Application
|
|
# Password from the WP admin user. Leave blank if the store
|
|
# never needs MCP-driven image uploads.
|
|
{
|
|
"name": "wp_username",
|
|
"label": "WordPress Username (for media tools)",
|
|
"type": "text",
|
|
"required": False,
|
|
"advanced": True,
|
|
"hint": (
|
|
"Only required if you want the AI / media tools "
|
|
"(upload_and_attach_to_product, attach_media_to_product, "
|
|
"set_featured_image, generate_and_upload_image with attach_to_post). "
|
|
"Other WC tools work with Consumer Key / Secret alone."
|
|
),
|
|
},
|
|
{
|
|
"name": "wp_app_password",
|
|
"label": "WordPress Application Password (for media tools)",
|
|
"type": "password",
|
|
"required": False,
|
|
"advanced": True,
|
|
"hint": (
|
|
"WP Admin → Users → Profile → Application Passwords. "
|
|
"WC media uploads hit /wp/v2/media which only accepts "
|
|
"Application Passwords (not Consumer Key / Secret). "
|
|
"Leave empty if you don't use MCP for image uploads."
|
|
),
|
|
},
|
|
],
|
|
# F.19.1 WordPress Specialist — companion-backed, no Docker socket.
|
|
# Same auth shape as the basic wordpress plugin (Application Password)
|
|
# but the WP user behind the password must have ``manage_options``,
|
|
# AND Airano MCP Bridge v2.11.0+ must be active on the WP site for
|
|
# the admin namespace to exist.
|
|
"wordpress_specialist": [
|
|
{
|
|
"name": "username",
|
|
"label": "Username",
|
|
"type": "text",
|
|
"required": True,
|
|
"hint": "WordPress admin username (the user that owns the Application Password).",
|
|
},
|
|
{
|
|
"name": "app_password",
|
|
"label": "Application Password",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": (
|
|
"WordPress Admin → Users → Profile → Application Passwords. "
|
|
"The user MUST have manage_options. Requires Airano MCP "
|
|
"Bridge v2.11.0+ on the WP side — install from "
|
|
"wordpress-plugin/airano-mcp-bridge.zip in the repo."
|
|
),
|
|
},
|
|
],
|
|
"gitea": [
|
|
{
|
|
"name": "token",
|
|
"label": "Access Token",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "Gitea → Settings → Applications → Generate Token",
|
|
},
|
|
],
|
|
"n8n": [
|
|
{
|
|
"name": "api_key",
|
|
"label": "API Key",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "n8n → Settings → API → Create API Key",
|
|
},
|
|
],
|
|
"supabase": [
|
|
{
|
|
"name": "service_role_key",
|
|
"label": "Service Role Key",
|
|
"type": "password",
|
|
"required": True,
|
|
"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 (Optional)",
|
|
"type": "password",
|
|
"required": False,
|
|
"advanced": True,
|
|
"hint": (
|
|
"Supabase Dashboard → Settings → API → anon key. "
|
|
"Optional — if omitted, service_role_key is used for all calls. "
|
|
"Only useful for testing RLS policies as a regular user."
|
|
),
|
|
},
|
|
{
|
|
"name": "meta_url",
|
|
"label": "postgres-meta URL (Optional)",
|
|
"type": "text",
|
|
"required": False,
|
|
"advanced": True,
|
|
"hint": (
|
|
"Only needed if your Supabase setup does not expose /pg/ through Kong. "
|
|
"Most self-hosted installs (including Coolify) work without this. "
|
|
"Example: http://supabase-meta:8080 or https://your-meta.example.com"
|
|
),
|
|
},
|
|
{
|
|
"name": "meta_auth",
|
|
"label": "postgres-meta Auth (Optional)",
|
|
"type": "password",
|
|
"required": False,
|
|
"advanced": True,
|
|
"hint": (
|
|
"Basic Auth for postgres-meta (format: username:password). "
|
|
"Only needed when postgres-meta is exposed via a public URL."
|
|
),
|
|
},
|
|
],
|
|
"openpanel": [
|
|
{
|
|
"name": "client_id",
|
|
"label": "Client ID",
|
|
"type": "text",
|
|
"required": True,
|
|
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
|
|
},
|
|
{
|
|
"name": "client_secret",
|
|
"label": "Client Secret",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "Generated with your Client ID",
|
|
},
|
|
{
|
|
"name": "project_id",
|
|
"label": "Project ID",
|
|
"type": "text",
|
|
"required": False,
|
|
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools",
|
|
},
|
|
{
|
|
"name": "organization_id",
|
|
"label": "Organization ID",
|
|
"type": "text",
|
|
"required": False,
|
|
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/",
|
|
"advanced": True,
|
|
},
|
|
],
|
|
"appwrite": [
|
|
{
|
|
"name": "project_id",
|
|
"label": "Project ID",
|
|
"type": "text",
|
|
"required": True,
|
|
"hint": "Appwrite Console → Project Settings → Project ID",
|
|
},
|
|
{
|
|
"name": "api_key",
|
|
"label": "API Key",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "Appwrite Console → Project Settings → API Keys → Create",
|
|
},
|
|
],
|
|
"directus": [
|
|
{
|
|
"name": "token",
|
|
"label": "Static Token",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "Directus → Settings → User → Static Token",
|
|
},
|
|
],
|
|
"coolify": [
|
|
{
|
|
"name": "token",
|
|
"label": "API Token",
|
|
"type": "password",
|
|
"required": True,
|
|
"hint": "Coolify → Keys & Tokens → API tokens → Create",
|
|
},
|
|
],
|
|
}
|
|
|
|
# Plugin display names for UI
|
|
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
|
|
"wordpress": "WordPress",
|
|
"woocommerce": "WooCommerce",
|
|
"wordpress_specialist": "WordPress Specialist",
|
|
"gitea": "Gitea",
|
|
"n8n": "n8n",
|
|
"supabase": "Supabase",
|
|
"openpanel": "OpenPanel",
|
|
"appwrite": "Appwrite",
|
|
"directus": "Directus",
|
|
"coolify": "Coolify",
|
|
}
|
|
|
|
# Health check endpoints per plugin type
|
|
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
|
|
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
|
|
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
|
|
# F.19.1: hits the companion's lightest admin route. Returns 404 if
|
|
# Airano MCP Bridge v2.11.0+ is missing, 403 if the user lacks
|
|
# ``manage_options``, 200 with a small JSON body otherwise. This
|
|
# makes "Test Connection" surface the actual end-to-end requirement
|
|
# instead of just "auth works" (which /users/me would).
|
|
"wordpress_specialist": {"path": "/wp-json/airano-mcp/v1/admin/maintenance", "method": "GET"},
|
|
"gitea": {"path": "/api/v1/user", "method": "GET"},
|
|
"n8n": {"path": "/healthz", "method": "GET"},
|
|
"supabase": {"path": "/rest/v1/", "method": "GET"},
|
|
"openpanel": {"path": "/healthcheck", "method": "GET"},
|
|
"appwrite": {"path": "/v1/health", "method": "GET"},
|
|
"directus": {"path": "/server/health", "method": "GET"},
|
|
"coolify": {"path": "/api/v1/version", "method": "GET"},
|
|
}
|
|
|
|
|
|
def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
|
|
"""Get credential field definitions for a plugin type.
|
|
|
|
Args:
|
|
plugin_type: Plugin type name.
|
|
|
|
Returns:
|
|
List of field definition dicts.
|
|
|
|
Raises:
|
|
ValueError: If plugin_type is unknown.
|
|
"""
|
|
fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type)
|
|
if fields is None:
|
|
raise ValueError(
|
|
f"Unknown plugin type '{plugin_type}'. "
|
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
|
)
|
|
return fields
|
|
|
|
|
|
def get_user_credential_fields(is_admin: bool = False) -> dict[str, list[dict[str, Any]]]:
|
|
"""Get credential fields for the dashboard's Add/Edit Site forms.
|
|
|
|
Admin users see every registered plugin so they can use admin-only
|
|
plugins (wordpress_specialist) without having to add them to
|
|
``ENABLED_PLUGINS`` for everyone. Public users get only the plugins
|
|
enabled via ``ENABLED_PLUGINS`` env var (Track F.1).
|
|
|
|
F.19.1 (2026-05-01) — surfaced when an admin OAuth user added a
|
|
wordpress_specialist site and the manage page rendered an empty
|
|
credential form on revisit because the unfiltered map was being
|
|
filtered for public visibility. The site row exists but the
|
|
template can't render its fields.
|
|
|
|
Args:
|
|
is_admin: When True, return every plugin's credential fields.
|
|
Defaults to False for backward compatibility.
|
|
|
|
Returns:
|
|
Dict of plugin_type -> field definitions.
|
|
"""
|
|
if is_admin:
|
|
return dict(PLUGIN_CREDENTIAL_FIELDS)
|
|
|
|
from core.plugin_visibility import get_public_plugin_types
|
|
|
|
public = get_public_plugin_types()
|
|
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
|
|
|
|
|
|
def get_user_plugin_names(is_admin: bool = False) -> dict[str, str]:
|
|
"""Get plugin display names for the dashboard's plugin pickers.
|
|
|
|
Admin users see every registered plugin (so the Add Site dropdown
|
|
surfaces ``wordpress_specialist`` etc.); public users get only the
|
|
plugins enabled via ``ENABLED_PLUGINS`` env var.
|
|
|
|
Args:
|
|
is_admin: When True, return every plugin's display name.
|
|
Defaults to False for backward compatibility.
|
|
|
|
Returns:
|
|
Dict of plugin_type -> display name.
|
|
"""
|
|
if is_admin:
|
|
return dict(PLUGIN_DISPLAY_NAMES)
|
|
|
|
from core.plugin_visibility import get_public_plugin_types
|
|
|
|
public = get_public_plugin_types()
|
|
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public}
|
|
|
|
|
|
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:
|
|
"""Validate that all required credential fields are present and non-empty.
|
|
|
|
Args:
|
|
plugin_type: Plugin type name.
|
|
credentials: Dict of credential key→value.
|
|
|
|
Returns:
|
|
Tuple of (is_valid, list_of_error_messages).
|
|
"""
|
|
fields = get_credential_fields(plugin_type)
|
|
errors: list[str] = []
|
|
|
|
for field in fields:
|
|
if field["required"]:
|
|
value = credentials.get(field["name"], "").strip()
|
|
if not value:
|
|
errors.append(f"'{field['label']}' is required")
|
|
|
|
return (len(errors) == 0, errors)
|
|
|
|
|
|
async def validate_site_connection(
|
|
plugin_type: str, url: str, credentials: dict[str, str]
|
|
) -> tuple[bool, str]:
|
|
"""Test connectivity to a site using HTTP health check.
|
|
|
|
Args:
|
|
plugin_type: Plugin type name.
|
|
url: Site URL.
|
|
credentials: Plaintext credential dict.
|
|
|
|
Returns:
|
|
Tuple of (success, message). Message is "OK" on success or
|
|
a human-readable error description.
|
|
"""
|
|
endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type)
|
|
if endpoint_info is None:
|
|
return False, f"Unknown plugin type '{plugin_type}'"
|
|
|
|
check_url = url.rstrip("/") + endpoint_info["path"]
|
|
method = endpoint_info["method"]
|
|
|
|
# Build auth headers per plugin type
|
|
headers: dict[str, str] = {}
|
|
if plugin_type in ("wordpress", "wordpress_specialist"):
|
|
import base64
|
|
|
|
username = credentials.get("username", "")
|
|
app_password = credentials.get("app_password", "")
|
|
token = base64.b64encode(f"{username}:{app_password}".encode()).decode()
|
|
headers["Authorization"] = f"Basic {token}"
|
|
elif plugin_type == "woocommerce":
|
|
import base64
|
|
|
|
ck = credentials.get("consumer_key", "")
|
|
cs = credentials.get("consumer_secret", "")
|
|
token = base64.b64encode(f"{ck}:{cs}".encode()).decode()
|
|
headers["Authorization"] = f"Basic {token}"
|
|
elif plugin_type == "gitea":
|
|
headers["Authorization"] = f"token {credentials.get('token', '')}"
|
|
elif plugin_type == "n8n":
|
|
headers["X-N8N-API-KEY"] = credentials.get("api_key", "")
|
|
elif plugin_type == "supabase":
|
|
headers["apikey"] = credentials.get("service_role_key", "")
|
|
headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}"
|
|
elif plugin_type == "appwrite":
|
|
headers["X-Appwrite-Project"] = credentials.get("project_id", "")
|
|
headers["X-Appwrite-Key"] = credentials.get("api_key", "")
|
|
elif plugin_type in ("directus", "coolify"):
|
|
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
|
|
elif plugin_type == "openpanel":
|
|
headers["openpanel-client-id"] = credentials.get("client_id", "")
|
|
headers["openpanel-client-secret"] = credentials.get("client_secret", "")
|
|
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=15)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
if method == "POST":
|
|
async with session.post(check_url, headers=headers) as resp:
|
|
status_code = resp.status
|
|
resp_text = await resp.text()
|
|
else:
|
|
async with session.get(check_url, headers=headers) as resp:
|
|
status_code = resp.status
|
|
resp_text = await resp.text()
|
|
|
|
if status_code < 400:
|
|
return True, "OK"
|
|
elif status_code == 401:
|
|
return False, "Authentication failed — check credentials"
|
|
elif status_code == 403:
|
|
return False, "Access forbidden — check permissions or API may be disabled"
|
|
elif status_code == 404:
|
|
return False, f"Endpoint not found at {check_url} — check URL"
|
|
else:
|
|
return False, f"HTTP {status_code}: {resp_text[:200]}"
|
|
|
|
except aiohttp.ClientConnectorError:
|
|
return False, "Connection failed — check URL and ensure the site is reachable"
|
|
except TimeoutError:
|
|
return False, "Connection timed out (15s) — site may be slow or unreachable"
|
|
except aiohttp.InvalidURL:
|
|
return False, "Invalid URL protocol — use https:// or http://"
|
|
except Exception as e:
|
|
return False, f"Connection error: {type(e).__name__}: {e}"
|
|
|
|
|
|
async def create_user_site(
|
|
user_id: str,
|
|
plugin_type: str,
|
|
alias: str,
|
|
url: str,
|
|
credentials: dict[str, str],
|
|
skip_validation: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Create a new site for a user.
|
|
|
|
Validates credentials, tests the connection, encrypts credentials,
|
|
and stores in the database.
|
|
|
|
Args:
|
|
user_id: Owner's UUID.
|
|
plugin_type: Plugin type name.
|
|
alias: User-chosen friendly name.
|
|
url: Site URL.
|
|
credentials: Plaintext credential dict.
|
|
skip_validation: If True, skip connection test (for testing).
|
|
|
|
Returns:
|
|
The created site dict (without decrypted credentials).
|
|
|
|
Raises:
|
|
ValueError: On validation errors (bad plugin type, missing fields,
|
|
alias taken, site limit reached, connection failed).
|
|
"""
|
|
from core.database import get_database
|
|
from core.encryption import get_credential_encryption
|
|
|
|
# Validate plugin type
|
|
if plugin_type not in PLUGIN_CREDENTIAL_FIELDS:
|
|
raise ValueError(
|
|
f"Unknown plugin type '{plugin_type}'. "
|
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
|
)
|
|
|
|
# Validate alias format
|
|
alias = alias.strip().lower()
|
|
if not alias or len(alias) < 2 or len(alias) > 50:
|
|
raise ValueError("Alias must be 2-50 characters")
|
|
if not alias.replace("-", "").replace("_", "").isalnum():
|
|
raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores")
|
|
|
|
# Validate required credential fields
|
|
valid, errors = validate_credentials(plugin_type, credentials)
|
|
if not valid:
|
|
raise ValueError(f"Missing credentials: {', '.join(errors)}")
|
|
|
|
db = get_database()
|
|
|
|
# Check site limit (reads DB > ENV > default so dashboard/settings changes take effect)
|
|
from core.settings import get_cached_max_sites
|
|
|
|
max_sites = get_cached_max_sites()
|
|
count = await db.count_sites_by_user(user_id)
|
|
if count >= max_sites:
|
|
raise ValueError(f"Site limit reached ({max_sites} sites per user)")
|
|
|
|
# Check alias uniqueness (DB constraint will also catch this)
|
|
existing = await db.get_site_by_alias(user_id, alias)
|
|
if existing is not None:
|
|
raise ValueError(f"Alias '{alias}' is already in use")
|
|
|
|
# Test connection
|
|
status = "active"
|
|
status_msg = "Connection verified"
|
|
if not skip_validation:
|
|
ok, msg = await validate_site_connection(plugin_type, url, credentials)
|
|
if not ok:
|
|
raise ValueError(f"Connection test failed: {msg}")
|
|
|
|
# Encrypt credentials
|
|
encryptor = get_credential_encryption()
|
|
# We need the site_id for encryption, but we don't have it yet.
|
|
# Use a pre-generated UUID as the site_id.
|
|
import uuid
|
|
|
|
site_id = str(uuid.uuid4())
|
|
encrypted = encryptor.encrypt_credentials(credentials, site_id)
|
|
|
|
# Store in database — we bypass db.create_site() to use our pre-generated ID
|
|
from core.database import _utc_now
|
|
|
|
now = _utc_now()
|
|
await db.execute(
|
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now),
|
|
)
|
|
|
|
# Return the created site (without credentials blob)
|
|
site = await db.get_site(site_id, user_id)
|
|
if site is None:
|
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
|
|
|
result = dict(site)
|
|
result.pop("credentials", None)
|
|
logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id)
|
|
return result
|
|
|
|
|
|
async def get_user_sites(user_id: str) -> list[dict[str, Any]]:
|
|
"""Get all sites for a user (without credentials).
|
|
|
|
Args:
|
|
user_id: Owner's UUID.
|
|
|
|
Returns:
|
|
List of site dicts.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
sites = await db.get_sites_by_user(user_id)
|
|
# Strip credentials blob from response
|
|
return [{k: v for k, v in site.items() if k != "credentials"} for site in sites]
|
|
|
|
|
|
async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None:
|
|
"""Get a single site (without credentials).
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
user_id: Owner's UUID.
|
|
|
|
Returns:
|
|
Site dict or None.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
site = await db.get_site(site_id, user_id)
|
|
if site is None:
|
|
return None
|
|
result = dict(site)
|
|
result.pop("credentials", None)
|
|
return result
|
|
|
|
|
|
async def delete_user_site(site_id: str, user_id: str) -> bool:
|
|
"""Delete a user's site.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
user_id: Owner's UUID.
|
|
|
|
Returns:
|
|
True if deleted, False if not found.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
deleted = await db.delete_site(site_id, user_id)
|
|
if deleted:
|
|
logger.info("Deleted site %s for user %s", site_id, user_id)
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Only WordPress / WooCommerce sites can host AI image provider keys — the
|
|
# only consumer is ``wordpress_generate_and_upload_image``, which uploads into
|
|
# a WordPress media library. Other plugin types never surface the section.
|
|
PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"})
|
|
|
|
# Providers supported for per-site keys. Must stay in sync with
|
|
# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to
|
|
# drive the UI).
|
|
SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter")
|
|
|
|
|
|
def site_provider_scope(site_id: str, provider: str) -> str:
|
|
"""Return the HKDF scope used for per-site provider-key encryption.
|
|
|
|
Format: ``site_provider:{site_id}:{provider}`` — distinct from the
|
|
per-site credentials scope (bare ``site_id``) so the same master key
|
|
never produces the same derived key for both credential types.
|
|
"""
|
|
return f"site_provider:{site_id}:{provider}"
|
|
|
|
|
|
def site_supports_provider_keys(plugin_type: str) -> bool:
|
|
"""Return True if the plugin type can hold AI provider keys."""
|
|
return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
|
|
|
|
|
|
async def set_site_provider_key(
|
|
site_id: str,
|
|
user_id: str,
|
|
provider: str,
|
|
api_key: str,
|
|
) -> dict[str, Any]:
|
|
"""Encrypt and store an AI provider API key for a user-owned site.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
user_id: Owner's UUID (enforces tenant isolation).
|
|
provider: One of :data:`SITE_PROVIDERS`.
|
|
api_key: The plaintext API key to store.
|
|
|
|
Returns:
|
|
The stored row (without plaintext).
|
|
|
|
Raises:
|
|
ValueError: If the site is not found, the plugin type does not
|
|
support provider keys, the provider is unknown, or the key
|
|
is empty.
|
|
"""
|
|
from core.database import get_database
|
|
from core.encryption import get_credential_encryption
|
|
|
|
if provider not in SITE_PROVIDERS:
|
|
raise ValueError(
|
|
f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}"
|
|
)
|
|
if not api_key or not api_key.strip():
|
|
raise ValueError("API key must not be empty")
|
|
|
|
db = get_database()
|
|
site = await db.get_site(site_id, user_id)
|
|
if site is None:
|
|
raise ValueError("Site not found")
|
|
|
|
if not site_supports_provider_keys(site["plugin_type"]):
|
|
raise ValueError(
|
|
f"Plugin type '{site['plugin_type']}' does not support AI "
|
|
f"provider keys (only WordPress/WooCommerce)."
|
|
)
|
|
|
|
encryptor = get_credential_encryption()
|
|
ciphertext = encryptor.encrypt_for_scope(
|
|
api_key.strip(), site_provider_scope(site_id, provider)
|
|
)
|
|
|
|
row = await db.upsert_site_provider_key(site_id, provider, ciphertext)
|
|
logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id)
|
|
return row
|
|
|
|
|
|
async def get_site_provider_key(
|
|
site_id: str,
|
|
provider: str,
|
|
) -> str | None:
|
|
"""Return the decrypted AI provider key for a site, or None.
|
|
|
|
Caller is responsible for verifying site ownership — this helper is
|
|
also called from the AI tool handler which already trusts the
|
|
resolved site_id.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
provider: Provider identifier.
|
|
|
|
Returns:
|
|
Decrypted API key string, or None if no key is stored.
|
|
"""
|
|
from core.database import get_database
|
|
from core.encryption import get_credential_encryption
|
|
|
|
if provider not in SITE_PROVIDERS:
|
|
return None
|
|
|
|
db = get_database()
|
|
row = await db.get_site_provider_key(site_id, provider)
|
|
if row is None:
|
|
return None
|
|
|
|
encryptor = get_credential_encryption()
|
|
try:
|
|
return encryptor.decrypt_for_scope(
|
|
row["key_ciphertext"], site_provider_scope(site_id, provider)
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to decrypt site provider key (site=%s provider=%s)",
|
|
site_id,
|
|
provider,
|
|
)
|
|
return None
|
|
|
|
|
|
async def list_site_providers_set(site_id: str) -> set[str]:
|
|
"""Return the set of providers that have a key stored for the site."""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
rows = await db.list_site_provider_keys(site_id)
|
|
return {row["provider"] for row in rows}
|
|
|
|
|
|
async def delete_site_provider_key(
|
|
site_id: str,
|
|
user_id: str,
|
|
provider: str,
|
|
) -> bool:
|
|
"""Remove an AI provider key for a site. Returns True if a row was deleted.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
user_id: Owner's UUID — enforces tenant isolation before deletion.
|
|
provider: Provider identifier.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
site = await db.get_site(site_id, user_id)
|
|
if site is None:
|
|
return False
|
|
|
|
deleted = await db.delete_site_provider_key(site_id, provider)
|
|
if deleted:
|
|
logger.info(
|
|
"Deleted %s provider key for site %s (user %s)",
|
|
provider,
|
|
site_id,
|
|
user_id,
|
|
)
|
|
return deleted
|
|
|
|
|
|
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
|
"""Test connectivity to an existing site.
|
|
|
|
Decrypts credentials from the database, runs the health check,
|
|
and updates the site status.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
user_id: Owner's UUID.
|
|
|
|
Returns:
|
|
Tuple of (success, message).
|
|
|
|
Raises:
|
|
ValueError: If site not found.
|
|
"""
|
|
from core.database import get_database
|
|
from core.encryption import get_credential_encryption
|
|
|
|
db = get_database()
|
|
site = await db.get_site(site_id, user_id)
|
|
if site is None:
|
|
raise ValueError("Site not found")
|
|
|
|
encryptor = get_credential_encryption()
|
|
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
|
|
|
|
ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials)
|
|
|
|
# Update status
|
|
new_status = "active" if ok else "error"
|
|
await db.update_site_status(site_id, new_status, msg, user_id=user_id)
|
|
|
|
return ok, msg
|