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) <noreply@anthropic.com>
This commit is contained in:
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -159,10 +159,10 @@
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
{% else %}
|
||||
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +228,14 @@
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}
|
||||
سایتها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایتها</a> مدیریت میشوند.
|
||||
{% else %}
|
||||
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user