From 3815f5a6d56bd2f9779cf6128ab86d288ca3112f Mon Sep 17 00:00:00 2001 From: airano-ir Date: Thu, 2 Apr 2026 00:24:33 +0200 Subject: [PATCH] chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs - Remove env-based site discovery from SiteManager and ProjectManager - Unify admin and user OAuth Clients into single role-based page - Update all documentation to reflect DB-based site management - Clean up env.example, README, ARCHITECTURE docs - Improve Projects page empty state with link to My Sites - Remove obsolete env discovery tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 +- CLAUDE.md | 29 +-- README.md | 71 ++----- core/api_keys.py | 4 +- core/dashboard/routes.py | 206 +++++++++----------- core/project_manager.py | 132 +------------ core/site_manager.py | 186 +----------------- core/templates/dashboard/base.html | 6 +- core/templates/dashboard/connect.html | 4 +- core/templates/dashboard/projects/list.html | 8 + docker-compose.coolify.yaml | 12 +- docs/getting-started.md | 17 +- env.example | 117 +---------- plugins/openpanel/handlers/export.py | 4 +- plugins/openpanel/handlers/utils.py | 2 +- plugins/supabase/handlers/auth.py | 2 + plugins/wordpress/client.py | 7 +- plugins/wordpress/wp_cli.py | 69 ++----- plugins/wordpress_advanced/plugin.py | 2 +- server.py | 34 +--- server_multi.py | 1 - tests/test_admin_system.py | 1 + tests/test_site_manager.py | 65 +----- 23 files changed, 202 insertions(+), 782 deletions(-) diff --git a/.env.example b/.env.example index 5436967..c5e52ba 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,12 @@ # Version: 3.0.0 # Last Updated: 2026-02-17 # -# This file contains all environment variables needed to run the MCP server. +# This file contains environment variables needed to run the MCP server. # Copy this file to .env and fill in your actual values. # +# SITES: Sites are managed via the web dashboard (DB-based), not env vars. +# The plugin sections below are kept as credential reference only. +# # SECURITY NOTE: Never commit .env file to version control! # # Multi-Endpoint Architecture (v3.0.0): diff --git a/CLAUDE.md b/CLAUDE.md index b7cd90f..1051ef5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,26 +125,17 @@ plugins/{name}/ ### Tool Generation -Tools are dynamically generated at startup: -1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`) -2. `ToolGenerator` creates unified tools with a `site` parameter injected -3. Tools are registered in `ToolRegistry` and exposed via FastMCP +Tools are dynamically generated at startup from plugin specifications: +1. `ToolGenerator` creates unified tools with a `site` parameter injected +2. Tools are registered in `ToolRegistry` and exposed via FastMCP Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias. -### Site Configuration via Environment Variables +### Site Configuration -Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}` - -```bash -WORDPRESS_SITE1_URL=https://example.com -WORDPRESS_SITE1_USERNAME=admin -WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx -WORDPRESS_SITE1_ALIAS=myblog # optional friendly name -WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI -``` - -Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup. +Sites are managed via the web dashboard and stored in SQLite (DB-based). +User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`. +`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation. ### Key Core Modules @@ -152,7 +143,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a |--------|---------| | `core/auth.py` | Master API key validation, request authentication | | `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) | -| `core/site_manager.py` | Type-safe site config discovery from env vars | +| `core/site_manager.py` | Type-safe site config registration and lookup | | `core/tool_registry.py` | Central tool definitions and lookup | | `core/tool_generator.py` | Dynamic unified tool creation with site injection | | `core/health.py` | Health monitoring, metrics, alerts | @@ -182,7 +173,7 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. ### Legacy Modules (Deprecated) -`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead. +`core/project_manager.py` (retained for HealthMonitor compatibility), `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead. ## Commit Style @@ -211,7 +202,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled. - OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login -- User sites stored in SQLite (`core/database.py`), admin sites still from env vars +- All sites stored in SQLite (`core/database.py`), managed via web dashboard - Dashboard templates live in `core/templates/` (included in pip package as `package_data`) - `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format) - The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows) diff --git a/README.md b/README.md index 44b4658..93762c6 100644 --- a/README.md +++ b/README.md @@ -125,28 +125,15 @@ Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mc ### Configure Your Sites -Add site credentials to `.env`: +Sites are managed via the **web dashboard** — no environment variables needed. + +1. Set `MASTER_API_KEY` in your `.env` file +2. Start the server and open the dashboard +3. Add sites with their credentials (URL, username, password/token) ```bash -# Master API Key (recommended — auto-generates temp key if omitted) +# .env — only system configuration needed MASTER_API_KEY=your-secure-key-here - -# WordPress Site -WORDPRESS_SITE1_URL=https://myblog.com -WORDPRESS_SITE1_USERNAME=admin -WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx -WORDPRESS_SITE1_ALIAS=myblog - -# WooCommerce Store -WOOCOMMERCE_STORE1_URL=https://mystore.com -WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx -WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx -WOOCOMMERCE_STORE1_ALIAS=mystore - -# Gitea Instance -GITEA_REPO1_URL=https://git.example.com -GITEA_REPO1_TOKEN=your_gitea_token -GITEA_REPO1_ALIAS=mygitea ```
@@ -158,45 +145,25 @@ GITEA_REPO1_ALIAS=mygitea |----------|----------|---------|-------------| | `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access | | `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | +| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption | | `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) | | `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) | -| `OAUTH_JWT_ALGORITHM` | No | `HS256` | JWT algorithm | -| `OAUTH_ACCESS_TOKEN_TTL` | No | `3600` | Access token TTL in seconds | -| `OAUTH_REFRESH_TOKEN_TTL` | No | `604800` | Refresh token TTL in seconds | -| `OAUTH_STORAGE_TYPE` | No | `json` | Token storage type | -| `OAUTH_STORAGE_PATH` | No | `/app/data` | Data directory path | > **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth. -**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}` +**Plugin Credential Reference** — when adding sites via dashboard, you'll need: -| Plugin | Required Keys | Optional Keys | -|--------|--------------|---------------| -| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` | -| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` | -| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` | -| `GITEA` | `URL`, `TOKEN` | `ALIAS` | -| `N8N` | `URL`, `API_KEY` | `ALIAS` | -| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` | -| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` | -| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` | -| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` | - -> **CONTAINER**: Docker container name of your WordPress site. Optional for WordPress (enables WP-CLI tools like cache flush, transient management). **Required** for WordPress Advanced (all 22 tools use WP-CLI). Find your container: `docker ps --filter name=wordpress`. Also requires Docker socket mount. - -**Example** — Multiple WordPress sites: - -```bash -WORDPRESS_BLOG_URL=https://blog.example.com -WORDPRESS_BLOG_USERNAME=admin -WORDPRESS_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx -WORDPRESS_BLOG_ALIAS=blog - -WORDPRESS_SHOP_URL=https://shop.example.com -WORDPRESS_SHOP_USERNAME=admin -WORDPRESS_SHOP_APP_PASSWORD=yyyy yyyy yyyy yyyy -WORDPRESS_SHOP_ALIAS=shop -``` +| Plugin | Required Credentials | Notes | +|--------|---------------------|-------| +| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) | +| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API | +| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) | +| Gitea | URL, Token | Settings → Applications → Personal Access Token | +| n8n | URL, API Key | Settings → API → Create API Key | +| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API | +| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings | +| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys | +| Directus | URL, Static Token | Directus Admin → Settings |
diff --git a/core/api_keys.py b/core/api_keys.py index 62abd6a..24c04f9 100644 --- a/core/api_keys.py +++ b/core/api_keys.py @@ -10,12 +10,12 @@ import json import logging import os import secrets + +import bcrypt from dataclasses import asdict, dataclass from datetime import datetime, timedelta from pathlib import Path -import bcrypt - logger = logging.getLogger(__name__) diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index 1c2ece9..d3450c1 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -850,12 +850,9 @@ async def get_all_projects( is_admin = False current_user_id = None if user_session: - if ( - hasattr(user_session, "user_type") - and user_session.user_type == "master" - or isinstance(user_session, dict) - and user_session.get("role") == "admin" - ): + if hasattr(user_session, "user_type") and user_session.user_type == "master": + is_admin = True + elif isinstance(user_session, dict) and user_session.get("role") == "admin": is_admin = True elif isinstance(user_session, dict) and "user_id" in user_session: current_user_id = user_session["user_id"] @@ -1512,6 +1509,7 @@ async def get_oauth_clients_data() -> dict: "grant_types": client.grant_types, "allowed_scopes": client.allowed_scopes, "created_at": client.created_at.isoformat() if client.created_at else "", + "owner_user_id": getattr(client, "owner_user_id", None), } ) @@ -1528,10 +1526,26 @@ async def get_oauth_clients_data() -> dict: async def dashboard_oauth_clients_list(request: Request) -> Response: - """Render OAuth clients list page (admin only).""" - session, redirect = _require_admin_session(request) - if redirect: - return redirect + """Render OAuth clients list page (admin and user).""" + # Try admin session first, then user session + auth = get_dashboard_auth() + session = None + is_admin = False + user_id = None + + admin_session = auth.get_session_from_request(request) + if admin_session and is_admin_session(admin_session): + session = admin_session + is_admin = True + else: + user_session = auth.get_user_session_from_request(request) + if user_session: + session = user_session + is_admin = is_admin_session(user_session) + user_id = user_session.get("user_id") + + if not session: + return RedirectResponse(url="/auth/login", status_code=303) # Get language accept_language = request.headers.get("accept-language") @@ -1539,8 +1553,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response: lang = detect_language(accept_language, query_lang) t = get_translations(lang) - # Get clients data + # Get clients data — admin sees all, user sees own clients_data = await get_oauth_clients_data() + if not is_admin and user_id: + clients_data["clients"] = [ + c for c in clients_data["clients"] if c.get("owner_user_id") == user_id + ] + clients_data["total_count"] = len(clients_data["clients"]) return templates.TemplateResponse( request, @@ -1552,15 +1571,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response: "clients": clients_data["clients"], "total_count": clients_data["total_count"], "current_page": "oauth_clients", + "is_admin": is_admin, }, ) async def dashboard_oauth_clients_create(request: Request) -> Response: - """API endpoint to create OAuth client (admin only).""" - session, redirect = _require_admin_session(request) - if redirect: - return JSONResponse({"error": "Admin access required"}, status_code=403) + """API endpoint to create OAuth client (admin and user).""" + # Accept both admin and user sessions + auth = get_dashboard_auth() + owner_user_id = None + + admin_session = auth.get_session_from_request(request) + user_session = auth.get_user_session_from_request(request) + + if admin_session and is_admin_session(admin_session): + pass # Admin — no owner_user_id + elif user_session: + owner_user_id = user_session.get("user_id") + else: + return JSONResponse({"error": "Authentication required"}, status_code=403) try: data = await request.json() @@ -1569,7 +1599,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response: redirect_uris = data.get("redirect_uris") or [] if not redirect_uris and data.get("redirect_uri"): redirect_uris = [data.get("redirect_uri")] - scopes = data.get("scopes", ["read"]) + scopes = data.get("scopes", ["read", "write", "admin"]) if not client_name or not redirect_uris: return JSONResponse({"error": "Missing required fields"}, status_code=400) @@ -1578,16 +1608,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response: client_registry = get_client_registry() - client_id, client_secret = client_registry.create_client( - client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes - ) + create_kwargs = { + "client_name": client_name, + "redirect_uris": redirect_uris, + "allowed_scopes": scopes, + } + if owner_user_id: + create_kwargs["owner_user_id"] = owner_user_id + + client_id, client_secret = client_registry.create_client(**create_kwargs) # Log the action from core.audit_log import get_audit_logger audit_logger = get_audit_logger() audit_logger.log_system_event( - event=f"OAuth client created: {client_name}", details={"client_id": client_id} + event=f"OAuth client created: {client_name}", + details={"client_id": client_id, "owner_user_id": owner_user_id}, ) return JSONResponse( @@ -1604,10 +1641,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response: async def dashboard_oauth_clients_delete(request: Request) -> Response: - """API endpoint to delete OAuth client (admin only).""" - session, redirect = _require_admin_session(request) - if redirect: - return JSONResponse({"error": "Admin access required"}, status_code=403) + """API endpoint to delete OAuth client (admin and user).""" + # Accept both admin and user sessions + auth = get_dashboard_auth() + is_admin_user = False + user_id = None + + admin_session = auth.get_session_from_request(request) + user_session = auth.get_user_session_from_request(request) + + if admin_session and is_admin_session(admin_session): + is_admin_user = True + elif user_session: + user_id = user_session.get("user_id") + is_admin_user = is_admin_session(user_session) + else: + return JSONResponse({"error": "Authentication required"}, status_code=403) try: client_id = request.path_params.get("client_id", "") @@ -1616,6 +1665,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response: client_registry = get_client_registry() + # Non-admin users can only delete their own clients + if not is_admin_user and user_id: + client = client_registry.get_client(client_id) + if not client: + return JSONResponse({"error": "Client not found"}, status_code=404) + if getattr(client, "owner_user_id", None) != user_id: + return JSONResponse({"error": "Access denied"}, status_code=403) + success = client_registry.delete_client(client_id) if success: @@ -3131,107 +3188,18 @@ async def api_get_config(request: Request) -> Response: async def dashboard_user_oauth_clients_list(request: Request) -> Response: - """GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients.""" - user_session, redirect = _require_user_session(request) - if redirect: - return redirect - - 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) - - from core.oauth import get_client_registry as _get_client_registry - - registry = _get_client_registry() - user_id = user_session["user_id"] - user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id] - - return templates.TemplateResponse( - request, - "dashboard/user-oauth-clients.html", - { - "lang": lang, - "t": t, - "session": user_session, - "clients": user_clients, - "current_page": "connect", - }, - ) + """GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page.""" + return RedirectResponse(url="/dashboard/oauth-clients", status_code=303) async def dashboard_user_oauth_clients_create(request: Request) -> Response: - """POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user.""" - user_session, redirect = _require_user_session(request) - if redirect: - return JSONResponse({"error": "Unauthorized"}, status_code=401) - - try: - body = await request.json() - except Exception: - return JSONResponse({"error": "Invalid JSON"}, status_code=400) - - client_name = body.get("client_name", "").strip() - redirect_uris_raw = body.get("redirect_uris", "") - scopes = body.get("scopes", ["read", "write", "admin"]) - - if not client_name: - return JSONResponse({"error": "Client name required"}, status_code=400) - - if isinstance(redirect_uris_raw, str): - redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()] - else: - redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()] - - if not redirect_uris: - return JSONResponse({"error": "At least one redirect URI required"}, status_code=400) - - scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes - - from core.oauth import get_client_registry as _get_client_registry - - registry = _get_client_registry() - client_id, client_secret = registry.create_client( - client_name=client_name, - redirect_uris=redirect_uris, - allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes, - owner_user_id=user_session["user_id"], - ) - client = registry.get_client(client_id) - - return JSONResponse( - { - "client_id": client_id, - "client_secret": client_secret, - "client_name": client.client_name, - "redirect_uris": client.redirect_uris, - "scope": client.scope, - } - ) + """POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint.""" + return await dashboard_oauth_clients_create(request) async def dashboard_user_oauth_clients_delete(request: Request) -> Response: - """DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client.""" - user_session, redirect = _require_user_session(request) - if redirect: - return JSONResponse({"error": "Unauthorized"}, status_code=401) - - client_id = request.path_params.get("client_id", "") - - from core.oauth import get_client_registry as _get_client_registry - - registry = _get_client_registry() - client = registry.get_client(client_id) - - if not client: - return JSONResponse({"error": "Client not found"}, status_code=404) - - # Only allow deleting own clients - if client.owner_user_id != user_session["user_id"]: - return JSONResponse({"error": "Access denied"}, status_code=403) - - registry.delete_client(client_id) - return JSONResponse({"success": True}) + """DELETE /api/dashboard/user-oauth-clients/{client_id} — Forwards to unified delete endpoint.""" + return await dashboard_oauth_clients_delete(request) async def get_service_page_data(plugin_type: str) -> dict | None: diff --git a/core/project_manager.py b/core/project_manager.py index c7c39de..8d08695 100644 --- a/core/project_manager.py +++ b/core/project_manager.py @@ -1,33 +1,23 @@ """ -Project Manager +Project Manager (Legacy) -Discovers and manages project instances from environment variables. -Handles plugin lifecycle and tool registration. +Retained for backward compatibility with HealthMonitor. +Sites are now managed via the web dashboard (DB-based). """ import logging -import os -import re from typing import Any -from plugins import BasePlugin, registry +from plugins import BasePlugin logger = logging.getLogger(__name__) class ProjectManager: """ - Manage multiple project instances. + Legacy project manager — retained for HealthMonitor compatibility. - Projects are discovered from environment variables: - - {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY} - - Example: - WORDPRESS_SITE1_URL=https://example.com - WORDPRESS_SITE1_USERNAME=admin - WORDPRESS_SITE1_APP_PASSWORD=xxxx - WORDPRESS_SITE2_URL=https://other.com - ... + Sites are now managed via the web dashboard and stored in SQLite. """ def __init__(self): @@ -35,115 +25,6 @@ class ProjectManager: self.projects: dict[str, BasePlugin] = {} self.logger = logging.getLogger("ProjectManager") - def discover_projects(self) -> None: - """ - Discover projects from environment variables. - - Scans environment for project configurations and creates - plugin instances. - """ - self.logger.info("Starting project discovery...") - - # Get all registered plugin types - plugin_types = registry.get_registered_types() - - for plugin_type in plugin_types: - self._discover_plugin_type(plugin_type) - - self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.") - - def _discover_plugin_type(self, plugin_type: str) -> None: - """ - Discover all projects of a specific plugin type. - - Args: - plugin_type: Type of plugin (e.g., 'wordpress') - """ - prefix = plugin_type.upper() + "_" - - # Build list of longer prefixes from other plugin types to avoid collisions. - # e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars. - plugin_types = registry.get_registered_types() - longer_prefixes = [ - pt.upper() + "_" - for pt in plugin_types - if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_") - ] - - # Find all project IDs for this plugin type - project_ids = set() - env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$") - - for env_key in os.environ.keys(): - # Skip env vars that belong to a more specific plugin type - if any(env_key.startswith(lp) for lp in longer_prefixes): - continue - - match = env_pattern.match(env_key) - if match: - project_id = match.group(1).lower() - project_ids.add(project_id) - - # Create plugin instance for each project - for project_id in project_ids: - try: - config = self._load_project_config(plugin_type, project_id) - if config: - self._create_project_instance(plugin_type, project_id, config) - except Exception as e: - self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}") - - def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None: - """ - Load configuration for a project from environment. - - Args: - plugin_type: Plugin type - project_id: Project ID - - Returns: - Dict with configuration or None if incomplete - """ - prefix = f"{plugin_type.upper()}_{project_id.upper()}_" - config = {} - - # Collect all config keys for this project - for env_key, env_value in os.environ.items(): - if env_key.startswith(prefix): - # Extract config key (everything after prefix) - config_key = env_key[len(prefix) :].lower() - config[config_key] = env_value - - if not config: - return None - - self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}") - return config - - def _create_project_instance( - self, plugin_type: str, project_id: str, config: dict[str, Any] - ) -> None: - """ - Create a plugin instance for a project. - - Args: - plugin_type: Plugin type - project_id: Project ID - config: Project configuration - """ - try: - # Create plugin instance - plugin = registry.create_instance(plugin_type, project_id, config) - - # Store with full identifier - full_id = f"{plugin_type}_{project_id}" - self.projects[full_id] = plugin - - self.logger.info(f"Created project: {full_id}") - - except Exception as e: - raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}") - def get_project(self, full_id: str) -> BasePlugin | None: """ Get a project plugin instance. @@ -255,5 +136,4 @@ def get_project_manager() -> ProjectManager: global _project_manager if _project_manager is None: _project_manager = ProjectManager() - _project_manager.discover_projects() return _project_manager diff --git a/core/site_manager.py b/core/site_manager.py index 4502d78..d4b5340 100644 --- a/core/site_manager.py +++ b/core/site_manager.py @@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management Manages site configurations with Pydantic validation. Part of Option B clean architecture refactoring. -Discovers sites from environment variables: -- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY} -- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional) - -Example: - WORDPRESS_SITE1_URL=https://example.com - WORDPRESS_SITE1_USERNAME=admin - WORDPRESS_SITE1_APP_PASSWORD=xxxx - WORDPRESS_SITE2_ALIAS=myblog +Sites are managed via the web dashboard and stored in SQLite (DB-based). +The SiteManager provides registration and lookup infrastructure for +plugin tool generation and endpoint routing. """ import logging -import os -import re from typing import Any from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator @@ -111,7 +103,8 @@ class SiteManager: """ Manage site configurations with type safety. - Discovers, validates, and provides access to site configurations. + Provides registration and lookup of site configurations. + Sites are registered programmatically (e.g., from database) via register_site(). Attributes: sites: Dictionary mapping plugin_type to site configurations @@ -120,7 +113,7 @@ class SiteManager: Examples: >>> manager = SiteManager() - >>> manager.discover_sites(['wordpress', 'gitea']) + >>> manager.register_site(config) >>> config = manager.get_site_config('wordpress', 'myblog') >>> sites = manager.list_sites('wordpress') """ @@ -136,167 +129,6 @@ class SiteManager: self.logger = logging.getLogger("SiteManager") self.logger.info("SiteManager initialized") - def discover_sites(self, plugin_types: list[str]) -> int: - """ - Discover sites from environment variables. - - Scans environment for site configurations and registers them. - - Args: - plugin_types: List of plugin types to discover (e.g., ['wordpress']) - - Returns: - Number of sites discovered - - Examples: - >>> count = manager.discover_sites(['wordpress', 'gitea']) - >>> print(f"Discovered {count} sites") - """ - self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}") - - total_discovered = 0 - for plugin_type in plugin_types: - count = self._discover_plugin_sites(plugin_type) - total_discovered += count - - self.logger.info( - f"Discovery complete. Found {total_discovered} sites " - f"across {len(plugin_types)} plugin types." - ) - - return total_discovered - - # Reserved words that should NOT be interpreted as site IDs - RESERVED_SITE_WORDS = { - "limit", - "rate", - "config", - "debug", - "log", - "level", - "mode", - "timeout", - "retry", - "max", - "min", - "default", - "global", - "enabled", - "disabled", - "host", - "port", - "path", - "key", - "secret", - "token", - "advanced", - "basic", - "simple", - "pro", - "premium", - "standard", - } - - def _discover_plugin_sites(self, plugin_type: str) -> int: - """ - Discover all sites for a specific plugin type. - - Args: - plugin_type: Type of plugin (e.g., 'wordpress') - - Returns: - Number of sites discovered for this plugin type - - Examples: - >>> count = manager._discover_plugin_sites('wordpress') - """ - prefix = plugin_type.upper() + "_" - - # Build list of longer prefixes from other plugin types to avoid collisions. - # e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars. - from plugins import registry as plugin_registry - - all_plugin_types = plugin_registry.get_registered_types() - longer_prefixes = [ - pt.upper() + "_" - for pt in all_plugin_types - if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_") - ] - - # Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc. - env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$") - - # Find all unique site IDs - site_ids = set() - for env_key in os.environ.keys(): - # Skip env vars that belong to a more specific plugin type - if any(env_key.startswith(lp) for lp in longer_prefixes): - continue - - match = env_pattern.match(env_key) - if match: - site_id = match.group(1).lower() - # Skip reserved words that are not real site IDs - if site_id not in self.RESERVED_SITE_WORDS: - site_ids.add(site_id) - - # Load configuration for each site - discovered_count = 0 - for site_id in site_ids: - try: - config = self._load_site_config(plugin_type, site_id) - if config: - self.register_site(config) - discovered_count += 1 - except Exception as e: - self.logger.error( - f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True - ) - - return discovered_count - - def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None: - """ - Load configuration for a site from environment. - - Args: - plugin_type: Plugin type - site_id: Site ID - - Returns: - SiteConfig if successful, None if incomplete - - Examples: - >>> config = manager._load_site_config('wordpress', 'site1') - """ - prefix = f"{plugin_type.upper()}_{site_id.upper()}_" - config_data = {"site_id": site_id, "plugin_type": plugin_type} - - # Collect all config keys for this site - for env_key, env_value in os.environ.items(): - if env_key.startswith(prefix): - # Extract config key (everything after prefix) - config_key = env_key[len(prefix) :].lower() - config_data[config_key] = env_value - - # Must have at least some configuration beyond site_id and plugin_type - if len(config_data) <= 2: - return None - - try: - # Create and validate SiteConfig - config = SiteConfig(**config_data) - - self.logger.debug( - f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}" - ) - - return config - - except Exception as e: - self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True) - return None - def register_site(self, config: SiteConfig) -> None: """ Register a site configuration. @@ -356,7 +188,7 @@ class SiteManager: # SECURITY: Don't reveal available plugin types in multi-tenant environment raise ValueError( f"No sites configured for plugin type: {plugin_type}. " - f"Please check your environment variables." + f"Please add a site via the dashboard." ) # Try direct lookup @@ -373,7 +205,7 @@ class SiteManager: ) raise ValueError( f"Site '{site}' not configured for {plugin_type}. " - f"Please verify the site alias/ID and check environment variables." + f"Please verify the site alias/ID in the dashboard." ) def list_sites(self, plugin_type: str) -> list[str]: @@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager: Examples: >>> manager = get_site_manager() - >>> manager.discover_sites(['wordpress']) + >>> manager.register_site(config) """ global _site_manager if _site_manager is None: diff --git a/core/templates/dashboard/base.html b/core/templates/dashboard/base.html index f15da31..469e2cd 100644 --- a/core/templates/dashboard/base.html +++ b/core/templates/dashboard/base.html @@ -138,6 +138,9 @@ ('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'), ('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'), + ('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 + 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 + 0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'), ] %} {# ── Admin-only nav items ── #} @@ -146,9 +149,6 @@ '/dashboard/projects'), ('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'), - ('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 - 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 - 0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'), ('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', '/dashboard/audit-logs'), diff --git a/core/templates/dashboard/connect.html b/core/templates/dashboard/connect.html index a752d48..82b2313 100644 --- a/core/templates/dashboard/connect.html +++ b/core/templates/dashboard/connect.html @@ -159,10 +159,10 @@

{% if lang == 'fa' %} نکته: فقط آدرس URL کافی است. هنگام اتصال می‌توانید با API Key یا GitHub/Google احراز هویت کنید. - ساخت OAuth Client اختیاری است. + ساخت OAuth Client اختیاری است. {% else %} Tip: You only need the URL above. When connecting, you can authenticate with an API Key or GitHub/Google. - Creating an OAuth Client is optional. + Creating an OAuth Client is optional. {% endif %}

diff --git a/core/templates/dashboard/projects/list.html b/core/templates/dashboard/projects/list.html index b500d6c..7b01d0c 100644 --- a/core/templates/dashboard/projects/list.html +++ b/core/templates/dashboard/projects/list.html @@ -228,6 +228,14 @@

{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}

+ {% else %} +

+ {% if lang == 'fa' %} + سایت‌ها از طریق صفحه سایت‌ها مدیریت می‌شوند. + {% else %} + Sites are managed via the My Sites page. + {% endif %} +

{% endif %} diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml index 69c617a..f67d180 100644 --- a/docker-compose.coolify.yaml +++ b/docker-compose.coolify.yaml @@ -50,15 +50,9 @@ services: - USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30} - USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500} - # === ADMIN-MANAGED SITES (optional) === - # Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access. - # Users can add their own sites via the dashboard without these. - # Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY} - # - # - WORDPRESS_SITE1_URL=${WORDPRESS_SITE1_URL:-} - # - WORDPRESS_SITE1_USERNAME=${WORDPRESS_SITE1_USERNAME:-} - # - WORDPRESS_SITE1_APP_PASSWORD=${WORDPRESS_SITE1_APP_PASSWORD:-} - # - WORDPRESS_SITE1_ALIAS=${WORDPRESS_SITE1_ALIAS:-} + # === SITES === + # Sites are managed via the web dashboard (DB-based). + # After deployment, open the dashboard to add sites. healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"] diff --git a/docs/getting-started.md b/docs/getting-started.md index 41f6a94..b24f959 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -248,12 +248,11 @@ Without Docker socket: - WordPress Advanced database/system tools are unavailable - All REST API tools (bulk operations, content management) work normally -### Environment Variable Reference +### Site Configuration -All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value` +Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites. -- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`) -- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc. +Each plugin requires specific credentials (see below for reference). #### WordPress ```env @@ -392,7 +391,7 @@ docker compose logs -f mcphub | Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` | | Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors | | Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` | -| Sites not showing up | Restart after adding new env vars: `docker compose restart` | +| Sites not showing up | Add sites via the web dashboard, then check the connect page | --- @@ -610,18 +609,16 @@ docker compose up --build -d ### Step 3: Configure Environment Variables -Add all required environment variables in Coolify's environment variable UI: +Add the required environment variables in Coolify's environment variable UI: ``` MASTER_API_KEY=your-secure-key-here OAUTH_JWT_SECRET_KEY=your-jwt-secret OAUTH_BASE_URL=https://your-domain.com -WORDPRESS_SITE1_URL=https://example.com -WORDPRESS_SITE1_USERNAME=admin -WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx +ENCRYPTION_KEY=your-base64-encryption-key ``` -The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup. +After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`. ### Step 4: Configure Health Check diff --git a/env.example b/env.example index 8e6f9c2..6fa7d63 100644 --- a/env.example +++ b/env.example @@ -2,7 +2,7 @@ # MCP Hub — Environment Configuration # =================================== # -# MINIMUM TO START: Just set MASTER_API_KEY and one site below. +# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard. # Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md # # After editing, run: @@ -49,117 +49,14 @@ DASHBOARD_SESSION_SECRET= # DISABLE_MASTER_KEY_LOGIN=false # ============================================ -# WORDPRESS SITES +# SITES # ============================================ -# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY} -# Add as many sites as you want (SITE1, SITE2, SITE3, ...) +# Sites are managed via the web dashboard (DB-based). +# After starting the server, open the dashboard to add sites: +# http://localhost:8000/dashboard # -# How to create a WordPress Application Password: -# 1. Log into WordPress admin → Users → Your Profile -# 2. Scroll to "Application Passwords" section -# 3. Enter name "MCP Hub" → click "Add New" -# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx) - -WORDPRESS_SITE1_URL=https://your-site.com -WORDPRESS_SITE1_USERNAME=admin -WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx -WORDPRESS_SITE1_ALIAS=mysite - -# Optional: Docker container name for WP-CLI tools (cache flush, transients, etc.) -# Find your container name with: docker ps --filter name=wordpress -# This lets MCP Hub run wp-cli commands inside your WordPress container. -# WORDPRESS_SITE1_CONTAINER=wordpress-container-name - -# Second site example (uncomment to use): -# WORDPRESS_SITE2_URL=https://another-site.com -# WORDPRESS_SITE2_USERNAME=admin -# WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy -# WORDPRESS_SITE2_ALIAS=blog - -# ============================================ -# WORDPRESS ADVANCED (database, bulk, system) -# ============================================ -# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY} -# Provides database operations, bulk operations, and system management. -# REQUIRES Docker socket AND container name — all tools use WP-CLI. -# -# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED. -# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com -# WORDPRESS_ADVANCED_SITE1_USERNAME=admin -# WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx -# WORDPRESS_ADVANCED_SITE1_CONTAINER=wordpress-container-name -# WORDPRESS_ADVANCED_SITE1_ALIAS=mysite-advanced - -# ============================================ -# WOOCOMMERCE STORES -# ============================================ -# Pattern: WOOCOMMERCE_{STORE_ID}_{CONFIG_KEY} -# WooCommerce is a separate plugin — uses Consumer Key/Secret, not App Passwords. -# -# Create keys: WooCommerce → Settings → Advanced → REST API → Add Key - -# WOOCOMMERCE_STORE1_URL=https://your-store.com -# WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx -# WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx -# WOOCOMMERCE_STORE1_ALIAS=mystore - -# ============================================ -# OTHER PLUGINS (uncomment to use) -# ============================================ -# All plugins auto-discover their environment variables at startup. -# Pattern: {PLUGIN_TYPE}_{INSTANCE_ID}_{CONFIG_KEY} - -# --- Gitea --- -# GITEA_REPO1_URL=https://git.example.com -# GITEA_REPO1_TOKEN=your_gitea_token -# GITEA_REPO1_ALIAS=mygitea - -# --- n8n --- -# N8N_INSTANCE1_URL=https://n8n.example.com -# N8N_INSTANCE1_API_KEY=your_n8n_api_key -# N8N_INSTANCE1_ALIAS=myn8n - -# --- Supabase (Self-Hosted or Cloud) --- -# Works with both self-hosted Supabase and supabase.com cloud projects. -# For cloud: URL is https://xxxx.supabase.co -# 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 -# -# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong: -# SUPABASE_PROJECT1_META_URL=http://localhost:5555 -# -# Optional: Basic Auth for postgres-meta when exposed via a public URL. -# Format: username:password — not needed for internal Docker networks or Kong. -# SUPABASE_PROJECT1_META_AUTH=admin:secretpassword - -# --- OpenPanel --- -# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com -# OPENPANEL_INSTANCE1_CLIENT_ID=your_client_id -# OPENPANEL_INSTANCE1_CLIENT_SECRET=your_client_secret -# OPENPANEL_INSTANCE1_ALIAS=myopenpanel - -# --- Appwrite --- -# APPWRITE_PROJECT1_URL=https://appwrite.example.com -# APPWRITE_PROJECT1_API_KEY=your_appwrite_api_key -# APPWRITE_PROJECT1_PROJECT_ID=your_project_id -# APPWRITE_PROJECT1_ALIAS=myappwrite - -# --- Directus --- -# DIRECTUS_INSTANCE1_URL=https://directus.example.com -# DIRECTUS_INSTANCE1_TOKEN=your_directus_token -# DIRECTUS_INSTANCE1_ALIAS=mydirectus +# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel, +# Gitea, n8n, Appwrite, Directus, WordPress Advanced # ============================================ # OAUTH (optional — for ChatGPT/Claude auto-registration) diff --git a/plugins/openpanel/handlers/export.py b/plugins/openpanel/handlers/export.py index 232c380..98e69ea 100644 --- a/plugins/openpanel/handlers/export.py +++ b/plugins/openpanel/handlers/export.py @@ -4,8 +4,8 @@ Uses REST APIs: - Export API (GET /export/events, /export/charts) for raw data export - Insights API (GET /insights/:projectId/*) for analytics queries -Note: project_id is optional if configured in environment variables. -When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used. +Note: project_id is optional if configured in the site settings. +When not provided, the default project_id from the site configuration is used. Requires 'read' or 'root' mode client for Export API. """ diff --git a/plugins/openpanel/handlers/utils.py b/plugins/openpanel/handlers/utils.py index 57b8722..bab15cf 100644 --- a/plugins/openpanel/handlers/utils.py +++ b/plugins/openpanel/handlers/utils.py @@ -23,6 +23,6 @@ def get_project_id(client: OpenPanelClient, project_id: str | None) -> str: return client.default_project_id raise ValueError( "project_id is required. Either provide it as a parameter or configure " - "OPENPANEL_SITE1_PROJECT_ID in environment variables. " + "the project_id field when adding the site in the dashboard. " "You can find your Project ID in OpenPanel Dashboard → Project Settings." ) diff --git a/plugins/supabase/handlers/auth.py b/plugins/supabase/handlers/auth.py index a4bb174..d657e5e 100644 --- a/plugins/supabase/handlers/auth.py +++ b/plugins/supabase/handlers/auth.py @@ -483,6 +483,8 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) + + async def search_users( client: SupabaseClient, query: str, page: int = 1, per_page: int = 50 ) -> str: diff --git a/plugins/wordpress/client.py b/plugins/wordpress/client.py index 185ccde..be3592d 100644 --- a/plugins/wordpress/client.py +++ b/plugins/wordpress/client.py @@ -67,18 +67,17 @@ class WordPressClient: # Validate required parameters if not site_url: raise ConfigurationError( - "Site URL is not configured. " - "Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)." + "Site URL is not configured. " "Please add or update the site in the dashboard." ) if not username: raise ConfigurationError( "Username is not configured. " - "Please set the USERNAME environment variable (e.g., WORDPRESS_SITE1_USERNAME)." + "Please update the site credentials in the dashboard." ) if not app_password: raise ConfigurationError( "App password is not configured. " - "Please set the APP_PASSWORD environment variable (e.g., WORDPRESS_SITE1_APP_PASSWORD)." + "Please update the site credentials in the dashboard." ) self.site_url = site_url.rstrip("/") diff --git a/plugins/wordpress/wp_cli.py b/plugins/wordpress/wp_cli.py index 5b263f4..30493b3 100644 --- a/plugins/wordpress/wp_cli.py +++ b/plugins/wordpress/wp_cli.py @@ -77,12 +77,8 @@ class WPCLIManager: try: # First, test if we have Docker socket access test_process = await asyncio.create_subprocess_exec( - "docker", - "version", - "--format", - "{{.Server.Version}}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "version", "--format", "{{.Server.Version}}", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) test_stdout, test_stderr = await asyncio.wait_for( @@ -103,15 +99,10 @@ class WPCLIManager: # Now check for our specific container using exact name match process = await asyncio.create_subprocess_exec( - "docker", - "ps", - "--all", - "--filter", - f"name=^{self.container_name}$", - "--format", - "{{.Names}}|{{.Status}}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "ps", "--all", + "--filter", f"name=^{self.container_name}$", + "--format", "{{.Names}}|{{.Status}}", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) @@ -126,13 +117,8 @@ class WPCLIManager: if not output: # Container not found - get list of available containers for helpful error list_process = await asyncio.create_subprocess_exec( - "docker", - "ps", - "--all", - "--format", - "{{.Names}}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "ps", "--all", "--format", "{{.Names}}", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) list_stdout, _ = await list_process.communicate() available = list_stdout.decode().strip().split("\n") if list_stdout else [] @@ -184,14 +170,9 @@ class WPCLIManager: try: # Try to run wp --version process = await asyncio.create_subprocess_exec( - "docker", - "exec", - self.container_name, - "wp", - "--version", - "--allow-root", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "exec", self.container_name, + "wp", "--version", "--allow-root", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0) @@ -310,9 +291,7 @@ class WPCLIManager: ) # 4. Build docker exec command (split command into args to prevent shell injection) - cmd_parts = ( - ["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"] - ) + cmd_parts = ["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"] self.logger.info(f"Executing: {' '.join(cmd_parts)}") @@ -603,29 +582,17 @@ class WPCLIManager: try: # Try GNU stat first, fall back to BSD stat process = await asyncio.create_subprocess_exec( - "docker", - "exec", - self.container_name, - "stat", - "-c", - "%s", - export_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "exec", self.container_name, + "stat", "-c", "%s", export_path, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0) if process.returncode != 0: # BSD stat fallback process = await asyncio.create_subprocess_exec( - "docker", - "exec", - self.container_name, - "stat", - "-f", - "%z", - export_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + "docker", "exec", self.container_name, + "stat", "-f", "%z", export_path, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0) diff --git a/plugins/wordpress_advanced/plugin.py b/plugins/wordpress_advanced/plugin.py index 7e3b268..0560228 100644 --- a/plugins/wordpress_advanced/plugin.py +++ b/plugins/wordpress_advanced/plugin.py @@ -67,7 +67,7 @@ class WordPressAdvancedPlugin(BasePlugin): if not container_name: raise ValueError( "WordPress Advanced plugin requires 'container' configuration. " - "Please set WORDPRESS_ADVANCED_SITE1_CONTAINER in environment variables." + "Please set the 'container' field when adding the site in the dashboard." ) # Import WP-CLI manager diff --git a/server.py b/server.py index 3f77eda..ec8a852 100644 --- a/server.py +++ b/server.py @@ -14,8 +14,9 @@ Usage: Environment Variables: MASTER_API_KEY: Master API key for authentication - {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}: Project configurations LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR) + +Sites are managed via the web dashboard and stored in SQLite (DB-based). """ import base64 @@ -66,8 +67,6 @@ from core.dashboard.routes import ( api_get_config, api_list_keys, api_list_sites, - # K.5: Settings routes - api_save_setting, api_test_site, api_update_site, # E.2: OAuth Social Login routes @@ -109,6 +108,8 @@ from core.dashboard.routes import ( # F.3: Service pages dashboard_service_page, dashboard_services_list, + # K.5: Settings routes + api_save_setting, dashboard_settings_page, # E.3: Site Management pages dashboard_sites_add, @@ -308,7 +309,6 @@ from plugins import registry as plugin_registry site_manager = get_site_manager() plugin_types = plugin_registry.get_registered_types() -site_manager.discover_sites(plugin_types) # Initialize tool registry (central tool management) tool_registry = get_tool_registry() @@ -323,24 +323,8 @@ if auth_manager._is_temporary_key: logger.info(f"Master API Key (temporary): {_mk}") else: logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}") -logger.info(f"Discovered {len(project_manager.projects)} per-site project instances (legacy)") -logger.info( - f"Discovered {site_manager.get_count()} unique sites across {len(plugin_types)} plugin types" -) -logger.info(f"Site breakdown: {site_manager.get_count_by_type()}") - -# Log discovered projects -for full_id, plugin in project_manager.projects.items(): - logger.info(f" - {full_id} ({plugin.get_plugin_name()})") - -# Log discovered sites -logger.info("\nDiscovered sites:") -for site_info in site_manager.list_all_sites(): - alias_display = ( - f" (alias: {site_info['alias']})" if site_info["alias"] != site_info["site_id"] else "" - ) - logger.info(f" - {site_info['full_id']}{alias_display}") - +logger.info(f"Plugin types registered: {', '.join(plugin_types)}") +logger.info("Sites are managed via the web dashboard (DB-based)") logger.info("=" * 60) @@ -5032,12 +5016,6 @@ def main(): logger.info(f"Host: {args.host}") logger.info(f"Port: {args.port}") - # Check if any sites were discovered (SiteManager is the primary discovery system) - if site_manager.get_count() == 0: - logger.warning("No sites discovered! Check your environment variables.") - logger.warning("Expected format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value") - logger.warning("Example: WORDPRESS_SITE1_URL=https://example.com") - try: if args.transport == "stdio": # stdio doesn't support multi-endpoint diff --git a/server_multi.py b/server_multi.py index 7020e2f..5b1b778 100644 --- a/server_multi.py +++ b/server_multi.py @@ -165,7 +165,6 @@ rate_limiter = get_rate_limiter() # Initialize site manager site_manager = get_site_manager() plugin_types = plugin_registry.get_registered_types() -site_manager.discover_sites(plugin_types) # WooCommerce can fallback to WordPress site configurations if no WOOCOMMERCE_* env vars # This mapping is only used when woocommerce has no sites configured diff --git a/tests/test_admin_system.py b/tests/test_admin_system.py index 94ad5f7..8f8b6e3 100644 --- a/tests/test_admin_system.py +++ b/tests/test_admin_system.py @@ -1,5 +1,6 @@ """Tests for Admin System Unification (F.4).""" + from core.dashboard.auth import ( DashboardSession, get_session_display_info, diff --git a/tests/test_site_manager.py b/tests/test_site_manager.py index 2ae2442..c97dadb 100644 --- a/tests/test_site_manager.py +++ b/tests/test_site_manager.py @@ -1,4 +1,4 @@ -"""Tests for Site Manager (core/site_manager.py).""" +"""Tests for Site Manager (core/site_manager.py) — registration and lookup.""" import pytest @@ -127,69 +127,6 @@ class TestSiteManagerRegistration: assert manager.list_sites("nonexistent") == [] -class TestSiteManagerDiscovery: - """Test site discovery from environment variables.""" - - def test_discover_wordpress_sites(self, monkeypatch): - """Should discover sites from WORDPRESS_SITE1_* env vars.""" - monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com") - monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin") - monkeypatch.setenv("WORDPRESS_SITE1_APP_PASSWORD", "xxxx") - monkeypatch.setenv("WORDPRESS_SITE1_ALIAS", "myblog") - - manager = SiteManager() - count = manager.discover_sites(["wordpress"]) - - assert count == 1 - config = manager.get_site_config("wordpress", "site1") - assert config.url == "https://wp1.example.com" - assert config.username == "admin" - assert config.alias == "myblog" - - def test_discover_multiple_sites(self, monkeypatch): - """Should discover multiple sites for same plugin type.""" - monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com") - monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin") - monkeypatch.setenv("WORDPRESS_SITE2_URL", "https://wp2.example.com") - monkeypatch.setenv("WORDPRESS_SITE2_USERNAME", "editor") - - manager = SiteManager() - count = manager.discover_sites(["wordpress"]) - - assert count == 2 - - def test_discover_across_plugin_types(self, monkeypatch): - """Should discover sites across different plugin types.""" - monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp.example.com") - monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin") - monkeypatch.setenv("GITEA_SITE1_URL", "https://git.example.com") - monkeypatch.setenv("GITEA_SITE1_TOKEN", "tok_123") - - manager = SiteManager() - count = manager.discover_sites(["wordpress", "gitea"]) - - assert count == 2 - - def test_reserved_words_skipped(self, monkeypatch): - """Reserved words like LIMIT, RATE should not become site IDs.""" - monkeypatch.setenv("WORDPRESS_LIMIT_PER_MINUTE", "100") - monkeypatch.setenv("WORDPRESS_RATE_PER_HOUR", "500") - - manager = SiteManager() - count = manager.discover_sites(["wordpress"]) - - assert count == 0 - - def test_incomplete_config_skipped(self, monkeypatch): - """Sites with no config data (only prefix match) should be skipped.""" - # WORDPRESS_SITE1_ exists as prefix but no actual config keys - # This is hard to test directly since env vars always have a value - # Instead, test that a site with only site_id and plugin_type (no config) is skipped - manager = SiteManager() - count = manager.discover_sites(["wordpress"]) - assert count == 0 - - class TestSiteManagerCounts: """Test counting and listing methods."""