Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983d93c2a7 | ||
|
|
80377a1a22 | ||
|
|
684c889c27 | ||
|
|
55d30ba876 | ||
|
|
eceba04578 | ||
|
|
1fcc539093 | ||
|
|
9e6f06d933 | ||
|
|
42ea75bcb3 | ||
|
|
647a650629 | ||
|
|
c57fd666c9 | ||
|
|
7a16a260f1 | ||
|
|
f29dc299b2 | ||
|
|
5d97b31c12 | ||
|
|
72cfdad5e3 | ||
|
|
5ce70f120e | ||
|
|
cafcd64eff | ||
|
|
d74594524a | ||
|
|
b6551abe0a | ||
|
|
cb2a835d0e | ||
|
|
bb2a416dd5 | ||
|
|
ff79968170 | ||
|
|
bb851e4be2 | ||
|
|
91979ee18d | ||
|
|
809dc264af | ||
|
|
cb6bcd8136 | ||
|
|
3b87c01d78 | ||
|
|
85379ec36c | ||
|
|
4a45178d2d | ||
|
|
f303a04322 | ||
|
|
bb74703311 | ||
|
|
aa8afce707 | ||
|
|
076a0b940e | ||
|
|
7daf604a32 | ||
|
|
5e0441c85e | ||
|
|
3a3f04aca8 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,6 +28,7 @@ ENV/
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
pytest-out.txt
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
@@ -159,3 +160,6 @@ pytest-cache-files-*/
|
||||
# Project specific
|
||||
# ====================================
|
||||
# Add project-specific ignores here
|
||||
.worktrees/
|
||||
worktrees/
|
||||
/.agents
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] - 2026-02-23
|
||||
|
||||
### Live Platform Foundation (Track E.1 - E.3)
|
||||
|
||||
Major release introducing the Live Platform architecture — SQLite database, OAuth social login, site management, and per-user MCP endpoints. All features are included in the Community Edition.
|
||||
|
||||
#### Added
|
||||
- **SQLite Database Backend** (E.1): Async SQLite via aiosqlite, WAL mode, schema versioning with migrations framework, CRUD for users/sites/API keys/connection tokens
|
||||
- **Credential Encryption** (E.1): AES-256-GCM with HKDF per-site key derivation, versioned wire format for future migration support
|
||||
- **OAuth Social Login** (E.2): GitHub + Google OAuth 2.0, CSRF state tokens, JWT session management, dual session types (admin + oauth_user)
|
||||
- **Site Management API** (E.3): Plugin credential definitions for all 9 plugins, connection validation, encrypted site CRUD, MAX_SITES_PER_USER=10
|
||||
- **User API Keys** (E.3): bcrypt-hashed keys with `mhu_` prefix, 8-char prefix for indexed lookup, 5-minute validation cache
|
||||
- **Per-User MCP Endpoints** (E.3): Direct JSON-RPC handler at `/u/{user_id}/{alias}/mcp`, per-user rate limiting (30/min, 500/hr)
|
||||
- **Config Snippets** (E.3): Auto-generated config for Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT
|
||||
- **Dashboard Pages**: My Sites (list/add/test/delete), Connect (API keys + config snippets), Profile, OAuth Login
|
||||
- **Dark/Light Mode Toggle**: Theme switcher across all dashboard pages
|
||||
- **RBAC**: Role-based access control for dashboard
|
||||
- **Active Health Checks**: Background health monitoring for connected services
|
||||
|
||||
#### Fixed
|
||||
- CSRF middleware body consumption bug
|
||||
- OAuth log noise and DCR crash on startup
|
||||
- WordPress site connection validation (uses aiohttp to match plugin client)
|
||||
- Tenant isolation enforced on all site queries
|
||||
|
||||
#### Security
|
||||
- All bare `except:` replaced with `except Exception:` across 12 files
|
||||
- Network error differentiation (DNS, SSL, timeout, connection refused)
|
||||
- Retry with exponential backoff for transient errors only
|
||||
- Auth/config errors never retried
|
||||
|
||||
#### Tests
|
||||
- 452 total tests (up from 303), all passing
|
||||
- New test suites: database (37), encryption (27), user_auth (32), site_api (17), user_keys (14), user_endpoints (12), config_snippets (10)
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] - 2026-02-14
|
||||
|
||||
### Project Revival - Dependency Updates & Documentation Sync
|
||||
@@ -30,7 +67,7 @@ After 2-month hiatus (Dec 2025 - Feb 2026), updated all dependencies and verifie
|
||||
- All 54 tests now pass (previously 5 were failing)
|
||||
|
||||
#### Verified
|
||||
- All 589 tools generate correctly
|
||||
- All 596 tools generate correctly
|
||||
- Middleware API stable (Middleware, MiddlewareContext, get_http_headers)
|
||||
- 30+ custom routes operational (dashboard + OAuth)
|
||||
- Multi-endpoint architecture functional
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted projects through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~589 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 596 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
@@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
cp env.example .env # Copy and fill in credentials
|
||||
pip install -e ".[dev]" # Install with dev deps
|
||||
python server.py # Run (stdio) or:
|
||||
python server.py --transport sse --port 8000 # Run (HTTP)
|
||||
python server.py --transport streamable-http --port 8000 # Run (HTTP)
|
||||
```
|
||||
|
||||
## Build & Development Commands
|
||||
@@ -24,8 +24,8 @@ pip install -e ".[dev]"
|
||||
# Run server (stdio transport for Claude Desktop)
|
||||
python server.py
|
||||
|
||||
# Run server (SSE/HTTP transport for testing)
|
||||
python server.py --transport sse --port 8000
|
||||
# Run server (HTTP transport for testing)
|
||||
python server.py --transport streamable-http --port 8000
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
@@ -72,7 +72,7 @@ All configured in `pyproject.toml`:
|
||||
├── server_multi.py # Alternative multi-endpoint server
|
||||
├── core/ # Layer 1: Core system modules
|
||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||
├── templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
├── tests/ # Organized test suite
|
||||
├── scripts/ # Setup & deployment scripts
|
||||
├── wordpress-plugin/ # Companion WP plugins (PHP)
|
||||
@@ -181,7 +181,7 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
|
||||
- `wordpress-plugin/` contains companion WP plugins (openpanel, seo-api-bridge) — these are PHP, not Python
|
||||
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||
- Dashboard templates live in `templates/` (not inside `core/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)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pytest # Verify setup
|
||||
|
||||
```bash
|
||||
python server.py # stdio (Claude Desktop)
|
||||
python server.py --transport sse --port 8000 # HTTP (testing)
|
||||
python server.py --transport streamable-http --port 8000 # HTTP (testing)
|
||||
```
|
||||
|
||||
### Code Style
|
||||
@@ -119,8 +119,8 @@ Then register in `plugins/__init__.py` and add tests.
|
||||
```
|
||||
core/ # Core system (auth, site manager, tool registry, dashboard)
|
||||
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
||||
templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
tests/ # Test suite (289 tests)
|
||||
core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
tests/ # Test suite (290 tests)
|
||||
scripts/ # Setup & deployment scripts
|
||||
docs/ # Documentation
|
||||
```
|
||||
|
||||
175
DOCKER_README.md
Normal file
175
DOCKER_README.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# MCP Hub
|
||||
|
||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||
|
||||
596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a `.env` file
|
||||
|
||||
```bash
|
||||
# Recommended (auto-generates temp key if omitted — check container logs)
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# Add at least one WordPress site
|
||||
WORDPRESS_SITE1_URL=https://your-site.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=mysite
|
||||
```
|
||||
|
||||
### 2. Run the container
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
airano/mcphub:latest
|
||||
```
|
||||
|
||||
### 3. Verify it works
|
||||
|
||||
```bash
|
||||
# Health check (wait ~30 seconds for startup)
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Open the web dashboard
|
||||
# http://localhost:8000/dashboard
|
||||
```
|
||||
|
||||
### 4. Connect your AI client
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://localhost:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer your-secure-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** (`.vscode/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mcphub": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer your-secure-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Endpoints](#endpoints) below.
|
||||
|
||||
## Authentication
|
||||
|
||||
MCP Hub uses **Bearer token** authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
> `X-API-Key` header and query parameter auth are **not** supported.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Use the most specific endpoint for your use case:
|
||||
|
||||
| Endpoint | Tools | `site` param? | Best for |
|
||||
|----------|------:|:-------------:|----------|
|
||||
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
||||
| `/mcp` | 596 | Yes | Admin & discovery only |
|
||||
|
||||
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
||||
|
||||
## Using Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcphub:
|
||||
image: airano/mcphub:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- mcphub-data:/app/data
|
||||
- mcphub-logs:/app/logs
|
||||
# Optional: mount Docker socket for WP-CLI tools
|
||||
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mcphub-data:
|
||||
mcphub-logs:
|
||||
```
|
||||
|
||||
> **WP-CLI tools** (cache flush, database export, plugin updates via CLI) require Docker socket access.
|
||||
> Add `WORDPRESS_SITE1_CONTAINER=your-wp-container-name` to your `.env` and uncomment the Docker socket volume above.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## After Starting
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `http://localhost:8000/health` | Health check & status |
|
||||
| `http://localhost:8000/dashboard` | Web dashboard (manage API keys, view sites, health) |
|
||||
| `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `MASTER_API_KEY` | Recommended | API key for authentication. If omitted, a temporary key is auto-generated and printed to logs |
|
||||
| `WORDPRESS_SITE1_URL` | For WP | WordPress site URL |
|
||||
| `WORDPRESS_SITE1_USERNAME` | For WP | WordPress admin username |
|
||||
| `WORDPRESS_SITE1_APP_PASSWORD` | For WP | WordPress Application Password |
|
||||
| `WORDPRESS_SITE1_ALIAS` | Recommended | Friendly name (e.g., `myblog`) |
|
||||
| `WORDPRESS_SITE1_CONTAINER` | For WP-CLI | Docker container name of your WordPress site (enables cache/db/system tools) |
|
||||
| `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) |
|
||||
|
||||
> **CONTAINER**: Required for WP-CLI tools (cache flush, database export, system info) and **all** WordPress Advanced tools. Find your container name: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
|
||||
|
||||
> **OAuth**: 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.
|
||||
|
||||
Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md).
|
||||
|
||||
## Supported Plugins
|
||||
|
||||
| Plugin | Tools | Env Prefix |
|
||||
|--------|-------|------------|
|
||||
| WordPress | 67 | `WORDPRESS_` |
|
||||
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
||||
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` |
|
||||
| Gitea | 56 | `GITEA_` |
|
||||
| n8n | 56 | `N8N_` |
|
||||
| Supabase | 70 | `SUPABASE_` |
|
||||
| OpenPanel | 73 | `OPENPANEL_` |
|
||||
| Appwrite | 100 | `APPWRITE_` |
|
||||
| Directus | 100 | `DIRECTUS_` |
|
||||
|
||||
## Links
|
||||
|
||||
- **GitHub**: [github.com/airano-ir/mcphub](https://github.com/airano-ir/mcphub)
|
||||
- **PyPI**: [pypi.org/project/mcphub-server](https://pypi.org/project/mcphub-server/)
|
||||
- **Documentation**: [Getting Started Guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md)
|
||||
- **License**: MIT
|
||||
@@ -1,5 +1,5 @@
|
||||
# ===================================
|
||||
# Coolify Projects MCP Server - Dockerfile
|
||||
# MCP Hub — Dockerfile
|
||||
# ===================================
|
||||
# Multi-stage build for optimized image size
|
||||
# Production-ready with security best practices
|
||||
|
||||
163
README.md
163
README.md
@@ -1,3 +1,5 @@
|
||||
<!-- mcp-name: io.github.airano-ir/mcphub -->
|
||||
|
||||
# MCP Hub
|
||||
|
||||
<div align="center">
|
||||
@@ -6,12 +8,13 @@
|
||||
|
||||
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
|
||||
[](https://github.com/airano-ir/mcphub/releases)
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](https://pypi.org/project/mcphub-server/)
|
||||
[](https://hub.docker.com/r/airano/mcphub)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||
|
||||
</div>
|
||||
@@ -46,7 +49,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
|
||||
---
|
||||
|
||||
## 589 Tools Across 9 Plugins
|
||||
## 596 Tools Across 9 Plugins
|
||||
|
||||
| Plugin | Tools | What You Can Do |
|
||||
|--------|-------|-----------------|
|
||||
@@ -59,8 +62,8 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
|
||||
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
||||
| **System** | 17 | Health monitoring, API keys, project discovery |
|
||||
| **Total** | **589** | Constant count — scales to unlimited sites |
|
||||
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
||||
| **Total** | **596** | Constant count — scales to unlimited sites |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,14 +75,15 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
git clone https://github.com/airano-ir/mcphub.git
|
||||
cd mcphub
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
# Edit .env — set MASTER_API_KEY and add your site credentials
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: PyPI
|
||||
### Option 2: Docker Hub (No Clone)
|
||||
|
||||
```bash
|
||||
pip install mcphub-server
|
||||
# Create a .env file with your credentials (see "Configure Your Sites" below)
|
||||
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||
```
|
||||
|
||||
### Option 3: From Source
|
||||
@@ -90,15 +94,28 @@ cd mcphub
|
||||
pip install -e .
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
python server.py --transport sse --port 8000
|
||||
python server.py --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
### Verify It Works
|
||||
|
||||
After starting the server, wait ~30 seconds then:
|
||||
|
||||
```bash
|
||||
# Check server health
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Open the **web dashboard** in your browser: **http://localhost:8000/dashboard**
|
||||
|
||||
You should see the login page. Use your `MASTER_API_KEY` to log in.
|
||||
|
||||
### Configure Your Sites
|
||||
|
||||
Add site credentials to `.env`:
|
||||
|
||||
```bash
|
||||
# Master API Key (required)
|
||||
# Master API Key (recommended — auto-generates temp key if omitted)
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# WordPress Site
|
||||
@@ -119,8 +136,63 @@ GITEA_REPO1_TOKEN=your_gitea_token
|
||||
GITEA_REPO1_ALIAS=mygitea
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Full Environment Variable Reference</b></summary>
|
||||
|
||||
**System Configuration:**
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `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 | 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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Connect Your AI Client
|
||||
|
||||
All MCP clients use **Bearer token** authentication: `Authorization: Bearer YOUR_API_KEY`
|
||||
|
||||
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Architecture](#architecture) below.
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
@@ -129,10 +201,11 @@ Add to `claude_desktop_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,11 +222,11 @@ Add to `.mcp.json` in your project:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "http",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,9 +240,9 @@ Add to `.mcp.json` in your project:
|
||||
|
||||
Go to **Settings > MCP Servers > Add Server**:
|
||||
|
||||
- **Name**: MCP Hub
|
||||
- **URL**: `https://your-server:8000/mcp`
|
||||
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
||||
- **Name**: MCP Hub WordPress
|
||||
- **URL**: `http://your-server:8000/wordpress/mcp`
|
||||
- **Headers**: `Authorization: Bearer YOUR_API_KEY`
|
||||
|
||||
</details>
|
||||
|
||||
@@ -181,11 +254,11 @@ Add to `.vscode/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "http",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,15 +278,18 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
|
||||
</details>
|
||||
|
||||
> **Transport types**: Use `"type": "streamableHttp"` for Claude Desktop and `"type": "http"` for VS Code/Claude Code. Using `"type": "sse"` will cause `400 Bad Request` errors.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
/mcp → Admin endpoint (all 589 tools)
|
||||
/system/mcp → System tools only (17 tools)
|
||||
/mcp → Admin endpoint (all 596 tools)
|
||||
/system/mcp → System tools only (24 tools)
|
||||
/wordpress/mcp → WordPress tools (67 tools)
|
||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
|
||||
/gitea/mcp → Gitea tools (56 tools)
|
||||
/n8n/mcp → n8n tools (56 tools)
|
||||
/supabase/mcp → Supabase tools (70 tools)
|
||||
@@ -223,7 +299,13 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||
```
|
||||
|
||||
**Multi-endpoint architecture**: Give each team member or AI agent access to only the tools they need.
|
||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
|
||||
|
||||
| Endpoint | Use Case | Tools |
|
||||
|----------|----------|------:|
|
||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||
| `/mcp` | Admin & discovery only | 596 |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -235,6 +317,29 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
|
||||
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
|
||||
|
||||
### WordPress Plugin Requirements
|
||||
|
||||
Some MCP Hub tools require companion WordPress plugins:
|
||||
|
||||
| Tools | Requirement |
|
||||
|-------|-------------|
|
||||
| SEO tools (`wordpress_get_post_seo`, etc.) | [SEO API Bridge](wordpress-plugin/seo-api-bridge/) ([Download ZIP](wordpress-plugin/seo-api-bridge.zip)) + Rank Math or Yoast SEO |
|
||||
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var |
|
||||
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
|
||||
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
|
||||
| WooCommerce tools | WooCommerce plugin (separate `WOOCOMMERCE_` config) |
|
||||
|
||||
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
WORDPRESS_SITE1_CONTAINER: your-wp-container-name
|
||||
```
|
||||
|
||||
Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
@@ -258,14 +363,14 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
# Install with dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests (289 tests)
|
||||
# Run tests (290 tests)
|
||||
pytest
|
||||
|
||||
# Format and lint
|
||||
black . && ruff check --fix .
|
||||
|
||||
# Run server locally
|
||||
python server.py --transport sse --port 8000
|
||||
python server.py --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -45,12 +45,10 @@ from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
||||
|
||||
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
||||
from core.site_registry import SiteInfo, SiteRegistry, get_site_registry
|
||||
from core.tool_generator import ToolGenerator
|
||||
|
||||
# Tool Management (Option B architecture)
|
||||
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
||||
from core.unified_tools import UnifiedToolGenerator
|
||||
|
||||
__all__ = [
|
||||
# Authentication
|
||||
@@ -64,11 +62,6 @@ __all__ = [
|
||||
"SiteManager",
|
||||
"SiteConfig",
|
||||
"get_site_manager",
|
||||
# Legacy (deprecated)
|
||||
"SiteRegistry",
|
||||
"SiteInfo",
|
||||
"get_site_registry",
|
||||
"UnifiedToolGenerator",
|
||||
# Tool Management
|
||||
"ToolRegistry",
|
||||
"ToolDefinition",
|
||||
|
||||
12
core/auth.py
12
core/auth.py
@@ -27,11 +27,14 @@ class AuthManager:
|
||||
if not self.master_api_key:
|
||||
# Generate a random key if not provided (dev mode)
|
||||
self.master_api_key = secrets.token_urlsafe(32)
|
||||
self._is_temporary_key = True
|
||||
logger.warning(
|
||||
"No MASTER_API_KEY environment variable found. "
|
||||
f"Generated temporary key: {self.master_api_key[:8]}***{self.master_api_key[-4:]} "
|
||||
f"Generated temporary key: {self.master_api_key} "
|
||||
"(set MASTER_API_KEY in .env for production use)"
|
||||
)
|
||||
else:
|
||||
self._is_temporary_key = False
|
||||
|
||||
# Project-specific keys (future feature)
|
||||
self.project_keys = {}
|
||||
@@ -48,10 +51,13 @@ class AuthManager:
|
||||
Returns:
|
||||
bool: True if valid
|
||||
"""
|
||||
is_valid = secrets.compare_digest(api_key, self.master_api_key)
|
||||
if not self.master_api_key:
|
||||
return False
|
||||
|
||||
is_valid = secrets.compare_digest(api_key.strip(), self.master_api_key.strip())
|
||||
|
||||
if not is_valid:
|
||||
logger.warning("Invalid API key attempt")
|
||||
logger.debug("Master key validation failed for provided token")
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
115
core/config_snippets.py
Normal file
115
core/config_snippets.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""MCP client configuration snippet generation (Track E.3).
|
||||
|
||||
Generates copy-paste configuration snippets for connecting AI clients
|
||||
(Claude Desktop, Claude Code, Cursor, VS Code, ChatGPT) to per-user
|
||||
MCP endpoints.
|
||||
|
||||
Usage:
|
||||
from core.config_snippets import generate_config, get_supported_clients
|
||||
|
||||
snippet = generate_config(
|
||||
base_url="https://mcp.example.com",
|
||||
user_id="abc123",
|
||||
alias="myblog",
|
||||
api_key="mhu_...",
|
||||
client_type="claude_desktop",
|
||||
)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# Supported MCP client types
|
||||
SUPPORTED_CLIENTS = [
|
||||
{
|
||||
"id": "claude_desktop",
|
||||
"label": "Claude Desktop",
|
||||
"description": "Anthropic's desktop app for Claude",
|
||||
},
|
||||
{
|
||||
"id": "claude_code",
|
||||
"label": "Claude Code",
|
||||
"description": "Anthropic's CLI for Claude",
|
||||
},
|
||||
{
|
||||
"id": "cursor",
|
||||
"label": "Cursor",
|
||||
"description": "AI-first code editor",
|
||||
},
|
||||
{
|
||||
"id": "vscode",
|
||||
"label": "VS Code",
|
||||
"description": "Visual Studio Code with MCP extension",
|
||||
},
|
||||
{
|
||||
"id": "chatgpt",
|
||||
"label": "ChatGPT",
|
||||
"description": "OpenAI ChatGPT (URL-based)",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_supported_clients() -> list[dict[str, str]]:
|
||||
"""Return the list of supported MCP client types."""
|
||||
return SUPPORTED_CLIENTS
|
||||
|
||||
|
||||
def generate_config(
|
||||
base_url: str,
|
||||
user_id: str,
|
||||
alias: str,
|
||||
api_key: str,
|
||||
client_type: str,
|
||||
) -> str:
|
||||
"""Generate a configuration snippet for the given MCP client.
|
||||
|
||||
Args:
|
||||
base_url: Public URL of the MCP Hub instance (no trailing slash).
|
||||
user_id: User UUID.
|
||||
alias: Site alias.
|
||||
api_key: User API key (``mhu_...``).
|
||||
client_type: One of the supported client type IDs.
|
||||
|
||||
Returns:
|
||||
JSON configuration string ready for copy-paste.
|
||||
|
||||
Raises:
|
||||
ValueError: If client_type is not supported.
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp"
|
||||
server_name = f"mcphub-{alias}"
|
||||
|
||||
if client_type in ("claude_desktop", "claude_code"):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
server_name: {
|
||||
"url": endpoint_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
elif client_type in ("cursor", "vscode"):
|
||||
config = {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
server_name: {
|
||||
"url": endpoint_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return json.dumps(config, indent=2)
|
||||
|
||||
elif client_type == "chatgpt":
|
||||
return endpoint_url
|
||||
|
||||
else:
|
||||
valid = [c["id"] for c in SUPPORTED_CLIENTS]
|
||||
raise ValueError(f"Unsupported client type '{client_type}'. Valid: {valid}")
|
||||
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
Dashboard Authentication - Session-based authentication for Web UI.
|
||||
|
||||
Phase K.1: Core Infrastructure
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -91,19 +89,36 @@ class DashboardAuth:
|
||||
if not api_key:
|
||||
return False, "", None
|
||||
|
||||
# Check master API key
|
||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
||||
api_key_clean = api_key.strip()
|
||||
# Check master API key (from env var)
|
||||
if self.master_api_key and secrets.compare_digest(
|
||||
api_key_clean, self.master_api_key.strip()
|
||||
):
|
||||
return True, "master", None
|
||||
|
||||
# Check AuthManager's master key (covers auto-generated temp keys)
|
||||
try:
|
||||
from core.auth import get_auth_manager
|
||||
|
||||
auth_mgr = get_auth_manager()
|
||||
if auth_mgr.validate_master_key(api_key):
|
||||
return True, "master", None
|
||||
except Exception as e:
|
||||
logger.debug(f"AuthManager check skipped: {e}")
|
||||
|
||||
# Check project API keys with admin scope
|
||||
try:
|
||||
from core.api_keys import get_api_key_manager
|
||||
|
||||
api_key_manager = get_api_key_manager()
|
||||
|
||||
key_info = api_key_manager.validate_key(api_key)
|
||||
if key_info and "admin" in key_info.get("scope", "").split():
|
||||
return True, "api_key", key_info.get("key_id")
|
||||
# Dashboard login is not project-specific, so skip project check
|
||||
# and require admin scope
|
||||
key_id = api_key_manager.validate_key(
|
||||
api_key, project_id="*", required_scope="admin", skip_project_check=True
|
||||
)
|
||||
if key_id:
|
||||
return True, "api_key", key_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking API key: {e}")
|
||||
|
||||
@@ -190,6 +205,9 @@ class DashboardAuth:
|
||||
user_type=payload["type"],
|
||||
key_id=payload.get("kid"),
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
|
||||
return None
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug("Dashboard session expired")
|
||||
return None
|
||||
@@ -212,6 +230,27 @@ class DashboardAuth:
|
||||
return None
|
||||
return self.validate_session(token)
|
||||
|
||||
def get_user_session_from_request(self, request: Request) -> dict | None:
|
||||
"""Extract and validate an OAuth user session from request.
|
||||
|
||||
Args:
|
||||
request: Starlette request object.
|
||||
|
||||
Returns:
|
||||
User session dict (user_id, email, name, role, type)
|
||||
or None.
|
||||
"""
|
||||
token = request.cookies.get(self.COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from core.user_auth import get_user_auth
|
||||
|
||||
user_auth = get_user_auth()
|
||||
return user_auth.validate_user_session(token)
|
||||
except (RuntimeError, Exception):
|
||||
return None
|
||||
|
||||
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||
"""
|
||||
Set session cookie on response.
|
||||
@@ -261,13 +300,15 @@ class DashboardAuth:
|
||||
RedirectResponse to login page if not authenticated, None if OK.
|
||||
"""
|
||||
session = self.get_session_from_request(request)
|
||||
if not session:
|
||||
user_session = self.get_user_session_from_request(request)
|
||||
|
||||
if not session and not user_session:
|
||||
# Store original URL for redirect after login
|
||||
next_url = str(request.url.path)
|
||||
if request.url.query:
|
||||
next_url += f"?{request.url.query}"
|
||||
return RedirectResponse(
|
||||
url=f"/dashboard/login?next={next_url}",
|
||||
url=f"/auth/login?next={next_url}",
|
||||
status_code=303,
|
||||
)
|
||||
return None
|
||||
@@ -279,3 +320,57 @@ def get_dashboard_auth() -> DashboardAuth:
|
||||
if _dashboard_auth is None:
|
||||
_dashboard_auth = DashboardAuth()
|
||||
return _dashboard_auth
|
||||
|
||||
|
||||
# ── Role-checking helpers ──────────────────────────────────────
|
||||
|
||||
|
||||
def is_admin_session(session) -> bool:
|
||||
"""Check if session is admin (master key or API key with admin scope).
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
True if admin session.
|
||||
"""
|
||||
if isinstance(session, DashboardSession):
|
||||
return session.user_type in ("master", "api_key")
|
||||
if isinstance(session, dict):
|
||||
return session.get("type") == "master" or session.get("role") == "admin"
|
||||
return False
|
||||
|
||||
|
||||
def get_session_display_info(session) -> dict:
|
||||
"""Get display info for header/UI.
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
Dict with name, type, email, avatar keys.
|
||||
"""
|
||||
if isinstance(session, DashboardSession):
|
||||
return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
|
||||
if isinstance(session, dict):
|
||||
return {
|
||||
"name": session.get("name") or session.get("email", "User"),
|
||||
"type": "user",
|
||||
"email": session.get("email"),
|
||||
"avatar": None,
|
||||
}
|
||||
return {"name": "Unknown", "type": "unknown", "email": None, "avatar": None}
|
||||
|
||||
|
||||
def get_session_user_id(session) -> str | None:
|
||||
"""Get user_id for OAuth sessions, None for admin.
|
||||
|
||||
Args:
|
||||
session: DashboardSession or OAuth user dict.
|
||||
|
||||
Returns:
|
||||
User UUID string or None.
|
||||
"""
|
||||
if isinstance(session, dict):
|
||||
return session.get("user_id")
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
706
core/database.py
Normal file
706
core/database.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""SQLite database backend for the Live Platform (Track E).
|
||||
|
||||
Manages the database connection, schema creation, migrations, and CRUD
|
||||
operations for the user system. Uses aiosqlite for async SQLite access
|
||||
with WAL mode and foreign key enforcement.
|
||||
|
||||
This module is only for user-registered sites on the Live Platform.
|
||||
Admin endpoints continue to use env var sites via SiteManager.
|
||||
|
||||
Usage:
|
||||
db = await initialize_database()
|
||||
user = await db.create_user(
|
||||
email="user@example.com",
|
||||
name="Jane",
|
||||
provider="github",
|
||||
provider_id="12345",
|
||||
)
|
||||
await db.close()
|
||||
|
||||
# Or as a context manager:
|
||||
async with Database("/tmp/test.db") as db:
|
||||
user = await db.get_user_by_id("some-uuid")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default data directory: /app/data in Docker, ./data elsewhere
|
||||
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||
|
||||
# Schema version — increment when adding migrations
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
# Initial schema DDL
|
||||
_SCHEMA_SQL = """\
|
||||
-- Users (OAuth social login)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL,
|
||||
last_login TEXT,
|
||||
UNIQUE(provider, provider_id)
|
||||
);
|
||||
|
||||
-- User's registered sites (any plugin type)
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
plugin_type TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
credentials BLOB NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
status_msg TEXT,
|
||||
last_health TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, alias)
|
||||
);
|
||||
|
||||
-- Per-user API keys (for MCP client authentication)
|
||||
CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT,
|
||||
name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL DEFAULT 'read write',
|
||||
last_used TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||
CREATE TABLE IF NOT EXISTS connection_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Schema version tracking
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
# Migration registry: version -> SQL string
|
||||
# Migrations are applied sequentially from the current version + 1 up to SCHEMA_VERSION.
|
||||
_MIGRATIONS: dict[int, str] = {
|
||||
2: (
|
||||
"ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n"
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class Database:
|
||||
"""Async SQLite database for the Live Platform.
|
||||
|
||||
Manages connections, schema, migrations, and provides CRUD helpers
|
||||
for users, sites, API keys, and connection tokens.
|
||||
|
||||
Attributes:
|
||||
db_path: Resolved path to the SQLite database file.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
"""Initialize database configuration.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file. If not provided,
|
||||
reads DATABASE_PATH env var, defaulting to ``data/mcphub.db``.
|
||||
"""
|
||||
if db_path is None:
|
||||
db_file = os.getenv("DATABASE_PATH", None)
|
||||
if db_file:
|
||||
self.db_path = Path(db_file)
|
||||
else:
|
||||
self.db_path = Path(_DEFAULT_DATA_DIR) / "mcphub.db"
|
||||
else:
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Create data directory, connect, and set up schema.
|
||||
|
||||
Enables WAL mode and foreign keys, creates tables if they do
|
||||
not exist, and runs any pending migrations.
|
||||
"""
|
||||
# Ensure parent directory exists
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._conn = await aiosqlite.connect(str(self.db_path))
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
|
||||
# Enable WAL mode for better concurrent read performance
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
# Enable foreign key enforcement
|
||||
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
await self._create_schema()
|
||||
await self._run_migrations()
|
||||
|
||||
logger.info("Database initialized at %s", self.db_path)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
logger.info("Database connection closed")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def __aenter__(self) -> "Database":
|
||||
"""Enter async context — initialize and return self."""
|
||||
await self.initialize()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
"""Exit async context — close the connection."""
|
||||
await self.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level query helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_conn(self) -> aiosqlite.Connection:
|
||||
"""Return the active connection or raise if not initialized."""
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database not initialized. Call initialize() first.")
|
||||
return self._conn
|
||||
|
||||
async def execute(self, sql: str, params: tuple[Any, ...] = ()) -> aiosqlite.Cursor:
|
||||
"""Execute a single write SQL statement and commit.
|
||||
|
||||
Args:
|
||||
sql: SQL statement.
|
||||
params: Bind parameters.
|
||||
|
||||
Returns:
|
||||
The aiosqlite Cursor.
|
||||
"""
|
||||
conn = self._require_conn()
|
||||
cursor = await conn.execute(sql, params)
|
||||
await conn.commit()
|
||||
return cursor
|
||||
|
||||
async def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> aiosqlite.Cursor:
|
||||
"""Execute a SQL statement with multiple parameter sets and commit.
|
||||
|
||||
Args:
|
||||
sql: SQL statement.
|
||||
params_list: List of parameter tuples.
|
||||
|
||||
Returns:
|
||||
The aiosqlite Cursor.
|
||||
"""
|
||||
conn = self._require_conn()
|
||||
cursor = await conn.executemany(sql, params_list)
|
||||
await conn.commit()
|
||||
return cursor
|
||||
|
||||
async def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||
"""Fetch a single row as a dictionary (read-only, no commit).
|
||||
|
||||
Args:
|
||||
sql: SQL query.
|
||||
params: Bind parameters.
|
||||
|
||||
Returns:
|
||||
Row as a dict, or None if no result.
|
||||
"""
|
||||
conn = self._require_conn()
|
||||
cursor = await conn.execute(sql, params)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
async def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||
"""Fetch all rows as a list of dictionaries (read-only, no commit).
|
||||
|
||||
Args:
|
||||
sql: SQL query.
|
||||
params: Bind parameters.
|
||||
|
||||
Returns:
|
||||
List of rows, each as a dict.
|
||||
"""
|
||||
conn = self._require_conn()
|
||||
cursor = await conn.execute(sql, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schema management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _create_schema(self) -> None:
|
||||
"""Create all tables if they do not already exist."""
|
||||
conn = self._require_conn()
|
||||
|
||||
# Check if it's a completely fresh DB (no users table)
|
||||
row = await self.fetchone(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
)
|
||||
is_fresh = row is None
|
||||
|
||||
await conn.executescript(_SCHEMA_SQL)
|
||||
|
||||
if is_fresh:
|
||||
# Seed initial schema version
|
||||
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||
if row is None or row["v"] is None:
|
||||
await self.execute(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(SCHEMA_VERSION, _utc_now()),
|
||||
)
|
||||
else:
|
||||
# For existing v1 databases, if schema_version was just created
|
||||
row = await self.fetchone("SELECT COUNT(*) as c FROM schema_version")
|
||||
if row and row["c"] == 0:
|
||||
await self.execute(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(1, _utc_now()),
|
||||
)
|
||||
|
||||
async def _run_migrations(self) -> None:
|
||||
"""Apply any pending migrations sequentially."""
|
||||
conn = self._require_conn()
|
||||
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||
current = row["v"] if row and row["v"] is not None else 0
|
||||
|
||||
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||
if version == 3:
|
||||
logger.info("Applying python migration for version 3")
|
||||
# Idempotent repair for v1->v2 upgrade bug (some DBs stamped v2 but missed the column)
|
||||
try:
|
||||
await self.execute("ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT")
|
||||
except Exception as e:
|
||||
if "duplicate column name" not in str(e).lower():
|
||||
raise
|
||||
await self.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix)"
|
||||
)
|
||||
else:
|
||||
migration_sql = _MIGRATIONS.get(version)
|
||||
if migration_sql is not None:
|
||||
logger.info("Applying migration to version %d", version)
|
||||
await conn.executescript(migration_sql)
|
||||
logger.info("Migration to version %d applied", version)
|
||||
else:
|
||||
logger.warning(
|
||||
"No migration SQL for version %d, recording version only", version
|
||||
)
|
||||
|
||||
# Always record version to avoid infinite retry
|
||||
await self.execute(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(version, _utc_now()),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
name: str | None,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
avatar_url: str | None = None,
|
||||
role: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user.
|
||||
|
||||
Args:
|
||||
email: User email (unique).
|
||||
name: Display name.
|
||||
provider: OAuth provider ('github' or 'google').
|
||||
provider_id: Provider's unique user ID.
|
||||
avatar_url: URL to user avatar.
|
||||
role: User role ('user' or 'admin').
|
||||
|
||||
Returns:
|
||||
The created user as a dict.
|
||||
|
||||
Raises:
|
||||
aiosqlite.IntegrityError: If email or provider+provider_id already exists.
|
||||
"""
|
||||
user_id = str(uuid.uuid4())
|
||||
now = _utc_now()
|
||||
|
||||
await self.execute(
|
||||
"INSERT INTO users (id, email, name, avatar_url, provider, provider_id, role, "
|
||||
"created_at, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, email, name, avatar_url, provider, provider_id, role, now, now),
|
||||
)
|
||||
|
||||
result = await self.get_user_by_id(user_id)
|
||||
if result is None:
|
||||
raise RuntimeError(f"Failed to read back created user {user_id}")
|
||||
return result
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> dict[str, Any] | None:
|
||||
"""Get a user by their UUID.
|
||||
|
||||
Args:
|
||||
user_id: User UUID.
|
||||
|
||||
Returns:
|
||||
User dict, or None if not found.
|
||||
"""
|
||||
return await self.fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
|
||||
async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
|
||||
"""Get a user by their email.
|
||||
|
||||
Args:
|
||||
email: User email.
|
||||
|
||||
Returns:
|
||||
User dict, or None if not found.
|
||||
"""
|
||||
return await self.fetchone("SELECT * FROM users WHERE email = ?", (email,))
|
||||
|
||||
async def get_user_by_provider(self, provider: str, provider_id: str) -> dict[str, Any] | None:
|
||||
"""Get a user by OAuth provider and provider ID.
|
||||
|
||||
Args:
|
||||
provider: OAuth provider name.
|
||||
provider_id: Provider's unique user ID.
|
||||
|
||||
Returns:
|
||||
User dict, or None if not found.
|
||||
"""
|
||||
return await self.fetchone(
|
||||
"SELECT * FROM users WHERE provider = ? AND provider_id = ?",
|
||||
(provider, provider_id),
|
||||
)
|
||||
|
||||
async def update_user_last_login(self, user_id: str) -> None:
|
||||
"""Update a user's last_login timestamp to now.
|
||||
|
||||
Args:
|
||||
user_id: User UUID.
|
||||
"""
|
||||
await self.execute(
|
||||
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||
(_utc_now(), user_id),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Site CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_site(
|
||||
self,
|
||||
user_id: str,
|
||||
plugin_type: str,
|
||||
alias: str,
|
||||
url: str,
|
||||
credentials: bytes,
|
||||
status: str = "pending",
|
||||
status_msg: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new site for a user.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
plugin_type: Plugin type (e.g. 'wordpress', 'gitea').
|
||||
alias: User-chosen friendly name (unique per user).
|
||||
url: Site URL.
|
||||
credentials: AES-256-GCM encrypted credentials blob.
|
||||
status: Initial status ('pending', 'active', 'error', 'disabled').
|
||||
status_msg: Optional human-readable status message.
|
||||
|
||||
Returns:
|
||||
The created site as a dict.
|
||||
|
||||
Raises:
|
||||
aiosqlite.IntegrityError: If alias is duplicated for the same user.
|
||||
"""
|
||||
site_id = str(uuid.uuid4())
|
||||
now = _utc_now()
|
||||
|
||||
await self.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, credentials, status, status_msg, now),
|
||||
)
|
||||
|
||||
result = await self.get_site(site_id, user_id)
|
||||
if result is None:
|
||||
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||
return result
|
||||
|
||||
async def get_sites_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all sites belonging to a user.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
|
||||
Returns:
|
||||
List of site dicts.
|
||||
"""
|
||||
return await self.fetchall(
|
||||
"SELECT * FROM sites WHERE user_id = ? ORDER BY created_at",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
async def get_site(self, site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
"""Get a single site, scoped to a specific user.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID (for tenant isolation).
|
||||
|
||||
Returns:
|
||||
Site dict, or None if not found or not owned by user.
|
||||
"""
|
||||
return await self.fetchone(
|
||||
"SELECT * FROM sites WHERE id = ? AND user_id = ?",
|
||||
(site_id, user_id),
|
||||
)
|
||||
|
||||
async def delete_site(self, site_id: str, user_id: str) -> bool:
|
||||
"""Delete a site, scoped to a specific user.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID (for tenant isolation).
|
||||
|
||||
Returns:
|
||||
True if a row was deleted, False otherwise.
|
||||
"""
|
||||
cursor = await self.execute(
|
||||
"DELETE FROM sites WHERE id = ? AND user_id = ?",
|
||||
(site_id, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def update_site_status(
|
||||
self,
|
||||
site_id: str,
|
||||
status: str,
|
||||
status_msg: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
"""Update a site's status and optional status message.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
status: New status ('pending', 'active', 'error', 'disabled').
|
||||
status_msg: Optional human-readable status message.
|
||||
user_id: Optional user UUID for tenant-scoped update. When provided,
|
||||
only updates if the site belongs to this user. When None,
|
||||
performs system-level update (e.g., health checks).
|
||||
"""
|
||||
if user_id is not None:
|
||||
await self.execute(
|
||||
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ? AND user_id = ?",
|
||||
(status, status_msg, site_id, user_id),
|
||||
)
|
||||
else:
|
||||
await self.execute(
|
||||
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ?",
|
||||
(status, status_msg, site_id),
|
||||
)
|
||||
|
||||
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||
"""Get a site by user ID and alias.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
alias: Site alias (unique per user).
|
||||
|
||||
Returns:
|
||||
Site dict, or None if not found.
|
||||
"""
|
||||
return await self.fetchone(
|
||||
"SELECT * FROM sites WHERE user_id = ? AND alias = ?",
|
||||
(user_id, alias),
|
||||
)
|
||||
|
||||
async def count_sites_by_user(self, user_id: str) -> int:
|
||||
"""Count the number of sites belonging to a user.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
|
||||
Returns:
|
||||
Number of sites.
|
||||
"""
|
||||
row = await self.fetchone(
|
||||
"SELECT COUNT(*) AS cnt FROM sites WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User API Key CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
user_id: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
name: str,
|
||||
scopes: str = "read write",
|
||||
expires_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user API key.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
key_hash: bcrypt hash of the API key.
|
||||
key_prefix: First 8 chars after ``mhu_`` prefix for fast lookup.
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Space-separated scopes.
|
||||
expires_at: Optional ISO 8601 expiry timestamp.
|
||||
|
||||
Returns:
|
||||
The created API key row as a dict.
|
||||
"""
|
||||
key_id = str(uuid.uuid4())
|
||||
now = _utc_now()
|
||||
|
||||
await self.execute(
|
||||
"INSERT INTO user_api_keys "
|
||||
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
||||
)
|
||||
|
||||
result = await self.fetchone(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
if result is None:
|
||||
raise RuntimeError(f"Failed to read back created API key {key_id}")
|
||||
return result
|
||||
|
||||
async def get_api_keys_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all API keys for a user (without key_hash).
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
|
||||
Returns:
|
||||
List of API key dicts (key_hash excluded).
|
||||
"""
|
||||
return await self.fetchall(
|
||||
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
async def get_api_key_by_prefix(self, key_prefix: str) -> dict[str, Any] | None:
|
||||
"""Get an API key by its prefix (includes key_hash for verification).
|
||||
|
||||
Args:
|
||||
key_prefix: First 8 chars of the key after ``mhu_``.
|
||||
|
||||
Returns:
|
||||
API key dict including key_hash, or None.
|
||||
"""
|
||||
return await self.fetchone(
|
||||
"SELECT * FROM user_api_keys WHERE key_prefix = ?",
|
||||
(key_prefix,),
|
||||
)
|
||||
|
||||
async def delete_api_key(self, key_id: str, user_id: str) -> bool:
|
||||
"""Delete an API key, scoped to a specific user.
|
||||
|
||||
Args:
|
||||
key_id: API key UUID.
|
||||
user_id: Owner's UUID (for tenant isolation).
|
||||
|
||||
Returns:
|
||||
True if a row was deleted, False otherwise.
|
||||
"""
|
||||
cursor = await self.execute(
|
||||
"DELETE FROM user_api_keys WHERE id = ? AND user_id = ?",
|
||||
(key_id, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def update_api_key_usage(self, key_id: str) -> None:
|
||||
"""Increment use_count and update last_used timestamp for an API key.
|
||||
|
||||
Args:
|
||||
key_id: API key UUID.
|
||||
"""
|
||||
await self.execute(
|
||||
"UPDATE user_api_keys SET use_count = use_count + 1, last_used = ? WHERE id = ?",
|
||||
(_utc_now(), key_id),
|
||||
)
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Module-level helpers
|
||||
# ======================================================================
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
"""Return the current UTC time as an ISO 8601 string."""
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_database: Database | None = None
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
"""Get the singleton Database instance.
|
||||
|
||||
Returns:
|
||||
The Database singleton.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If initialize_database() has not been called.
|
||||
"""
|
||||
if _database is None:
|
||||
raise RuntimeError("Database not initialized. Call initialize_database() first.")
|
||||
return _database
|
||||
|
||||
|
||||
async def initialize_database(db_path: str | None = None) -> Database:
|
||||
"""Create, initialize, and store the singleton Database instance.
|
||||
|
||||
Args:
|
||||
db_path: Optional path to the SQLite database file.
|
||||
|
||||
Returns:
|
||||
The initialized Database instance.
|
||||
"""
|
||||
global _database
|
||||
db = Database(db_path)
|
||||
await db.initialize()
|
||||
_database = db
|
||||
return db
|
||||
212
core/encryption.py
Normal file
212
core/encryption.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Credential encryption for the Live Platform.
|
||||
|
||||
Provides AES-256-GCM encryption with HKDF key derivation for per-site
|
||||
credential storage. Credentials are encrypted as JSON blobs and stored
|
||||
in SQLite. Decryption happens only during tool execution and plaintext
|
||||
is never logged.
|
||||
|
||||
Usage:
|
||||
encryption = get_credential_encryption()
|
||||
cipherdata = encryption.encrypt_credentials(
|
||||
{"username": "admin", "app_password": "xxxx xxxx"},
|
||||
site_id="site_abc123",
|
||||
)
|
||||
credentials = encryption.decrypt_credentials(cipherdata, site_id="site_abc123")
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
_NONCE_LENGTH = 12 # 96-bit nonce for AES-GCM
|
||||
_KEY_LENGTH = 32 # 256-bit key
|
||||
_HKDF_SALT = b"mcphub-v1"
|
||||
_FORMAT_VERSION = b"\x01" # Wire format version for future migration support
|
||||
|
||||
|
||||
class CredentialEncryption:
|
||||
"""AES-256-GCM encryption with per-site HKDF-derived keys.
|
||||
|
||||
The master key is read from the ENCRYPTION_KEY environment variable
|
||||
(base64-encoded 32-byte key). Per-site keys are derived via HKDF
|
||||
using the site_id as the info parameter, ensuring each site has a
|
||||
unique encryption key.
|
||||
|
||||
Storage format: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
|
||||
"""
|
||||
|
||||
def __init__(self, encryption_key: str | None = None) -> None:
|
||||
"""Initialize credential encryption.
|
||||
|
||||
Args:
|
||||
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||
reads from the ENCRYPTION_KEY environment variable.
|
||||
|
||||
Raises:
|
||||
ValueError: If the encryption key is missing or invalid.
|
||||
"""
|
||||
raw_key = encryption_key or os.getenv("ENCRYPTION_KEY")
|
||||
|
||||
if not raw_key:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_KEY is required. Set it as an environment variable "
|
||||
"or pass it directly. Generate one with: "
|
||||
'python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"'
|
||||
)
|
||||
|
||||
try:
|
||||
self._master_key = base64.b64decode(raw_key)
|
||||
except Exception as exc:
|
||||
raise ValueError("ENCRYPTION_KEY must be a valid base64-encoded string.") from exc
|
||||
|
||||
if len(self._master_key) != _KEY_LENGTH:
|
||||
raise ValueError(
|
||||
f"ENCRYPTION_KEY must decode to exactly {_KEY_LENGTH} bytes, "
|
||||
f"got {len(self._master_key)} bytes."
|
||||
)
|
||||
|
||||
logger.info("Credential encryption initialized")
|
||||
|
||||
def _derive_key(self, site_id: str) -> bytes:
|
||||
"""Derive a per-site encryption key using HKDF.
|
||||
|
||||
Args:
|
||||
site_id: Unique identifier for the site.
|
||||
|
||||
Returns:
|
||||
32-byte derived key for the given site.
|
||||
"""
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=_KEY_LENGTH,
|
||||
salt=_HKDF_SALT,
|
||||
info=site_id.encode("utf-8"),
|
||||
)
|
||||
return hkdf.derive(self._master_key)
|
||||
|
||||
def encrypt(self, plaintext: str, site_id: str) -> bytes:
|
||||
"""Encrypt a plaintext string for a specific site.
|
||||
|
||||
Args:
|
||||
plaintext: The string to encrypt.
|
||||
site_id: Site identifier used for key derivation.
|
||||
|
||||
Returns:
|
||||
Encrypted bytes: version (1) || nonce (12) || ciphertext || tag (16).
|
||||
"""
|
||||
derived_key = self._derive_key(site_id)
|
||||
aesgcm = AESGCM(derived_key)
|
||||
nonce = os.urandom(_NONCE_LENGTH)
|
||||
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return _FORMAT_VERSION + nonce + ciphertext_with_tag
|
||||
|
||||
def decrypt(self, cipherdata: bytes, site_id: str) -> str:
|
||||
"""Decrypt cipherdata for a specific site.
|
||||
|
||||
Args:
|
||||
cipherdata: Encrypted bytes (nonce || ciphertext || tag).
|
||||
site_id: Site identifier used for key derivation.
|
||||
|
||||
Returns:
|
||||
Original plaintext string.
|
||||
|
||||
Raises:
|
||||
cryptography.exceptions.InvalidTag: If decryption fails
|
||||
(wrong key, tampered data, or wrong site_id).
|
||||
ValueError: If cipherdata is too short or has unsupported version.
|
||||
"""
|
||||
# Minimum: 1 (version) + 12 (nonce) + 16 (tag) = 29 bytes
|
||||
min_length = 1 + _NONCE_LENGTH + 16
|
||||
if len(cipherdata) < min_length:
|
||||
raise ValueError(
|
||||
f"Cipherdata too short: expected at least {min_length} bytes, "
|
||||
f"got {len(cipherdata)}."
|
||||
)
|
||||
|
||||
version = cipherdata[:1]
|
||||
if version != _FORMAT_VERSION:
|
||||
raise ValueError(
|
||||
f"Unsupported encryption format version: {version!r}. "
|
||||
f"Expected {_FORMAT_VERSION!r}."
|
||||
)
|
||||
|
||||
nonce = cipherdata[1 : 1 + _NONCE_LENGTH]
|
||||
ciphertext_with_tag = cipherdata[1 + _NONCE_LENGTH :]
|
||||
|
||||
derived_key = self._derive_key(site_id)
|
||||
aesgcm = AESGCM(derived_key)
|
||||
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
|
||||
def encrypt_credentials(self, credentials: dict, site_id: str) -> bytes:
|
||||
"""Encrypt a credentials dictionary for a specific site.
|
||||
|
||||
The dictionary is serialized to JSON, then encrypted.
|
||||
|
||||
Args:
|
||||
credentials: Dictionary of credentials (e.g., username, password).
|
||||
site_id: Site identifier used for key derivation.
|
||||
|
||||
Returns:
|
||||
Encrypted bytes.
|
||||
"""
|
||||
json_str = json.dumps(credentials, separators=(",", ":"), sort_keys=True)
|
||||
return self.encrypt(json_str, site_id)
|
||||
|
||||
def decrypt_credentials(self, cipherdata: bytes, site_id: str) -> dict:
|
||||
"""Decrypt cipherdata back to a credentials dictionary.
|
||||
|
||||
Args:
|
||||
cipherdata: Encrypted bytes from encrypt_credentials.
|
||||
site_id: Site identifier used for key derivation.
|
||||
|
||||
Returns:
|
||||
Original credentials dictionary.
|
||||
|
||||
Raises:
|
||||
cryptography.exceptions.InvalidTag: If decryption fails.
|
||||
json.JSONDecodeError: If decrypted data is not valid JSON.
|
||||
"""
|
||||
json_str = self.decrypt(cipherdata, site_id)
|
||||
return json.loads(json_str)
|
||||
|
||||
|
||||
# Global credential encryption instance
|
||||
_credential_encryption: CredentialEncryption | None = None
|
||||
|
||||
|
||||
def initialize_credential_encryption(
|
||||
encryption_key: str | None = None,
|
||||
) -> CredentialEncryption:
|
||||
"""Initialize the global credential encryption instance.
|
||||
|
||||
Args:
|
||||
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||
reads from the ENCRYPTION_KEY environment variable.
|
||||
|
||||
Returns:
|
||||
The initialized CredentialEncryption instance.
|
||||
"""
|
||||
global _credential_encryption
|
||||
_credential_encryption = CredentialEncryption(encryption_key)
|
||||
return _credential_encryption
|
||||
|
||||
|
||||
def get_credential_encryption() -> CredentialEncryption:
|
||||
"""Get the global credential encryption instance.
|
||||
|
||||
Lazily initializes from the ENCRYPTION_KEY environment variable
|
||||
if not already initialized via initialize_credential_encryption().
|
||||
"""
|
||||
global _credential_encryption
|
||||
if _credential_encryption is None:
|
||||
_credential_encryption = CredentialEncryption()
|
||||
return _credential_encryption
|
||||
@@ -99,7 +99,7 @@ ENDPOINT_CONFIGS = {
|
||||
# Mounted at "/" → /mcp (FastMCP adds /mcp automatically)
|
||||
EndpointType.ADMIN: EndpointConfig(
|
||||
path="/",
|
||||
name="Coolify Admin",
|
||||
name="MCP Hub Admin",
|
||||
description="Full administrative access to all tools and plugins",
|
||||
endpoint_type=EndpointType.ADMIN,
|
||||
plugin_types=[], # Empty = all plugins
|
||||
@@ -107,7 +107,7 @@ ENDPOINT_CONFIGS = {
|
||||
allowed_scopes={"admin"},
|
||||
max_tools=400,
|
||||
),
|
||||
# System endpoint - system tools only (17 tools) - Phase X.3
|
||||
# System endpoint - system tools only (24 tools)
|
||||
# For API key management, OAuth, rate limiting without loading all plugins
|
||||
EndpointType.SYSTEM: EndpointConfig(
|
||||
path="/system",
|
||||
|
||||
208
core/health.py
208
core/health.py
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Enhanced Health Monitoring System for MCP Server (Phase 7.2)
|
||||
Enhanced Health Monitoring System for MCP Server
|
||||
|
||||
This module provides comprehensive health monitoring capabilities including:
|
||||
- Response time tracking
|
||||
@@ -9,10 +9,10 @@ This module provides comprehensive health monitoring capabilities including:
|
||||
- Dependency health checks
|
||||
- System uptime tracking
|
||||
|
||||
Author: Coolify MCP Team
|
||||
Version: 7.2
|
||||
Author: MCP Hub Team
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -24,6 +24,7 @@ from typing import Any
|
||||
|
||||
from core.audit_log import AuditLogger
|
||||
from core.project_manager import ProjectManager
|
||||
from core.site_manager import SiteManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -133,6 +134,7 @@ class HealthMonitor:
|
||||
audit_logger: AuditLogger | None = None,
|
||||
metrics_retention_hours: int = 24,
|
||||
max_metrics_per_project: int = 1000,
|
||||
site_manager: SiteManager | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize health monitor.
|
||||
@@ -142,8 +144,10 @@ class HealthMonitor:
|
||||
audit_logger: Optional audit logger for logging health events
|
||||
metrics_retention_hours: Hours to retain historical metrics
|
||||
max_metrics_per_project: Maximum metrics to store per project
|
||||
site_manager: Optional SiteManager for comprehensive site discovery
|
||||
"""
|
||||
self.project_manager = project_manager
|
||||
self.site_manager = site_manager
|
||||
self.audit_logger = audit_logger
|
||||
self.metrics_retention_hours = metrics_retention_hours
|
||||
self.max_metrics_per_project = max_metrics_per_project
|
||||
@@ -172,7 +176,12 @@ class HealthMonitor:
|
||||
# Request rate tracking (for requests per minute)
|
||||
self.request_timestamps: deque = deque(maxlen=1000)
|
||||
|
||||
logger.info("HealthMonitor initialized (Phase 7.2)")
|
||||
# Active background checks
|
||||
self.latest_health_status: dict[str, ProjectHealthStatus] = {}
|
||||
self._bg_task: asyncio.Task | None = None
|
||||
self._is_running = False
|
||||
|
||||
logger.info("HealthMonitor initialized")
|
||||
|
||||
def _setup_default_thresholds(self):
|
||||
"""Setup default alert thresholds."""
|
||||
@@ -401,9 +410,17 @@ class HealthMonitor:
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Get plugin instance
|
||||
# Get plugin instance from ProjectManager
|
||||
plugin = self.project_manager.projects.get(project_id)
|
||||
if not plugin:
|
||||
|
||||
if plugin:
|
||||
# Perform health check via plugin instance
|
||||
health_result = await plugin.health_check()
|
||||
elif self.site_manager:
|
||||
# Site exists in SiteManager but not legacy ProjectManager
|
||||
# Create a temporary plugin instance for a proper health check
|
||||
health_result = await self._site_manager_health_check(project_id)
|
||||
else:
|
||||
return ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=False,
|
||||
@@ -413,9 +430,6 @@ class HealthMonitor:
|
||||
recent_errors=["Project not found"],
|
||||
alerts=["CRITICAL: Project not found"],
|
||||
)
|
||||
|
||||
# Perform health check
|
||||
health_result = await plugin.health_check()
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle both dict and string (JSON) responses
|
||||
@@ -462,7 +476,7 @@ class HealthMonitor:
|
||||
}
|
||||
alerts = self._check_alerts(project_id, alert_check_data)
|
||||
|
||||
return ProjectHealthStatus(
|
||||
status = ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=is_healthy,
|
||||
last_check=datetime.now(UTC),
|
||||
@@ -472,6 +486,8 @@ class HealthMonitor:
|
||||
alerts=alerts,
|
||||
details=health_result,
|
||||
)
|
||||
self.latest_health_status[project_id] = status
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
@@ -485,7 +501,7 @@ class HealthMonitor:
|
||||
error_message=error_msg,
|
||||
)
|
||||
|
||||
return ProjectHealthStatus(
|
||||
status = ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=False,
|
||||
last_check=datetime.now(UTC),
|
||||
@@ -494,6 +510,120 @@ class HealthMonitor:
|
||||
recent_errors=[error_msg],
|
||||
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
||||
)
|
||||
self.latest_health_status[project_id] = status
|
||||
return status
|
||||
|
||||
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
|
||||
"""Find site info from SiteManager by full_id."""
|
||||
if not self.site_manager:
|
||||
return None
|
||||
for info in self.site_manager.list_all_sites():
|
||||
if info["full_id"] == project_id:
|
||||
return info
|
||||
return None
|
||||
|
||||
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Health check for sites managed by SiteManager (not in legacy ProjectManager).
|
||||
|
||||
Creates a temporary plugin instance and calls its health_check() method,
|
||||
falling back to a basic HTTP check if plugin instantiation fails.
|
||||
"""
|
||||
if not self.site_manager:
|
||||
return {"healthy": False, "message": "SiteManager not available"}
|
||||
|
||||
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
|
||||
site_info = self._find_site_info(project_id)
|
||||
if not site_info:
|
||||
return {"healthy": False, "message": f"Site not found: {project_id}"}
|
||||
|
||||
plugin_type = site_info["plugin_type"]
|
||||
site_id = site_info["site_id"]
|
||||
|
||||
try:
|
||||
config = self.site_manager.get_site_config(plugin_type, site_id)
|
||||
except (KeyError, ValueError):
|
||||
return {"healthy": False, "message": f"Site config not found: {project_id}"}
|
||||
|
||||
# Try to create a temporary plugin instance for a proper health check
|
||||
try:
|
||||
from plugins import registry as plugin_registry
|
||||
|
||||
config_dict = config.to_dict()
|
||||
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
||||
return await plugin_instance.health_check()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not create plugin instance for {project_id}, "
|
||||
f"falling back to authenticated HTTP check: {e}"
|
||||
)
|
||||
|
||||
# Fallback: authenticated health check for WordPress-based plugins
|
||||
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
auth = aiohttp.BasicAuth(
|
||||
config.username or "",
|
||||
config.app_password or "",
|
||||
)
|
||||
async with aiohttp.ClientSession(auth=auth) as session:
|
||||
async with session.get(
|
||||
f"{config.url}/wp-json/wp/v2/users/me",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
auth_valid = resp.status == 200
|
||||
basic_result = await self._basic_http_health_check(config.url, project_id)
|
||||
basic_result["auth_valid"] = auth_valid
|
||||
if not auth_valid:
|
||||
basic_result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||
return basic_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: basic HTTP check
|
||||
return await self._basic_http_health_check(config.url, project_id)
|
||||
|
||||
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
||||
"""Basic HTTP health check as a last-resort fallback."""
|
||||
|
||||
import aiohttp
|
||||
|
||||
if not url:
|
||||
return {"healthy": False, "message": "No URL configured for site"}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=10), ssl=False
|
||||
) as resp:
|
||||
return {
|
||||
"healthy": resp.status < 500,
|
||||
"status_code": resp.status,
|
||||
"message": f"HTTP {resp.status} from {url}",
|
||||
}
|
||||
except TimeoutError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": "timeout",
|
||||
"message": f"Site at {url} did not respond within 10 seconds.",
|
||||
}
|
||||
except aiohttp.ClientConnectorDNSError:
|
||||
host = url.split("://")[-1].split("/")[0]
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": "dns_failure",
|
||||
"message": f"DNS resolution failed for '{host}'. Check the URL.",
|
||||
}
|
||||
except aiohttp.ClientConnectorError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": "connection_refused",
|
||||
"message": f"Cannot connect to {url}. Server unreachable.",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
|
||||
|
||||
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -507,8 +637,14 @@ class HealthMonitor:
|
||||
"""
|
||||
health_statuses = {}
|
||||
|
||||
# Collect all known project/site IDs from both sources
|
||||
all_project_ids = set(self.project_manager.projects.keys())
|
||||
if self.site_manager:
|
||||
for site_info in self.site_manager.list_all_sites():
|
||||
all_project_ids.add(site_info["full_id"])
|
||||
|
||||
# Check each project
|
||||
for project_id in self.project_manager.projects.keys():
|
||||
for project_id in sorted(all_project_ids):
|
||||
status = await self.check_project_health(project_id, include_metrics)
|
||||
health_statuses[project_id] = status.to_dict()
|
||||
|
||||
@@ -660,8 +796,46 @@ class HealthMonitor:
|
||||
self.failed_requests = 0
|
||||
self.response_times.clear()
|
||||
self.request_timestamps.clear()
|
||||
self.latest_health_status.clear()
|
||||
logger.warning("All metrics have been reset")
|
||||
|
||||
async def start_background_checks(self, interval_seconds: int = 60):
|
||||
"""Start background health checks for all projects."""
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = True
|
||||
logger.info(f"Starting background health checks every {interval_seconds} seconds")
|
||||
|
||||
async def _loop():
|
||||
# Initial wait to let server start up fully
|
||||
await asyncio.sleep(5)
|
||||
while self._is_running:
|
||||
try:
|
||||
await self.check_all_projects_health(include_metrics=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background health check loop: {e}")
|
||||
|
||||
# Sleep interval, check _is_running periodically
|
||||
for _ in range(interval_seconds):
|
||||
if not self._is_running:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
self._bg_task = asyncio.create_task(_loop())
|
||||
|
||||
async def stop_background_checks(self):
|
||||
"""Stop background health checks."""
|
||||
self._is_running = False
|
||||
if self._bg_task:
|
||||
self._bg_task.cancel()
|
||||
try:
|
||||
await self._bg_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._bg_task = None
|
||||
logger.info("Background health checks stopped")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_health_monitor: HealthMonitor | None = None
|
||||
@@ -673,7 +847,10 @@ def get_health_monitor() -> HealthMonitor | None:
|
||||
|
||||
|
||||
def initialize_health_monitor(
|
||||
project_manager: ProjectManager, audit_logger: AuditLogger | None = None, **kwargs
|
||||
project_manager: ProjectManager,
|
||||
audit_logger: AuditLogger | None = None,
|
||||
site_manager: SiteManager | None = None,
|
||||
**kwargs,
|
||||
) -> HealthMonitor:
|
||||
"""
|
||||
Initialize the global health monitor.
|
||||
@@ -681,11 +858,14 @@ def initialize_health_monitor(
|
||||
Args:
|
||||
project_manager: Project manager instance
|
||||
audit_logger: Optional audit logger
|
||||
site_manager: Optional SiteManager for comprehensive site discovery
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
HealthMonitor instance
|
||||
"""
|
||||
global _health_monitor
|
||||
_health_monitor = HealthMonitor(project_manager, audit_logger, **kwargs)
|
||||
_health_monitor = HealthMonitor(
|
||||
project_manager, audit_logger, site_manager=site_manager, **kwargs
|
||||
)
|
||||
return _health_monitor
|
||||
|
||||
@@ -139,7 +139,7 @@ class TokenManager:
|
||||
logger.warning("Expired access token")
|
||||
raise
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid access token: {e}")
|
||||
logger.debug(f"Invalid access token: {e}")
|
||||
raise
|
||||
|
||||
def generate_refresh_token(self, client_id: str, access_token: str) -> str:
|
||||
@@ -241,8 +241,8 @@ class TokenManager:
|
||||
try:
|
||||
old_payload = self.validate_access_token(token_data.access_token)
|
||||
scope = old_payload.get("scope", "read")
|
||||
except:
|
||||
pass # Old token might be expired, use default
|
||||
except Exception:
|
||||
pass # Old token might be expired, use default scope
|
||||
|
||||
# Generate new tokens
|
||||
new_access_token = self.generate_access_token(
|
||||
|
||||
@@ -61,11 +61,24 @@ class ProjectManager:
|
||||
"""
|
||||
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()
|
||||
@@ -78,9 +91,7 @@ class ProjectManager:
|
||||
if config:
|
||||
self._create_project_instance(plugin_type, project_id, config)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to create {plugin_type} project '{project_id}': {e}", exc_info=True
|
||||
)
|
||||
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:
|
||||
"""
|
||||
|
||||
545
core/site_api.py
Normal file
545
core/site_api.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""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__)
|
||||
|
||||
# Maximum sites per user (configurable via env var)
|
||||
MAX_SITES_PER_USER = 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",
|
||||
},
|
||||
{
|
||||
"name": "app_password",
|
||||
"label": "Application Password",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||
},
|
||||
],
|
||||
"woocommerce": [
|
||||
{
|
||||
"name": "consumer_key",
|
||||
"label": "Consumer Key",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"hint": "WooCommerce → Settings → Advanced → REST API",
|
||||
},
|
||||
{
|
||||
"name": "consumer_secret",
|
||||
"label": "Consumer Secret",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Shown once when creating the REST API key",
|
||||
},
|
||||
],
|
||||
"wordpress_advanced": [
|
||||
{
|
||||
"name": "username",
|
||||
"label": "Username",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"hint": "Your WordPress admin username",
|
||||
},
|
||||
{
|
||||
"name": "app_password",
|
||||
"label": "Application Password",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||
},
|
||||
{
|
||||
"name": "container",
|
||||
"label": "Docker Container Name",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"hint": "Docker container running WordPress (for WP-CLI access)",
|
||||
},
|
||||
],
|
||||
"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",
|
||||
},
|
||||
{
|
||||
"name": "anon_key",
|
||||
"label": "Anon Key",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → anon key",
|
||||
},
|
||||
],
|
||||
"openpanel": [
|
||||
{
|
||||
"name": "client_id",
|
||||
"label": "Client ID",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"hint": "OpenPanel admin panel → API section",
|
||||
},
|
||||
{
|
||||
"name": "client_secret",
|
||||
"label": "Client Secret",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Generated with your Client ID",
|
||||
},
|
||||
],
|
||||
"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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Plugin display names for UI
|
||||
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
|
||||
"wordpress": "WordPress",
|
||||
"woocommerce": "WooCommerce",
|
||||
"wordpress_advanced": "WordPress Advanced",
|
||||
"gitea": "Gitea",
|
||||
"n8n": "n8n",
|
||||
"supabase": "Supabase",
|
||||
"openpanel": "OpenPanel",
|
||||
"appwrite": "Appwrite",
|
||||
"directus": "Directus",
|
||||
}
|
||||
|
||||
# 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"},
|
||||
"wordpress_advanced": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
|
||||
"gitea": {"path": "/api/v1/user", "method": "GET"},
|
||||
"n8n": {"path": "/healthz", "method": "GET"},
|
||||
"supabase": {"path": "/rest/v1/", "method": "GET"},
|
||||
"openpanel": {"path": "/api/v1/oauth/token", "method": "POST"},
|
||||
"appwrite": {"path": "/v1/health", "method": "GET"},
|
||||
"directus": {"path": "/server/health", "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() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Get credential fields for regular (non-admin) users.
|
||||
|
||||
Excludes plugin types that require admin-level infrastructure
|
||||
(e.g., wordpress_advanced needs Docker access).
|
||||
|
||||
Returns:
|
||||
Filtered dict of plugin_type -> field definitions.
|
||||
"""
|
||||
excluded = {"wordpress_advanced"}
|
||||
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k not in excluded}
|
||||
|
||||
|
||||
def get_user_plugin_names() -> dict[str, str]:
|
||||
"""Get plugin display names for regular (non-admin) users.
|
||||
|
||||
Excludes plugins that require admin-level infrastructure.
|
||||
|
||||
Returns:
|
||||
Filtered dict of plugin_type -> display name.
|
||||
"""
|
||||
excluded = {"wordpress_advanced"}
|
||||
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k not in excluded}
|
||||
|
||||
|
||||
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_advanced"):
|
||||
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 == "directus":
|
||||
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
|
||||
elif plugin_type == "openpanel":
|
||||
# OpenPanel uses token exchange — just check that the URL is reachable
|
||||
pass
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
if method == "POST" and plugin_type == "openpanel":
|
||||
async with session.post(
|
||||
check_url,
|
||||
json={
|
||||
"clientId": credentials.get("client_id", ""),
|
||||
"clientSecret": credentials.get("client_secret", ""),
|
||||
},
|
||||
) as resp:
|
||||
status_code = resp.status
|
||||
resp_text = await resp.text()
|
||||
elif 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
|
||||
count = await db.count_sites_by_user(user_id)
|
||||
if count >= MAX_SITES_PER_USER:
|
||||
raise ValueError(f"Site limit reached ({MAX_SITES_PER_USER} 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 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
|
||||
@@ -50,6 +50,7 @@ class SiteConfig(BaseModel):
|
||||
site_id: str = Field(..., description="Unique site identifier")
|
||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||
alias: str | None = Field(None, description="Friendly alias for the site")
|
||||
user_id: str | None = Field(None, description="Owner user ID for the site")
|
||||
|
||||
# Common config fields (plugins may require additional fields)
|
||||
url: str | None = Field(None, description="Site URL")
|
||||
@@ -211,12 +212,27 @@ class SiteManager:
|
||||
"""
|
||||
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()
|
||||
@@ -437,6 +453,7 @@ class SiteManager:
|
||||
"site_id": config.site_id,
|
||||
"alias": config.alias,
|
||||
"full_id": config.get_full_id(),
|
||||
"user_id": config.user_id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -486,22 +503,15 @@ class SiteManager:
|
||||
>>> suffix = manager.get_effective_path_suffix('wordpress_site1')
|
||||
>>> print(suffix) # 'myblog' or 'wordpress_site1'
|
||||
"""
|
||||
# Parse full_id to get plugin_type and site_id
|
||||
parts = full_id.split("_", 1)
|
||||
if len(parts) != 2:
|
||||
return full_id
|
||||
# Look up by full_id from registered sites (handles multi-word plugin types)
|
||||
for info in self.list_all_sites():
|
||||
if info["full_id"] == full_id:
|
||||
config = self.sites[info["plugin_type"]].get(info["site_id"])
|
||||
if config and config.alias and config.alias != config.site_id:
|
||||
return config.alias
|
||||
return full_id
|
||||
|
||||
plugin_type, site_id = parts
|
||||
|
||||
# Try to get config
|
||||
try:
|
||||
config = self.get_site_config(plugin_type, site_id)
|
||||
# If alias exists and is different from site_id, use alias
|
||||
if config.alias and config.alias != config.site_id:
|
||||
return config.alias
|
||||
return full_id
|
||||
except ValueError:
|
||||
return full_id
|
||||
return full_id
|
||||
|
||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
||||
"""
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
"""
|
||||
Site Registry
|
||||
|
||||
Manages site configurations discovered from environment variables.
|
||||
Supports multi-site setups with aliases and dynamic discovery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteInfo:
|
||||
"""Information about a single site."""
|
||||
|
||||
def __init__(
|
||||
self, plugin_type: str, site_id: str, config: dict[str, Any], alias: str | None = None
|
||||
):
|
||||
"""
|
||||
Initialize site information.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
site_id: Site identifier (e.g., 'site1')
|
||||
config: Site configuration from environment
|
||||
alias: Optional friendly alias for the site
|
||||
"""
|
||||
self.plugin_type = plugin_type
|
||||
self.site_id = site_id
|
||||
self.config = config
|
||||
self.alias = alias or site_id
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
"""Get full site identifier: plugin_type_site_id"""
|
||||
return f"{self.plugin_type}_{self.site_id}"
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""Get display name (alias if available, otherwise site_id)"""
|
||||
return self.alias
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"plugin_type": self.plugin_type,
|
||||
"site_id": self.site_id,
|
||||
"alias": self.alias,
|
||||
"full_id": self.get_full_id(),
|
||||
"config_keys": list(self.config.keys()),
|
||||
}
|
||||
|
||||
|
||||
class SiteRegistry:
|
||||
"""
|
||||
Registry for managing site configurations across plugin types.
|
||||
|
||||
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_URL=https://myblog.com
|
||||
WORDPRESS_SITE2_ALIAS=myblog
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize site registry."""
|
||||
self.sites: dict[str, SiteInfo] = {} # full_id -> SiteInfo
|
||||
self.aliases: dict[str, str] = {} # alias -> full_id
|
||||
self.alias_conflicts: dict[str, list[str]] = {} # alias -> [full_ids that wanted it]
|
||||
self.logger = logging.getLogger("SiteRegistry")
|
||||
|
||||
def discover_sites(self, plugin_types: list[str]) -> None:
|
||||
"""
|
||||
Discover sites from environment variables.
|
||||
|
||||
Args:
|
||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
||||
"""
|
||||
self.logger.info("Starting site discovery...")
|
||||
|
||||
for plugin_type in plugin_types:
|
||||
self._discover_plugin_sites(plugin_type)
|
||||
|
||||
self.logger.info(
|
||||
f"Discovery complete. Found {len(self.sites)} sites "
|
||||
f"with {len(self.aliases)} aliases."
|
||||
)
|
||||
|
||||
# Log alias conflicts if any
|
||||
if self.alias_conflicts:
|
||||
self.logger.warning("=" * 50)
|
||||
self.logger.warning("DUPLICATE ALIAS CONFLICTS DETECTED:")
|
||||
for alias, full_ids in self.alias_conflicts.items():
|
||||
winner = self.aliases.get(alias)
|
||||
losers = [fid for fid in full_ids if fid != winner]
|
||||
self.logger.warning(
|
||||
f" Alias '{alias}': {winner} (winner), {losers} (using full_id)"
|
||||
)
|
||||
self.logger.warning("=" * 50)
|
||||
|
||||
# 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) -> None:
|
||||
"""
|
||||
Discover all sites for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
"""
|
||||
prefix = plugin_type.upper() + "_"
|
||||
|
||||
# Find all site IDs for this plugin type
|
||||
site_ids = set()
|
||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
||||
|
||||
for env_key in os.environ.keys():
|
||||
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)
|
||||
|
||||
# Create SiteInfo for each discovered site
|
||||
for site_id in site_ids:
|
||||
try:
|
||||
config = self._load_site_config(plugin_type, site_id)
|
||||
if config:
|
||||
alias = config.pop("alias", None) # Extract alias if present
|
||||
site_info = SiteInfo(plugin_type, site_id, config, alias)
|
||||
|
||||
full_id = site_info.get_full_id()
|
||||
self.sites[full_id] = site_info
|
||||
|
||||
# Register alias with duplicate detection
|
||||
if alias:
|
||||
self._register_alias_safe(alias, full_id)
|
||||
# Also register with prefix
|
||||
prefixed_alias = f"{plugin_type}_{alias}"
|
||||
self._register_alias_safe(prefixed_alias, full_id)
|
||||
|
||||
# Always register site_id as alias too (with safe check)
|
||||
self._register_alias_safe(site_id, full_id)
|
||||
self.aliases[full_id] = (
|
||||
full_id # full_id can reference itself (no conflict possible)
|
||||
)
|
||||
|
||||
# Log with alias status
|
||||
effective_alias = self._get_effective_alias(alias, full_id)
|
||||
self.logger.info(f"Discovered site: {full_id} " f"(path: {effective_alias})")
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
def _register_alias_safe(self, alias: str, full_id: str) -> bool:
|
||||
"""
|
||||
Register an alias with duplicate detection.
|
||||
|
||||
Args:
|
||||
alias: The alias to register
|
||||
full_id: The full_id to map the alias to
|
||||
|
||||
Returns:
|
||||
True if alias was registered, False if it was a duplicate
|
||||
"""
|
||||
if alias in self.aliases:
|
||||
existing_full_id = self.aliases[alias]
|
||||
if existing_full_id != full_id:
|
||||
# Duplicate alias detected
|
||||
if alias not in self.alias_conflicts:
|
||||
self.alias_conflicts[alias] = [existing_full_id]
|
||||
self.alias_conflicts[alias].append(full_id)
|
||||
self.logger.warning(
|
||||
f"Duplicate alias '{alias}': {full_id} conflicts with {existing_full_id}. "
|
||||
f"{full_id} will use full_id for endpoint path."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self.aliases[alias] = full_id
|
||||
return True
|
||||
|
||||
def _get_effective_alias(self, alias: str | None, full_id: str) -> str:
|
||||
"""
|
||||
Get the effective path suffix for a site.
|
||||
|
||||
If the alias was taken by another site, returns full_id.
|
||||
Otherwise returns the alias (or full_id if no alias).
|
||||
|
||||
Args:
|
||||
alias: The desired alias
|
||||
full_id: The full site ID
|
||||
|
||||
Returns:
|
||||
The effective path suffix
|
||||
"""
|
||||
if alias:
|
||||
# Check if this site owns the alias
|
||||
if self.aliases.get(alias) == full_id:
|
||||
return alias
|
||||
else:
|
||||
# Alias was taken, use full_id
|
||||
return full_id
|
||||
return full_id
|
||||
|
||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
||||
"""
|
||||
Get all alias conflicts.
|
||||
|
||||
Returns:
|
||||
Dict mapping conflicted aliases to list of full_ids that wanted them
|
||||
"""
|
||||
return self.alias_conflicts.copy()
|
||||
|
||||
def get_effective_path_suffix(self, full_id: str) -> str:
|
||||
"""
|
||||
Get the effective path suffix for a site's endpoint.
|
||||
|
||||
Uses alias if available and not conflicted, otherwise full_id.
|
||||
|
||||
Args:
|
||||
full_id: The full site ID
|
||||
|
||||
Returns:
|
||||
Path suffix to use in endpoint URL
|
||||
"""
|
||||
site_info = self.sites.get(full_id)
|
||||
if not site_info:
|
||||
return full_id
|
||||
|
||||
alias = site_info.alias
|
||||
return self._get_effective_alias(alias, full_id)
|
||||
|
||||
def _load_site_config(self, plugin_type: str, site_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Load configuration for a site from environment.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
site_id: Site ID
|
||||
|
||||
Returns:
|
||||
Dict with configuration or None if incomplete
|
||||
"""
|
||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
||||
config = {}
|
||||
|
||||
# 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[config_key] = env_value
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Loaded config for {plugin_type}/{site_id}: {list(config.keys())}")
|
||||
return config
|
||||
|
||||
def get_site(self, plugin_type: str, site_identifier: str) -> SiteInfo | None:
|
||||
"""
|
||||
Get site information by ID or alias.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type (e.g., 'wordpress')
|
||||
site_identifier: Site ID, alias, or full_id
|
||||
|
||||
Returns:
|
||||
SiteInfo if found, None otherwise
|
||||
"""
|
||||
# Try direct lookup first
|
||||
full_id = f"{plugin_type}_{site_identifier}"
|
||||
if full_id in self.sites:
|
||||
return self.sites[full_id]
|
||||
|
||||
# Try alias lookup
|
||||
if site_identifier in self.aliases:
|
||||
resolved_full_id = self.aliases[site_identifier]
|
||||
return self.sites.get(resolved_full_id)
|
||||
|
||||
# Try prefixed alias
|
||||
prefixed = f"{plugin_type}_{site_identifier}"
|
||||
if prefixed in self.aliases:
|
||||
resolved_full_id = self.aliases[prefixed]
|
||||
return self.sites.get(resolved_full_id)
|
||||
|
||||
return None
|
||||
|
||||
def get_sites_by_type(self, plugin_type: str) -> list[SiteInfo]:
|
||||
"""
|
||||
Get all sites of a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type to filter by
|
||||
|
||||
Returns:
|
||||
List of SiteInfo objects
|
||||
"""
|
||||
return [
|
||||
site_info for site_info in self.sites.values() if site_info.plugin_type == plugin_type
|
||||
]
|
||||
|
||||
def list_all_sites(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all discovered sites.
|
||||
|
||||
Returns:
|
||||
List of site info dictionaries
|
||||
"""
|
||||
return [site_info.to_dict() for site_info in self.sites.values()]
|
||||
|
||||
def get_site_options(self, plugin_type: str) -> list[str]:
|
||||
"""
|
||||
Get available site options for a plugin type (for schema enum).
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
|
||||
Returns:
|
||||
List of valid site identifiers (IDs and aliases)
|
||||
"""
|
||||
options = set()
|
||||
for site_info in self.get_sites_by_type(plugin_type):
|
||||
options.add(site_info.site_id)
|
||||
if site_info.alias and site_info.alias != site_info.site_id:
|
||||
options.add(site_info.alias)
|
||||
return sorted(options)
|
||||
|
||||
|
||||
# Global site registry instance
|
||||
_site_registry: SiteRegistry | None = None
|
||||
|
||||
|
||||
def get_site_registry() -> SiteRegistry:
|
||||
"""Get the global site registry instance."""
|
||||
global _site_registry
|
||||
if _site_registry is None:
|
||||
_site_registry = SiteRegistry()
|
||||
return _site_registry
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MCP Hub{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||
|
||||
<!-- Tailwind CSS from CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
47
core/templates/dashboard/404.html
Normal file
47
core/templates/dashboard/404.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 — Page Not Found | MCP Hub</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center px-6 py-16 max-w-lg">
|
||||
<div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8">
|
||||
<svg class="w-10 h-10 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-6xl font-bold text-white mb-4">404</h1>
|
||||
<p class="text-xl text-gray-400 mb-8">Page not found</p>
|
||||
<p class="text-sm text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p>
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<a href="/dashboard"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/health"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg transition-colors">
|
||||
Health Check
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-12 text-xs text-gray-600">MCP Hub</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Header with Create Button -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -24,16 +24,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||
|
||||
<!-- Project Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||
<select
|
||||
name="project"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||
@@ -48,10 +48,10 @@
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||
<select
|
||||
name="status"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||
@@ -67,7 +67,7 @@
|
||||
name="search"
|
||||
value="{{ search_query }}"
|
||||
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</button>
|
||||
|
||||
{% if search_query or selected_project or selected_status != 'active' %}
|
||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -84,42 +84,42 @@
|
||||
</div>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if api_keys %}
|
||||
{% for key in api_keys %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||
<!-- Key ID -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||
<button
|
||||
onclick="copyToClipboard('{{ key.key_id }}')"
|
||||
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
||||
@@ -139,7 +139,7 @@
|
||||
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-300">{{ key.project_id }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
<!-- Description -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-400">{{ key.description or '-' }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
<!-- Usage -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-300">{{ key.usage_count }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span>
|
||||
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
||||
</td>
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
{% endif %}
|
||||
<button
|
||||
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||
class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -218,7 +218,7 @@
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
||||
</p>
|
||||
<button
|
||||
@@ -236,8 +236,8 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-400">
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
||||
{% else %}
|
||||
@@ -248,7 +248,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% if page_number > 1 %}
|
||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -258,7 +258,7 @@
|
||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
{% if page_number < total_pages %}
|
||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -280,9 +280,9 @@
|
||||
|
||||
<!-- Create Key Modal -->
|
||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -291,13 +291,13 @@
|
||||
|
||||
<!-- Project -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||
</label>
|
||||
<select
|
||||
name="project_id"
|
||||
required
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||
{% for project in available_projects %}
|
||||
@@ -308,46 +308,46 @@
|
||||
|
||||
<!-- Scope -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Expiration -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
||||
</label>
|
||||
<select
|
||||
name="expires_in_days"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
||||
@@ -357,10 +357,10 @@
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeCreateModal()"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
@@ -376,9 +376,9 @@
|
||||
|
||||
<!-- New Key Modal (shows the actual key) -->
|
||||
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-white flex items-center">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-6 h-6 text-green-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
@@ -403,7 +403,7 @@
|
||||
type="text"
|
||||
id="newKeyValue"
|
||||
readonly
|
||||
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
||||
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm"
|
||||
>
|
||||
<button
|
||||
onclick="copyNewKey()"
|
||||
@@ -414,7 +414,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-700 flex justify-end">
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onclick="closeNewKeyModal()"
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
||||
@@ -427,14 +427,14 @@
|
||||
|
||||
<!-- Revoke Modal -->
|
||||
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-300">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}
|
||||
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
||||
{% else %}
|
||||
@@ -445,10 +445,10 @@
|
||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeRevokeModal()"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
@@ -464,14 +464,14 @@
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-300">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}
|
||||
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
||||
{% else %}
|
||||
@@ -482,10 +482,10 @@
|
||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeDeleteModal()"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
@@ -6,16 +6,16 @@
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Filters -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||
|
||||
<!-- Project Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
||||
<select
|
||||
name="project"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||
@@ -29,10 +29,10 @@
|
||||
|
||||
<!-- Event Type Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||
<select
|
||||
name="event_type"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
||||
@@ -45,10 +45,10 @@
|
||||
|
||||
<!-- Level Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
||||
<select
|
||||
name="level"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||
@@ -60,12 +60,12 @@
|
||||
|
||||
<!-- Date Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date"
|
||||
value="{{ selected_date }}"
|
||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
name="search"
|
||||
value="{{ search_query }}"
|
||||
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</button>
|
||||
|
||||
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -95,11 +95,11 @@
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.total }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -108,11 +108,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.tool_calls }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.tool_calls }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -121,11 +121,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.auth_events }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.auth_events }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -134,11 +134,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ stats.errors }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.errors }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -150,35 +150,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Logs Table -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if logs %}
|
||||
{% for log in logs %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<!-- Timestamp -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-300 font-mono">{{ log.timestamp[:19] }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] if log.timestamp else '-' }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Event Type -->
|
||||
@@ -207,7 +207,7 @@
|
||||
|
||||
<!-- Event/Message -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
||||
{% if log.project_id %}
|
||||
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
||||
{% endif %}
|
||||
@@ -237,7 +237,7 @@
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 2"/>
|
||||
</svg>
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
@@ -249,8 +249,8 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-400">
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
||||
{% else %}
|
||||
@@ -261,17 +261,17 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% if page_number > 1 %}
|
||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == page_number %}
|
||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-gray-900 dark:text-white">{{ page_num }}</span>
|
||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -281,7 +281,7 @@
|
||||
|
||||
{% if page_number < total_pages %}
|
||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
208
core/templates/dashboard/auth-login.html
Normal file
208
core/templates/dashboard/auth-login.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - MCP Hub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||
{% include "dashboard/partials/head_assets.html" %}
|
||||
<style>
|
||||
.login-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.btn-github {
|
||||
background: #24292e;
|
||||
}
|
||||
|
||||
.btn-github:hover {
|
||||
background: #2f363d;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(36, 41, 46, 0.4);
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
background: #4285f4;
|
||||
}
|
||||
|
||||
.btn-google:hover {
|
||||
background: #3367d6;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(66, 133, 244, 0.4);
|
||||
}
|
||||
|
||||
.btn-admin {
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.btn-admin:hover {
|
||||
background: #374151;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||
style="animation-delay: -3s;"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full max-w-md">
|
||||
<div class="card-shadow bg-gray-800 rounded-2xl p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
{% if lang == 'fa' %}برای مدیریت سایتهایتان وارد شوید{% else %}Sign in to manage your sites{% endif
|
||||
%}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{% if error %}
|
||||
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} flex-shrink-0"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-red-400 text-sm">
|
||||
{% if error == 'rate_limit' %}
|
||||
{% if lang == 'fa' %}تلاشهای ثبتنام زیاد. لطفاً بعداً تلاش کنید.{% else %}Too many
|
||||
registration attempts. Please try again later.{% endif %}
|
||||
{% elif error == 'oauth_denied' %}
|
||||
{% if lang == 'fa' %}احراز هویت لغو یا رد شد.{% else %}Authentication was cancelled or denied.{%
|
||||
endif %}
|
||||
{% elif error == 'no_email' %}
|
||||
{% if lang == 'fa' %}آدرس ایمیل شما قابل دریافت نبود.{% else %}Could not retrieve your email
|
||||
address. Please check your provider settings.{% endif %}
|
||||
{% elif error == 'invalid_state' %}
|
||||
{% if lang == 'fa' %}نشست منقضی شده. لطفاً دوباره تلاش کنید.{% else %}Session expired. Please
|
||||
try again.{% endif %}
|
||||
{% elif error == 'exchange_failed' %}
|
||||
{% if lang == 'fa' %}احراز هویت ناموفق. لطفاً دوباره تلاش کنید.{% else %}Authentication failed.
|
||||
Please try again.{% endif %}
|
||||
{% elif error == 'provider_unavailable' %}
|
||||
{% if lang == 'fa' %}این ارائهدهنده ورود در دسترس نیست.{% else %}This login provider is not
|
||||
available.{% endif %}
|
||||
{% else %}
|
||||
{% if lang == 'fa' %}خطایی رخ داد. لطفاً دوباره تلاش کنید.{% else %}An error occurred. Please
|
||||
try again.{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- OAuth Buttons -->
|
||||
<div class="space-y-3">
|
||||
{% if "github" in providers %}
|
||||
<a href="/auth/github"
|
||||
class="btn-github transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
{% if lang == 'fa' %}ورود با GitHub{% else %}Continue with GitHub{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if "google" in providers %}
|
||||
<a href="/auth/google"
|
||||
class="btn-google transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#fff" fill-opacity="0.8" />
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#fff" fill-opacity="0.6" />
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#fff" fill-opacity="0.9" />
|
||||
</svg>
|
||||
{% if lang == 'fa' %}ورود با Google{% else %}Continue with Google{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not providers %}
|
||||
<div class="text-center p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<p class="text-yellow-400 text-sm">
|
||||
{% if lang == 'fa' %}هیچ ارائهدهنده OAuth پیکربندی نشده.{% else %}No OAuth providers
|
||||
configured. Set GITHUB_CLIENT_ID/SECRET or GOOGLE_CLIENT_ID/SECRET in your environment.{% endif
|
||||
%}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-6 flex items-center">
|
||||
<div class="flex-1 border-t border-gray-600"></div>
|
||||
<span class="px-4 text-sm text-gray-400">
|
||||
{% if lang == 'fa' %}یا{% else %}or{% endif %}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Login Link -->
|
||||
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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" />
|
||||
</svg>
|
||||
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
|
||||
</a>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="mt-6 text-center">
|
||||
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-500 text-sm mt-6">MCP Hub v{{ version }}</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
305
core/templates/dashboard/base.html
Normal file
305
core/templates/dashboard/base.html
Normal file
@@ -0,0 +1,305 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||
|
||||
{% include "dashboard/partials/head_assets.html" %}
|
||||
|
||||
<style>
|
||||
/* Custom scrollbar — light mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Custom scrollbar — dark mode */
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Sidebar transition */
|
||||
.sidebar-transition {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator pulse */
|
||||
.status-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL adjustments */
|
||||
[dir="rtl"] .sidebar-icon {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
[dir="ltr"] .sidebar-icon {
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
{# ── Derive RBAC info from session ── #}
|
||||
{% set _is_admin = is_admin_session(session) %}
|
||||
{% set _display = get_session_display_info(session) %}
|
||||
|
||||
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
|
||||
x-data="{ sidebarOpen: true, darkMode: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
|
||||
x-init="$watch('darkMode', val => {
|
||||
if(val) { document.documentElement.classList.add('dark'); localStorage.theme = 'dark'; }
|
||||
else { document.documentElement.classList.remove('dark'); localStorage.theme = 'light'; }
|
||||
})">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col"
|
||||
:class="sidebarOpen ? 'w-64' : 'w-20'" {% if lang=='fa' %}style="border-left: 1px solid;" {% else
|
||||
%}style="border-right: 1px solid;" {% endif %}>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center" x-show="sidebarOpen">
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
|
||||
</div>
|
||||
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||
</div>
|
||||
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||
{# ── Common nav items (all users) ── #}
|
||||
{% set common_nav = [
|
||||
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1
|
||||
1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
||||
] %}
|
||||
|
||||
{# ── User-only nav items ── #}
|
||||
{% set user_nav = [
|
||||
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
|
||||
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
'/dashboard/sites'),
|
||||
('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'),
|
||||
] %}
|
||||
|
||||
{# ── Admin-only nav items ── #}
|
||||
{% set admin_nav = [
|
||||
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
'/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'),
|
||||
('health', t.health, 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12
|
||||
7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', '/dashboard/health'),
|
||||
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573
|
||||
1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724
|
||||
0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35
|
||||
0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0
|
||||
00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31
|
||||
2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
|
||||
] %}
|
||||
|
||||
{# Render common nav #}
|
||||
{% for item_id, label, icon_path, url in common_nav %}
|
||||
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||
</svg>
|
||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Render user nav (non-admin only) #}
|
||||
{% if not _is_admin %}
|
||||
{% for item_id, label, icon_path, url in user_nav %}
|
||||
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||
</svg>
|
||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Render admin nav (admin only) #}
|
||||
{% if _is_admin %}
|
||||
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p x-show="sidebarOpen" class="px-3 mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{% if lang == 'fa' %}مدیریت{% else %}Administration{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% for item_id, label, icon_path, url in admin_nav %}
|
||||
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||
</svg>
|
||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Profile link for OAuth users #}
|
||||
{% if not _is_admin %}
|
||||
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span x-show="sidebarOpen">{{ t.get('profile', 'Profile') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="/dashboard/logout"
|
||||
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
||||
<!-- Top Header -->
|
||||
<header class="sticky top-0 z-10 bg-white/95 dark:bg-gray-800/95 backdrop-blur border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between h-16 px-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
||||
|
||||
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button @click="darkMode = !darkMode"
|
||||
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Toggle Dark Mode">
|
||||
<svg x-show="darkMode" class="w-5 h-5" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<svg x-show="!darkMode" class="w-5 h-5" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||
class="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-800 dark:text-gray-200">
|
||||
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
||||
</a>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
hx-target="body" hx-swap="outerHTML"
|
||||
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="{{ t.refresh }}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Info -->
|
||||
{% if session %}
|
||||
<div class="flex items-center text-sm">
|
||||
{% if _is_admin %}
|
||||
<span class="px-2 py-1 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded text-xs font-medium">
|
||||
{{ t.get('admin_badge', 'Admin') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ _display.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
196
core/templates/dashboard/connect.html
Normal file
196
core/templates/dashboard/connect.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.connect }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.connect }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.api_keys }}</h3>
|
||||
<button onclick="createKey()" id="create-key-btn"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||
+ {{ t.generate_key }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New key alert -->
|
||||
{% if new_key %}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
||||
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
||||
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- New key display (JS-created) -->
|
||||
<div id="new-key-display" class="hidden bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
||||
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code>
|
||||
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||
</div>
|
||||
|
||||
{% if api_keys %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.api_key_name }}</th>
|
||||
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
||||
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}دسترسی{% else %}Access{% endif %}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
||||
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for key in api_keys %}
|
||||
<tr id="key-{{ key.id }}">
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="deleteKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">{{ t.delete }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_api_keys }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Config Snippets Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3>
|
||||
|
||||
{% if sites %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_site }}</label>
|
||||
<select id="config-site" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for site in sites %}
|
||||
<option value="{{ site.alias }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_client }}</label>
|
||||
<select id="config-client" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
||||
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function createKey() {
|
||||
const name = prompt('Key name (e.g., "Claude Desktop"):');
|
||||
if (!name) return;
|
||||
|
||||
const btn = document.getElementById('create-key-btn');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.key) {
|
||||
document.getElementById('new-key-value').textContent = data.key.key;
|
||||
document.getElementById('new-key-display').classList.remove('hidden');
|
||||
// Reload to show in table
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
alert(data.error || 'Failed to create key');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function deleteKey(keyId) {
|
||||
if (!confirm('Delete this API key?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
document.getElementById('key-' + keyId).remove();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to delete key');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConfig() {
|
||||
const alias = document.getElementById('config-site')?.value;
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!alias || !client) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('config-output').textContent = data.config;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('config-output').textContent = 'Error loading config';
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function copyNewKey() {
|
||||
const key = document.getElementById('new-key-value').textContent;
|
||||
navigator.clipboard.writeText(key);
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = document.getElementById('copy-config-btn');
|
||||
btn.textContent = '{{ t.copied }}';
|
||||
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
|
||||
}
|
||||
|
||||
// Load config on page load
|
||||
{% if sites %}
|
||||
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -8,10 +8,10 @@
|
||||
<!-- System Status Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Overall Status -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
||||
{% if async_load %}
|
||||
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
||||
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
||||
@@ -44,11 +44,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Uptime -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{{ t.system_uptime }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ uptime.formatted }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ uptime.formatted }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -59,11 +59,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Total Requests -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ metrics.total_requests|default(0) }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ metrics.total_requests|default(0) }}</p>
|
||||
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
@@ -75,10 +75,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Error Rate -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
||||
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
||||
</p>
|
||||
@@ -113,8 +113,8 @@
|
||||
<!-- Response Time & Throughput -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Response Time -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
@@ -134,22 +134,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Request Summary -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="w-full bg-gray-700 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
{% set total = metrics.total_requests|default(0) %}
|
||||
{% set successful = metrics.successful_requests|default(0) %}
|
||||
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
||||
@@ -164,12 +164,12 @@
|
||||
<div id="alerts-container"></div>
|
||||
|
||||
<!-- Projects Health -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
||||
</h3>
|
||||
<span id="projects-summary" class="text-sm text-gray-400">
|
||||
<span id="projects-summary" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if async_load %}
|
||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||
{% else %}
|
||||
@@ -180,21 +180,21 @@
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -203,7 +203,7 @@
|
||||
<!-- HTMX async loading - wraps tbody for proper swap -->
|
||||
<tbody
|
||||
id="projects-health-body"
|
||||
class="divide-y divide-gray-700"
|
||||
class="divide-y divide-gray-200 dark:divide-gray-700"
|
||||
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
@@ -215,7 +215,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -223,12 +223,12 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if projects_health %}
|
||||
{% for project_id, project in projects_health.items() %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-white font-medium">{{ project_id }}</span>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if project.healthy or project.status == 'healthy' %}
|
||||
@@ -244,7 +244,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||
@@ -252,14 +252,14 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
@@ -272,26 +272,26 @@
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +299,7 @@
|
||||
<!-- Refresh Buttons -->
|
||||
<div class="flex justify-between items-center">
|
||||
{% if is_cached %}
|
||||
<p class="text-sm text-gray-400">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
@@ -316,7 +316,7 @@
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t.refresh }}
|
||||
</a>
|
||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Projects Table Rows -->
|
||||
{% if projects_health %}
|
||||
{% for project_id, project in projects_health.items() %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-white font-medium">{{ project_id }}</span>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if project.healthy or project.status == 'healthy' %}
|
||||
@@ -17,7 +17,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||
@@ -25,14 +25,14 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||
</p>
|
||||
</td>
|
||||
455
core/templates/dashboard/index.html
Normal file
455
core/templates/dashboard/index.html
Normal file
@@ -0,0 +1,455 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.dashboard }}{% endblock %}
|
||||
{% block page_title %}{{ t.overview }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
|
||||
{% if is_admin is defined and is_admin %}
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
{# ADMIN DASHBOARD #}
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- Admin Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Projects Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_projects }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.projects_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.active_api_keys }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-green-400 hover:text-green-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tools Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_tools }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Uptime Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-orange-400 hover:text-orange-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if recent_activity %}
|
||||
{% for activity in recent_activity %}
|
||||
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
{% if activity.type == 'tool_call' %}
|
||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif activity.type == 'auth' %}
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif activity.level == 'ERROR' %}
|
||||
<div class="w-8 h-8 bg-red-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-8 h-8 bg-gray-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ activity.message }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project if activity.project and activity.project != 'None' else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 2"/>
|
||||
</svg>
|
||||
<p>{{ t.no_activity }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if recent_activity %}
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||
{{ t.view_all }} →
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
<!-- Projects by Type -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.projects_by_type }}</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'bg-blue-500',
|
||||
'woocommerce': 'bg-purple-500',
|
||||
'wordpress_advanced': 'bg-indigo-500',
|
||||
'gitea': 'bg-green-500',
|
||||
'n8n': 'bg-orange-500',
|
||||
'supabase': 'bg-emerald-500',
|
||||
'openpanel': 'bg-cyan-500',
|
||||
'appwrite': 'bg-pink-500',
|
||||
'directus': 'bg-violet-500',
|
||||
} %}
|
||||
|
||||
{% set plugin_icons = {
|
||||
'wordpress': 'W',
|
||||
'woocommerce': 'WC',
|
||||
'wordpress_advanced': 'WA',
|
||||
'gitea': 'G',
|
||||
'n8n': 'n8n',
|
||||
'supabase': 'SB',
|
||||
'openpanel': 'OP',
|
||||
'appwrite': 'AW',
|
||||
'directus': 'DI',
|
||||
} %}
|
||||
|
||||
{% if projects_by_type %}
|
||||
{% for plugin_type, count in projects_by_type.items() %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ plugin_type|plugin_name }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.health_status }}</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
{% for component, status in health_summary.components.items() %}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ component }}</span>
|
||||
<div class="flex items-center">
|
||||
{% if status == 'healthy' %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-green-600 dark:text-green-400">{{ t.healthy }}</span>
|
||||
{% elif status == 'warning' %}
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-yellow-600 dark:text-yellow-400">{{ t.warning }}</span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{{ t.error }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
||||
{{ health_summary.last_check[:19] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||
{{ t.view_all }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support / Donate -->
|
||||
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-white mb-1">
|
||||
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
|
||||
</p>
|
||||
<a
|
||||
href="https://nowpayments.io/donation/airano"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
{# USER DASHBOARD #}
|
||||
{# ══════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- User Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- My Sites Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('my_services', 'My Services') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.sites_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Active Connections Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('active_connections', 'Active Connections') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.active_sites_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}اتصالات فعال{% else %}Active connections{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('user_api_keys', 'API Keys') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-purple-400 hover:text-purple-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Available Tools Card -->
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('available_tools', 'Available Tools') }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}ابزارهای MCP قابل استفاده{% else %}MCP tools available{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Site Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.get('site_status', 'Site Status') }}</h2>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
{{ t.get('add_site', 'Add Service') }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
{% if user_sites %}
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'bg-blue-500',
|
||||
'woocommerce': 'bg-purple-500',
|
||||
'wordpress_advanced': 'bg-indigo-500',
|
||||
'gitea': 'bg-green-500',
|
||||
'n8n': 'bg-orange-500',
|
||||
'supabase': 'bg-emerald-500',
|
||||
'openpanel': 'bg-cyan-500',
|
||||
'appwrite': 'bg-pink-500',
|
||||
'directus': 'bg-violet-500',
|
||||
} %}
|
||||
|
||||
{% for site in user_sites %}
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 {{ plugin_colors.get(site.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<span class="text-xs font-bold text-white">{{ site.plugin_type[:2]|upper }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ site.alias }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ site.plugin_type|plugin_name }} · {{ site.url[:40] }}{% if site.url|length > 40 %}...{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
{% if site.status == 'active' %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-green-600 dark:text-green-400">{{ t.get('active', 'Active') }}</span>
|
||||
{% elif site.status == 'error' %}
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{{ t.get('error', 'Error') }}</span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">{{ t.get('no_services_yet', "You haven't connected any services yet.") }}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mb-6">{{ t.get('add_first_service', 'Add your first service to start using MCP tools.') }}</p>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
{{ t.get('add_site', 'Add Service') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Auto-refresh dashboard every 30 seconds
|
||||
htmx.config.defaultSwapStyle = 'outerHTML';
|
||||
|
||||
setInterval(function() {
|
||||
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,45 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ t.login_title }} - MCP Hub</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
{% include "dashboard/partials/head_assets.html" %}
|
||||
|
||||
<style>
|
||||
.login-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.input-focus:focus {
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float" style="animation-delay: -3s;"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||
style="animation-delay: -3s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
@@ -48,20 +64,22 @@
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||
<p class="text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>{{ t.login_subtitle }}</p>
|
||||
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>{{ t.login_subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{% if error %}
|
||||
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||
endif %}>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-red-400 text-sm">
|
||||
{% if error == 'rate_limit' %}
|
||||
@@ -76,63 +94,70 @@
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="/dashboard/login" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
|
||||
<input type="hidden" name="next" value="{{ next_url }}">
|
||||
|
||||
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<div {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
{{ t.api_key_label }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
required
|
||||
autocomplete="off"
|
||||
<input type="password" id="api_key" name="api_key" required autocomplete="off"
|
||||
placeholder="{{ t.api_key_placeholder }}"
|
||||
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
|
||||
>
|
||||
{% if lang=='fa' %}dir="ltr" style="text-align: right;" {% endif %}>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800"
|
||||
>
|
||||
<button type="submit"
|
||||
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
{{ t.login_button }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Security Note -->
|
||||
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||
endif %}>
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm text-blue-300">
|
||||
<div class="text-sm text-blue-300">
|
||||
{% if lang == 'fa' %}
|
||||
برای دسترسی به داشبورد به Master API Key یا یک API Key با scope=admin نیاز دارید.
|
||||
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
|
||||
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code
|
||||
class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
|
||||
{% else %}
|
||||
You need a Master API Key or an API Key with scope=admin to access the dashboard.
|
||||
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the
|
||||
dashboard.</p>
|
||||
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code
|
||||
class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the
|
||||
temporary key.</p>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank"
|
||||
class="text-purple-400 hover:text-purple-300 underline">
|
||||
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
||||
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
{% if lang == 'fa' %}English{% else %}<span lang="fa" dir="rtl">فارسی</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-gray-500 text-sm mt-6">
|
||||
MCP Hub v3.0.0
|
||||
MCP Hub v{{ version }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Header with Create Button -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-gray-400">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
||||
{% else %}
|
||||
@@ -29,11 +29,11 @@
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_count }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -42,10 +42,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
@@ -55,11 +55,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-white">OAuth 2.1</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">OAuth 2.1</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -71,37 +71,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Clients Table -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if clients %}
|
||||
{% for client in clients %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-white font-medium">{{ client.client_name }}</span>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ client.client_name }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-sm text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
||||
<code class="text-sm text-gray-500 dark:text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -143,15 +143,15 @@
|
||||
<td colspan="5" class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
||||
<div class="space-y-1">
|
||||
{% for uri in client.redirect_uris %}
|
||||
<code class="block text-xs text-gray-300 bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
||||
<code class="block text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for grant in client.grant_types %}
|
||||
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
|
||||
@@ -168,7 +168,7 @@
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<p class="text-gray-400 mb-4">
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
||||
</p>
|
||||
<button
|
||||
@@ -188,30 +188,30 @@
|
||||
|
||||
<!-- Create Client Modal -->
|
||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
||||
</h3>
|
||||
|
||||
<form id="createForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="client_name"
|
||||
required
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
||||
<textarea
|
||||
name="redirect_uris"
|
||||
required
|
||||
rows="3"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
@@ -220,19 +220,19 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-300">read</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-300">write</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||
<span class="text-sm text-gray-300">admin</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
@@ -244,7 +244,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick="closeCreateModal()"
|
||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
{{ t.cancel }}
|
||||
</button>
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
<!-- Success Modal (shows client secret) -->
|
||||
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -271,16 +271,16 @@
|
||||
<h3 class="text-xl font-semibold text-white mb-2">
|
||||
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400 mb-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Client ID</label>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-300 overflow-x-auto"></code>
|
||||
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-700 dark:text-gray-300 overflow-x-auto"></code>
|
||||
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
@@ -289,7 +289,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Client Secret</label>
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
|
||||
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
||||
@@ -312,7 +312,7 @@
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-md mx-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-md mx-4">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -322,7 +322,7 @@
|
||||
<h3 class="text-xl font-semibold text-white mb-2">
|
||||
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-1">
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-1">
|
||||
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
||||
</p>
|
||||
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
||||
@@ -331,7 +331,7 @@
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick="closeDeleteModal()"
|
||||
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||
>
|
||||
{{ t.cancel }}
|
||||
</button>
|
||||
107
core/templates/dashboard/partials/head_assets.html
Normal file
107
core/templates/dashboard/partials/head_assets.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!-- Vazirmatn Font for Persian -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- HTMX for dynamic updates -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for simple interactions -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Theme Initialization -->
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||
|
||||
<!-- Global CSRF Interceptor -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
|
||||
// Intercept HTMX requests
|
||||
document.body.addEventListener('htmx:configRequest', (event) => {
|
||||
const token = getCsrfToken();
|
||||
if (token && event.detail.verb !== 'get') {
|
||||
event.detail.headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept standard fetch requests
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function (resource, init) {
|
||||
const token = getCsrfToken();
|
||||
if (token) {
|
||||
const url = typeof resource === 'string' ? new URL(resource, window.location.origin) : resource;
|
||||
if (url.origin === window.location.origin) {
|
||||
init = init || {};
|
||||
init.headers = init.headers || {};
|
||||
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.append('X-CSRF-Token', token);
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers.push(['X-CSRF-Token', token]);
|
||||
} else {
|
||||
init.headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalFetch.call(this, resource, init);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Vazirmatn font for Persian */
|
||||
html[lang="fa"],
|
||||
html[lang="fa"] *,
|
||||
[lang="fa"],
|
||||
[lang="fa"] * {
|
||||
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
90
core/templates/dashboard/profile.html
Normal file
90
core/templates/dashboard/profile.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||
{% block page_title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- User Info Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
{% if user and user.avatar_url %}
|
||||
<img src="{{ user.avatar_url }}" alt="{{ user.name or user.email }}" class="w-16 h-16 rounded-full border-2 border-purple-500">
|
||||
{% else %}
|
||||
<div class="w-16 h-16 rounded-full bg-purple-600 flex items-center justify-center text-white text-xl font-bold">
|
||||
{{ (session.name or session.email or "?")[0]|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ user.name or session.name or "User" }}</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ user.email or session.email }}</p>
|
||||
{% if user %}
|
||||
<div class="flex items-center mt-1 space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if user.provider == 'github' %}bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300{% else %}bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300{% endif %}">
|
||||
{% if user.provider == 'github' %}
|
||||
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/></svg>
|
||||
{% endif %}
|
||||
{{ user.provider|title }}
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300">
|
||||
{{ user.role|title }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Details -->
|
||||
{% if user %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}جزئیات حساب{% else %}Account Details{% endif %}
|
||||
</h3>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کاربر{% else %}User ID{% endif %}</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200 font-mono text-sm">{{ user.id[:8] }}...</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ارائهدهنده ورود{% else %}Login Provider{% endif %}</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{{ user.provider|title }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ عضویت{% else %}Joined{% endif %}</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{{ user.created_at[:10] if user.created_at else "Unknown" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین ورود{% else %}Last Login{% endif %}</dt>
|
||||
<dd class="text-gray-700 dark:text-gray-200">{{ user.last_login[:10] if user.last_login else "Never" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Account linking note -->
|
||||
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
{% if lang == 'fa' %}
|
||||
حساب شما به آدرس ایمیل {{ user.email }} متصل است. میتوانید با هر ارائهدهندهای که از این ایمیل استفاده میکند وارد شوید.
|
||||
{% else %}
|
||||
Your account is linked to {{ user.email }}. You can sign in with any provider that uses this email address.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="text-center">
|
||||
<a href="/auth/logout" class="inline-flex items-center px-4 py-2 text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 border border-red-200 dark:border-red-500/30 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
|
||||
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}خروج{% else %}Sign Out{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
||||
{% block page_title %}
|
||||
<div class="flex items-center">
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-400 hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
||||
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@@ -17,10 +17,10 @@
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Status Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
||||
<div class="flex items-center mt-2">
|
||||
{% if project.health.status == 'healthy' %}
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
@@ -50,18 +50,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
||||
{% elif project.health.last_check %}
|
||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tools Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ project.tools_count }}</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.tools_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -69,15 +69,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ project.api_keys_count }}</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.api_keys_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -91,11 +91,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Requests Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ project.requests_24h }}</p>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.requests_24h }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -103,7 +103,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,33 +112,33 @@
|
||||
<!-- Configuration -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Config Section -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="space-y-4">
|
||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
||||
<dd class="text-white font-mono">{{ project.full_id }}</dd>
|
||||
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-mono">{{ project.full_id }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||
<dt class="text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
||||
<dd class="text-white">{{ project.plugin_type|plugin_name }}</dd>
|
||||
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
||||
<dd class="text-white font-mono">{{ project.site_id }}</dd>
|
||||
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-mono">{{ project.site_id }}</dd>
|
||||
</div>
|
||||
{% if project.alias %}
|
||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||
<dt class="text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
||||
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
||||
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if project.url %}
|
||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||
<dt class="text-gray-400">URL</dt>
|
||||
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt class="text-gray-500 dark:text-gray-400">URL</dt>
|
||||
<dd>
|
||||
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
||||
{{ project.url }}
|
||||
@@ -150,22 +150,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between py-2">
|
||||
<dt class="text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
||||
<dd class="text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
||||
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
||||
<dd class="text-gray-900 dark:text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Tools -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
||||
<span class="px-2 py-1 bg-gray-700 rounded-lg text-sm text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
||||
<span class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||
</div>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50 sticky top-0">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
||||
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||
@@ -178,14 +178,14 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for tool in project.tools[:50] %}
|
||||
<tr class="hover:bg-gray-700/30">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2">
|
||||
<span class="text-sm text-white font-mono">{{ tool.name }}</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ tool.name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="text-sm text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
||||
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
@@ -213,9 +213,9 @@
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
||||
</div>
|
||||
<!-- Health Check Result Display -->
|
||||
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
||||
@@ -227,7 +227,7 @@
|
||||
hx-swap="none"
|
||||
hx-on::before-request="showHealthCheckLoading()"
|
||||
hx-on::after-request="handleHealthCheckResponse(event)"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
|
||||
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -241,7 +241,7 @@
|
||||
|
||||
<a
|
||||
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -251,7 +251,7 @@
|
||||
|
||||
<a
|
||||
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||
>
|
||||
<span>{% if lang == 'fa' %}مشاهده لاگها{% else %}View Logs{% endif %}</span>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -262,14 +262,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-700">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if project.recent_activity %}
|
||||
{% for activity in project.recent_activity[:5] %}
|
||||
<div class="p-4 hover:bg-gray-700/30">
|
||||
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
{% if activity.level == 'ERROR' %}
|
||||
@@ -277,7 +277,7 @@
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-white">{{ activity.message }}</span>
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ activity.message }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
||||
</div>
|
||||
@@ -343,19 +343,19 @@
|
||||
resultDiv.classList.add('bg-green-500/20');
|
||||
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
||||
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
||||
'</p><p class="text-sm text-gray-400">' +
|
||||
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' +
|
||||
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
||||
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
||||
} else if (response.status === 'unhealthy') {
|
||||
resultDiv.classList.add('bg-red-500/20');
|
||||
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
||||
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
||||
'</p><p class="text-sm text-gray-400">' + (response.message || '') + '</p>';
|
||||
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || '') + '</p>';
|
||||
} else {
|
||||
resultDiv.classList.add('bg-yellow-500/20');
|
||||
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
||||
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
||||
'</p><p class="text-sm text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
||||
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
||||
}
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
285
core/templates/dashboard/projects/list.html
Normal file
285
core/templates/dashboard/projects/list.html
Normal file
@@ -0,0 +1,285 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.projects }}{% endblock %}
|
||||
{% block page_title %}{{ t.projects }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||
<!-- Plugin Type Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||
<select name="plugin_type" onchange="this.form.submit()"
|
||||
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||
<option value="">{{ t.all }} Types</option>
|
||||
{% for plugin_type in available_plugin_types %}
|
||||
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
|
||||
{{ plugin_type|plugin_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select name="status" onchange="this.form.submit()"
|
||||
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||
<option value="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
||||
<option value="healthy" {% if selected_status=='healthy' %}selected{% endif %}>{{ t.healthy }}
|
||||
</option>
|
||||
<option value="warning" {% if selected_status=='warning' %}selected{% endif %}>{% if lang == 'fa'
|
||||
%}هشدار{% else %}Warning{% endif %}</option>
|
||||
<option value="unhealthy" {% if selected_status=='unhealthy' %}selected{% endif %}>{% if lang ==
|
||||
'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
||||
<option value="unknown" {% if selected_status=='unknown' %}selected{% endif %}>{% if lang == 'fa'
|
||||
%}نامشخص{% else %}Unknown{% endif %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input type="text" name="search" value="{{ search_query }}" placeholder="{{ t.search }}..."
|
||||
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t.search }}
|
||||
</button>
|
||||
|
||||
{% if search_query or selected_plugin_type or selected_status %}
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Projects Table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
URL
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% if projects %}
|
||||
{% for project in projects %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<!-- Plugin Type -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'bg-blue-500',
|
||||
'woocommerce': 'bg-purple-500',
|
||||
'wordpress_advanced': 'bg-indigo-500',
|
||||
'gitea': 'bg-green-500',
|
||||
'n8n': 'bg-orange-500',
|
||||
'supabase': 'bg-emerald-500',
|
||||
'openpanel': 'bg-cyan-500',
|
||||
'appwrite': 'bg-pink-500',
|
||||
'directus': 'bg-violet-500',
|
||||
} %}
|
||||
{% set plugin_icons = {
|
||||
'wordpress': 'W',
|
||||
'woocommerce': 'WC',
|
||||
'wordpress_advanced': 'WA',
|
||||
'gitea': 'G',
|
||||
'n8n': 'n8n',
|
||||
'supabase': 'SB',
|
||||
'openpanel': 'OP',
|
||||
'appwrite': 'AW',
|
||||
'directus': 'DI',
|
||||
} %}
|
||||
<div
|
||||
class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type,
|
||||
project.plugin_type[:2]|upper) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Site ID -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ project.site_id }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Alias -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.alias %}
|
||||
<span class="px-2 py-1 bg-primary-500/20 text-primary-400 text-xs rounded-lg font-medium">
|
||||
{{ project.alias }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500 text-sm">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- URL -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.url %}
|
||||
<a href="{{ project.url }}" target="_blank"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block"
|
||||
title="{{ project.url }}">
|
||||
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-500 text-sm">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.health_status == 'healthy' %}
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||
</div>
|
||||
{% elif project.health_status == 'warning' %}
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{%
|
||||
endif %}</span>
|
||||
</div>
|
||||
{% elif project.health_status == 'unhealthy' %}
|
||||
<div class="flex items-center" {% if project.reason %}title="{{ project.reason }}" {% endif
|
||||
%}>
|
||||
<span
|
||||
class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-red-400">
|
||||
{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}
|
||||
{% if project.reason %}
|
||||
<svg class="w-4 h-4 inline-block ml-1 opacity-70" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif
|
||||
%}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4">
|
||||
<a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{{ t.view }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||
</p>
|
||||
{% if search_query or selected_plugin_type %}
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{
|
||||
total_count }} پروژه
|
||||
{% else %}
|
||||
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of
|
||||
{{ total_count }} projects
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if page_number > 1 %}
|
||||
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == page_number %}
|
||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <=
|
||||
page_number + 2) %} <a
|
||||
href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
<span class="text-gray-500">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_number < total_pages %} <a
|
||||
href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
210
core/templates/dashboard/settings/index.html
Normal file
210
core/templates/dashboard/settings/index.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.settings }}{% endblock %}
|
||||
{% block page_title %}{{ t.settings }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Language Settings -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/dashboard/settings?lang=en"
|
||||
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||
English
|
||||
</a>
|
||||
<a href="/dashboard/settings?lang=fa"
|
||||
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||
فارسی
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
زبان فعلی: فارسی
|
||||
{% else %}
|
||||
Current language: English
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Configuration -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.server_mode }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.port }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.log_level }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Plugins -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
||||
</h3>
|
||||
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||
{% if lang == 'fa' %}قابلیت آینده{% else %}Coming Soon{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{% if plugins %}
|
||||
{% for plugin in plugins %}
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div>
|
||||
<p class="text-gray-900 dark:text-white font-medium">{{ plugin.name }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ plugin.description }}</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
||||
{{ t.active }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="bg-gray-100 dark:bg-gray-700/30 rounded-lg p-4">
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{% if lang == 'fa' %}
|
||||
سیستم پلاگین در فازهای آینده اضافه خواهد شد. این امکان به شما اجازه میدهد قابلیتهای سفارشی به MCP Hub اضافه کنید.
|
||||
{% else %}
|
||||
The plugin system will be added in future phases. This will allow you to extend MCP Hub with custom functionality.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-primary-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xl font-bold text-gray-900 dark:text-white">MCP Hub</h4>
|
||||
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ about.version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ about.mcp_version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ about.python_version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ about.tools_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
||||
{% else %}
|
||||
MCP Hub is a multi-purpose MCP server that enables connection to various services including WordPress, Gitea, n8n, and other services.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Information -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.created_at[:19] if session_display.created_at else '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/dashboard/logout"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
||||
{{ t.logout }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
151
core/templates/dashboard/sites/add.html
Normal file
151
core/templates/dashboard/sites/add.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.add_site }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.add_site }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
<!-- Plugin Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<select id="plugin_type" name="plugin_type" required
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onchange="updateFields()">
|
||||
<option value="">{{ t.select_plugin }}</option>
|
||||
{% for ptype, pname in plugin_names.items() %}
|
||||
<option value="{{ ptype }}">{{ pname }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required placeholder="https://example.com"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<!-- Alias -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||
minlength="2" maxlength="50"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.add_site }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const pluginFields = {{ plugin_fields_json| safe }};
|
||||
|
||||
function updateFields() {
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const container = document.getElementById('credential-fields');
|
||||
|
||||
if (!ptype || !pluginFields[ptype]) {
|
||||
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||
pluginFields[ptype].forEach(field => {
|
||||
const req = field.required ? 'required' : '';
|
||||
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
|
||||
html += `
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
|
||||
<input type="${field.type}" name="cred_${field.name}" ${req}
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="${field.label}">
|
||||
${hintHtml}
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errorEl = document.getElementById('error-msg');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
btn.textContent = '{{ t.adding_site }}';
|
||||
btn.disabled = true;
|
||||
|
||||
const ptype = document.getElementById('plugin_type').value;
|
||||
const url = document.getElementById('url').value;
|
||||
const alias = document.getElementById('alias').value;
|
||||
|
||||
// Collect credentials
|
||||
const creds = {};
|
||||
if (pluginFields[ptype]) {
|
||||
pluginFields[ptype].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_type: ptype,
|
||||
url: url,
|
||||
alias: alias,
|
||||
credentials: creds,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to add site';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.add_site }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Network error: ' + err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.add_site }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
143
core/templates/dashboard/sites/list.html
Normal file
143
core/templates/dashboard/sites/list.html
Normal file
@@ -0,0 +1,143 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.my_sites }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.my_sites }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Add Site button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.my_sites }}</h2>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium"
|
||||
title="{{ t.add_site }}">
|
||||
+ {{ t.add_site }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages -->
|
||||
{% if msg %}
|
||||
<div class="bg-green-50 dark:bg-green-500/20 border border-green-200 dark:border-green-500/50 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if sites %}
|
||||
<!-- Sites table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_alias }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.plugin_type }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_url }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.status }}</th>
|
||||
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for site in sites %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors" id="site-{{ site.id }}">
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ site.alias }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if site.status == 'active' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||
{% elif site.status == 'error' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||
{% elif site.status == 'pending' %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||
{% endif %}
|
||||
{% if site.status_msg %}
|
||||
<p class="text-xs text-gray-500 mt-1">{{ site.status_msg[:60] }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="testSite('{{ site.id }}')"
|
||||
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</button>
|
||||
<button onclick="deleteSite('{{ site.id }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{{ t.delete }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty state -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.no_sites }}</h3>
|
||||
<p class="text-gray-500 mb-6">{{ t.add_first_site }}</p>
|
||||
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="btn-primary px-6 py-2 rounded-lg text-white text-sm font-medium">
|
||||
+ {{ t.add_site }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function testSite(siteId) {
|
||||
const btn = document.getElementById('test-btn-' + siteId);
|
||||
btn.textContent = '{{ t.testing }}';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = '{{ t.connection_ok }}';
|
||||
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
} else {
|
||||
btn.textContent = data.message || '{{ t.connection_failed }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = '{{ t.error }}';
|
||||
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = '{{ t.test_connection }}';
|
||||
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function deleteSite(siteId) {
|
||||
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
document.getElementById('site-' + siteId).remove();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to delete site');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
core/templates/static/logo.svg
Normal file
1
core/templates/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024"><style>.a{fill:#51b9f4}.b{fill:#fec13d}</style><path class="a" d="m496.4 11.5q5.4-0.4 10.9-0.5 5.4-0.1 10.9 0.1 5.4 0.2 10.8 0.7 5.5 0.4 10.9 1.2c56.2 7.7 99.4 36 133.3 81.3 31 41.4 41 94.3 34.8 144.5-6.9 54.7-31.6 89.2-71.8 124.8-9.7 8.6-19.4 21-29.9 28.7 8 7.2 17.5 17 25.1 24.8 8.6-11.9 22.8-24.8 33.4-35.4 10.6-10.1 20.7-22.2 32.2-31.2 44.5-34.8 109.1-43.7 163.2-31.3 97.6 22.4 162.8 115.9 151.4 215.3-12.8 111.6-114.9 189.3-225.7 173.7-56.9-8-89.1-29.9-126.7-72.5-8.4-9.5-20.3-19-28.1-29.2-3 4.4-20.1 21-24.7 25.2q0.8 0.7 1.6 1.4c10.3 8.9 20.5 20.3 30 29.2 31.4 29.2 55.6 55.7 65.1 98.8 3 13.7 4.9 22.1 6.1 36.6 4.1 52.5-11.2 105-46 144.9-28.1 32.3-64.1 56.5-106.4 64.9-9.6 2-17.3 3.9-27.3 4.6-11.3 1.4-27.3 0.9-38.7-0.3-55.1-6.2-100.6-31.3-135.2-74.7-6.8-8.5-12.8-17.6-18.1-27.2-5.2-9.5-9.7-19.4-13.3-29.7-3.6-10.3-6.3-20.8-8.1-31.6-1.9-10.7-2.8-21.5-2.8-32.4-0.3-41.9 11.1-88.4 38.8-121 5-5.8 10.5-11.8 16.2-17.1 15.8-14.8 31.2-33.3 48-46.7-7.7-8-17.1-16.5-24.2-24.6-8.4 9.8-18.5 19-27.4 28.3-10.5 10.8-20.7 21.7-31.8 31.9-36.3 32.8-85.8 44.5-133.6 42.7-12.8-0.5-25.5-2.3-38-5.3-12.5-3-24.6-7.3-36.2-12.7-11.7-5.4-22.7-11.9-33.1-19.4-10.3-7.6-19.9-16.2-28.6-25.6-46.8-50.7-62.6-116.6-46.9-183.1 8.7-41.1 30.3-72.1 60.7-100.4 64.9-60.6 180.9-67.1 250.4-10.7 10.9 8.8 22.5 21.5 32.4 31.7 10.5 10.9 21.9 21.7 32.1 33 8.1-11 14.4-15.9 23.9-24.9-37.7-41.9-77.8-63.2-94-122.1-17.9-65.2-8.7-131.9 34.5-185.2 37.9-46.8 81.1-66.8 139.9-73.5zm-44.5 380.8q-5.1 5.1-10.1 10.1-5.1 5-10.2 10.1-5 5-10.1 10.1-5 5-10 10.1c-3.7 3.7-16.3 16.8-20.1 19.1-12.9-14.3-28.9-27.9-41.3-41.9-35.7-40-68.4-67.4-124.3-70.7-53.9-3.2-94.4 9.6-135 46-8.2 7.5-15.6 15.7-22.2 24.7-6.6 8.9-12.3 18.5-16.9 28.5-4.7 10.1-8.4 20.6-11.1 31.4-2.6 10.7-4.2 21.7-4.6 32.8-6.6 133.7 129.3 221.3 249 166.3 3.7-2.1 7.3-4.3 10.8-6.5 10.9-6.8 20.8-15.5 30.3-24.1 8.2-7.5 14.8-16.4 22.4-24.5 13.8-14.7 29-28.3 43.3-42.4 4.5 5.4 11.9 12.5 17.1 17.6l28.4 28.4c3.5 3.5 11.1 10.7 14.1 14.3-7.4 9.1-18.4 18.4-26.7 27.1-18.8 19.6-40.3 36.2-56.9 57.9-24.9 32.5-31.7 75.7-28.5 115.7 4 47.7 27.3 87 63.8 117.4 6.4 5.3 13.3 10.2 20.5 14.5 7.2 4.3 14.8 8 22.5 11.2 7.8 3.2 15.8 5.8 23.9 7.8 8.2 2 16.5 3.4 24.8 4.2 7.8 0.6 17.8 0.5 25.6 0.3 35.2-1.1 68.9-14.1 95.9-36.2 8.1-6.7 12.7-9.6 20.1-17.7 33.7-36.9 49.6-74.3 48.1-124.8-1.2-38-9.3-69-34.2-98.8-10.3-12.4-23-23.8-34.6-34.8-14.7-14-29.1-29.5-43.8-43.4l-0.3-0.3c5.7-7.1 11.7-13.2 18.2-19.6 13.7-13.5 26.9-27.5 41.1-40.5l0.6 0.6c9.5 7.9 18.7 19.1 27.8 27.7 18.6 17.9 34.5 38.1 54.9 54.1 33.2 26.3 76.5 33.7 117.8 30.3 52.5-4.3 98.3-36.2 127.8-78.8 18.8-27.3 28.5-62 27.5-95.2-0.2-5 0-9.8-0.4-14.8-1-11.4-3.1-22.6-6.3-33.5-3.2-10.9-7.6-21.5-12.9-31.6-5.4-10-11.7-19.5-19-28.3-7.2-8.8-15.4-16.8-24.2-24-40.2-32.9-81.7-43.3-132.6-38.3-50.9 4.9-82 28.5-114.5 65.8-6.4 7.3-15 14.7-21.6 22-5.5 5.4-19.7 20.8-25 24.3-19.2-20.2-40.1-39.5-59.3-59.7 12.5-15 27.9-29.5 41.9-43.3 5.9-5.8 13.8-11.6 20.3-18.1 32.1-32.4 48.9-63.7 50.5-110 0.4-10.8 0.6-19.1-0.4-30.1-1.1-11.2-3.3-22.3-6.6-33.1-3.3-10.7-7.6-21.2-13-31.1-5.3-9.9-11.7-19.3-18.9-27.9-7.2-8.7-15.3-16.6-24-23.7-29.7-24.3-61.1-36.9-99.7-39-4.5-0.2-14.1-0.4-18.5 0.1-49.6 3.5-86.3 20.5-120.3 57.4-33.6 36.6-46.8 80.9-43.8 130 2.7 45.3 18.5 77.4 51.2 108.8 20 19.3 42.8 39.8 61.7 60z"/><path class="b" d="m210.1 432.1c43.8 0.6 78.9 36.4 78.7 80.1-0.3 43.8-35.9 79.2-79.6 79.2-44.3 0-80-35.9-79.8-80.1 0.3-44.2 36.5-79.8 80.7-79.2zm1.1 134.1c30-1.2 53.3-26.3 52.3-56.3-1-29.9-26-53.4-55.9-52.6-30.3 0.8-54.1 26-53.1 56.3 1 30.2 26.5 53.9 56.7 52.6z"/><path class="b" d="m503.9 129.7c43.8-4.5 82.9 27.4 87.3 71.2 4.4 43.8-27.5 82.9-71.3 87.3-43.8 4.3-82.8-27.6-87.2-71.3-4.4-43.8 27.5-82.8 71.2-87.2zm12.9 133.5c29.9-2.7 52-29.1 49.4-59.1-2.7-30-29.1-52.1-59.1-49.5-30 2.6-52.3 29.1-49.6 59.1 2.7 30.1 29.2 52.3 59.3 49.5z"/><path class="b" d="m503.3 735.5c43.8-4.8 83.1 26.9 87.9 70.7 4.7 43.7-27 83-70.8 87.7-43.7 4.6-82.9-27-87.6-70.7-4.7-43.7 26.9-82.9 70.5-87.7zm12.5 133.5c29.9-2.1 52.5-28 50.5-58-2-30-27.8-52.7-57.8-50.8-30.2 1.8-53.1 27.9-51.1 58 2 30.2 28.2 53 58.4 50.8z"/><path class="b" d="m805.8 432.7c43.7-5 83.3 26.4 88.2 70.2 4.9 43.7-26.6 83.2-70.3 88-43.7 4.9-83-26.5-87.9-70.2-5-43.6 26.4-83 70-88zm11.6 133.6c29.9-1.5 53-26.9 51.8-56.8-1.3-30-26.5-53.3-56.4-52.2-30.3 1-53.9 26.5-52.7 56.8 1.3 30.3 27 53.7 57.3 52.2z"/><path class="a" d="m511 451.9c3.1 0.9 33.1 32.8 37.8 36.9 2.7 2.4 19.9 19.4 22.3 22.7-2.6 4.7-53.2 55.7-59.3 60.2-0.2 0-0.4 0-0.5-0.1-2.1-0.8-56.2-54.4-59.3-59.3 1.4-4.2 52.5-54.7 59-60.4zm-24.9 59.5c3.3 4.6 19.8 20.5 24.8 25.4 0.5 0 1 0.1 1.4 0 8-8.8 16-16.6 24.6-24.8-3-3-24.1-24.6-25.8-25.3-3.7 4.3-21 21.9-25 24.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -448,12 +448,15 @@ class ToolGenerator:
|
||||
|
||||
except Exception as e:
|
||||
# Import custom exceptions for better error handling
|
||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
||||
from plugins.wordpress.client import (
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ConnectionError,
|
||||
)
|
||||
|
||||
error_type = type(e).__name__
|
||||
|
||||
if isinstance(e, ConfigurationError):
|
||||
# Configuration error - likely missing env vars
|
||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||
return (
|
||||
f"Configuration Error: {str(e)}\n\n"
|
||||
@@ -464,12 +467,14 @@ class ToolGenerator:
|
||||
)
|
||||
|
||||
elif isinstance(e, AuthenticationError):
|
||||
# Authentication error - 401/403
|
||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||
return f"Authentication Error: {str(e)}"
|
||||
|
||||
elif isinstance(e, ConnectionError):
|
||||
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
|
||||
return f"Connection Error: {str(e)}"
|
||||
|
||||
else:
|
||||
# Unexpected error
|
||||
logger.error(
|
||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||
exc_info=True,
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
"""
|
||||
Unified Tool Generator
|
||||
|
||||
Generates context-based tools that work across multiple sites.
|
||||
Maintains backward compatibility by keeping per-site tools.
|
||||
|
||||
Architecture:
|
||||
- Old: wordpress_site1_get_post(post_id)
|
||||
- New: wordpress_get_post(site, post_id)
|
||||
- Both work simultaneously!
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from core.site_registry import get_site_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedToolGenerator:
|
||||
"""
|
||||
Generates unified tools from per-site tool definitions.
|
||||
|
||||
Takes existing plugin tools and creates context-based versions
|
||||
that accept a 'site' parameter for dynamic routing.
|
||||
"""
|
||||
|
||||
def __init__(self, project_manager):
|
||||
"""
|
||||
Initialize unified tool generator.
|
||||
|
||||
Args:
|
||||
project_manager: ProjectManager instance with discovered projects
|
||||
"""
|
||||
self.project_manager = project_manager
|
||||
self.site_registry = get_site_registry()
|
||||
self.logger = logging.getLogger("UnifiedToolGenerator")
|
||||
|
||||
def generate_unified_tools(self, plugin_type: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate unified tools for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
|
||||
Returns:
|
||||
List of unified tool definitions
|
||||
"""
|
||||
# Get all projects of this type
|
||||
projects = self.project_manager.get_projects_by_type(plugin_type)
|
||||
|
||||
if not projects:
|
||||
self.logger.warning(f"No projects found for plugin type: {plugin_type}")
|
||||
return []
|
||||
|
||||
# Use the first project as a template to get tool definitions
|
||||
first_project_id = list(projects.keys())[0]
|
||||
template_plugin = projects[first_project_id]
|
||||
template_tools = template_plugin.get_tools()
|
||||
|
||||
self.logger.info(
|
||||
f"Generating unified tools for {plugin_type} "
|
||||
f"from {len(template_tools)} template tools"
|
||||
)
|
||||
|
||||
unified_tools = []
|
||||
seen_actions = set()
|
||||
|
||||
for tool in template_tools:
|
||||
# Extract action name from per-site tool name
|
||||
# e.g., "wordpress_site1_get_post" -> "get_post"
|
||||
tool_name = tool["name"]
|
||||
parts = tool_name.split("_")
|
||||
|
||||
# Skip if not in expected format
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
# Extract action (everything after plugin_type_site_id_)
|
||||
# e.g., wordpress_site1_get_post -> get_post
|
||||
action = "_".join(parts[2:])
|
||||
|
||||
# Skip duplicates (we only need one unified tool per action)
|
||||
if action in seen_actions:
|
||||
continue
|
||||
seen_actions.add(action)
|
||||
|
||||
# Create unified tool
|
||||
unified_tool = self._create_unified_tool(
|
||||
plugin_type=plugin_type, action=action, template_tool=tool
|
||||
)
|
||||
|
||||
if unified_tool:
|
||||
unified_tools.append(unified_tool)
|
||||
|
||||
self.logger.info(f"Generated {len(unified_tools)} unified tools for {plugin_type}")
|
||||
return unified_tools
|
||||
|
||||
def _create_unified_tool(
|
||||
self, plugin_type: str, action: str, template_tool: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Create a unified tool from a template.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type (e.g., 'wordpress')
|
||||
action: Action name (e.g., 'get_post')
|
||||
template_tool: Original per-site tool definition
|
||||
|
||||
Returns:
|
||||
Unified tool definition
|
||||
"""
|
||||
try:
|
||||
# Create unified tool name
|
||||
unified_name = f"{plugin_type}_{action}"
|
||||
|
||||
# Get available sites for this plugin type
|
||||
site_options = self.site_registry.get_site_options(plugin_type)
|
||||
|
||||
if not site_options:
|
||||
self.logger.warning(f"No sites available for {plugin_type}, skipping {action}")
|
||||
return None
|
||||
|
||||
# Modify input schema to add 'site' parameter
|
||||
original_schema = template_tool.get("inputSchema", {})
|
||||
unified_schema = self._add_site_parameter(original_schema, plugin_type, site_options)
|
||||
|
||||
# Update description to mention site parameter
|
||||
original_description = template_tool.get("description", "")
|
||||
unified_description = self._update_description(original_description, plugin_type)
|
||||
|
||||
# Create wrapper handler
|
||||
original_handler = template_tool.get("handler")
|
||||
unified_handler = self._create_unified_handler(plugin_type, action, original_handler)
|
||||
|
||||
return {
|
||||
"name": unified_name,
|
||||
"description": unified_description,
|
||||
"inputSchema": unified_schema,
|
||||
"handler": unified_handler,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error creating unified tool for {plugin_type}_{action}: {e}", exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
def _add_site_parameter(
|
||||
self, original_schema: dict[str, Any], plugin_type: str, site_options: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add 'site' parameter to input schema.
|
||||
|
||||
Args:
|
||||
original_schema: Original input schema
|
||||
plugin_type: Plugin type
|
||||
site_options: Available site IDs/aliases
|
||||
|
||||
Returns:
|
||||
Modified schema with site parameter
|
||||
"""
|
||||
# Deep copy to avoid modifying original
|
||||
import copy
|
||||
|
||||
schema = copy.deepcopy(original_schema)
|
||||
|
||||
# Ensure schema has required structure
|
||||
if "properties" not in schema:
|
||||
schema["properties"] = {}
|
||||
if "required" not in schema:
|
||||
schema["required"] = []
|
||||
|
||||
# Add 'site' as first parameter
|
||||
schema["properties"] = {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"Site ID or alias. Available options: {', '.join(site_options)}. "
|
||||
f"Use list_projects() to see all configured sites."
|
||||
),
|
||||
"enum": site_options,
|
||||
},
|
||||
**schema["properties"],
|
||||
}
|
||||
|
||||
# Make 'site' required
|
||||
if "site" not in schema["required"]:
|
||||
schema["required"].insert(0, "site")
|
||||
|
||||
return schema
|
||||
|
||||
def _update_description(self, original_description: str, plugin_type: str) -> str:
|
||||
"""
|
||||
Update tool description to mention unified context.
|
||||
|
||||
Args:
|
||||
original_description: Original description
|
||||
plugin_type: Plugin type
|
||||
|
||||
Returns:
|
||||
Updated description
|
||||
"""
|
||||
# Remove site-specific mentions (e.g., "from site1", "in site2")
|
||||
import re
|
||||
|
||||
cleaned = re.sub(
|
||||
r"\b(?:from|in|for)\s+site\d+\b", "", original_description, flags=re.IGNORECASE
|
||||
)
|
||||
cleaned = re.sub(
|
||||
r"\bsite\d+\b", f"the specified {plugin_type} site", cleaned, flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
# Add unified context note
|
||||
prefix = "[UNIFIED] "
|
||||
if not cleaned.startswith(prefix):
|
||||
cleaned = prefix + cleaned
|
||||
|
||||
return cleaned.strip()
|
||||
|
||||
def _create_unified_handler(
|
||||
self, plugin_type: str, action: str, original_handler: Callable | None
|
||||
) -> Callable:
|
||||
"""
|
||||
Create a unified handler that routes to the correct site.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
action: Action name
|
||||
original_handler: Original handler (not used, we call plugin method directly)
|
||||
|
||||
Returns:
|
||||
Async handler function
|
||||
"""
|
||||
|
||||
async def unified_handler(site: str, **kwargs):
|
||||
"""
|
||||
Unified handler that routes to the correct site plugin.
|
||||
|
||||
Args:
|
||||
site: Site ID or alias
|
||||
**kwargs: Other parameters for the tool
|
||||
"""
|
||||
try:
|
||||
# Get site info from registry
|
||||
site_info = self.site_registry.get_site(plugin_type, site)
|
||||
|
||||
if not site_info:
|
||||
available = self.site_registry.get_site_options(plugin_type)
|
||||
return (
|
||||
f"Error: Site '{site}' not found for {plugin_type}. "
|
||||
f"Available sites: {', '.join(available)}"
|
||||
)
|
||||
|
||||
# Get the plugin instance
|
||||
full_id = site_info.get_full_id()
|
||||
|
||||
# SECURITY: Check if API key has access to this project
|
||||
from core.context import get_api_key_context
|
||||
|
||||
api_key_info = get_api_key_context()
|
||||
|
||||
if api_key_info and not api_key_info.get("is_global"):
|
||||
# Per-project key - must match the project
|
||||
allowed_project = api_key_info.get("project_id")
|
||||
|
||||
# Resolve allowed_project to normalize alias vs site_id
|
||||
# API key might have been created with alias (wordpress_myblog)
|
||||
# or site_id (wordpress_site1)
|
||||
allowed_project_normalized = allowed_project
|
||||
if allowed_project and "_" in allowed_project:
|
||||
# Extract plugin type and site identifier from allowed_project
|
||||
allowed_parts = allowed_project.split("_", 1)
|
||||
if len(allowed_parts) == 2:
|
||||
allowed_plugin_type, allowed_site_identifier = allowed_parts
|
||||
# Try to resolve the site identifier to site_id
|
||||
try:
|
||||
allowed_site_info = self.site_registry.get_site(
|
||||
allowed_plugin_type, allowed_site_identifier
|
||||
)
|
||||
if allowed_site_info:
|
||||
# Normalize to plugin_type_site_id format
|
||||
allowed_project_normalized = allowed_site_info.get_full_id()
|
||||
except (ValueError, Exception):
|
||||
# Site not found, keep original for error message
|
||||
pass
|
||||
|
||||
if allowed_project_normalized != full_id:
|
||||
logger.warning(
|
||||
f"Access denied: API key for project '{allowed_project}' "
|
||||
f"attempted to access '{full_id}'"
|
||||
)
|
||||
return (
|
||||
f"Error: Access denied. This API key is restricted to project '{allowed_project}'. "
|
||||
f"Use a global API key or create a key for '{full_id}'."
|
||||
)
|
||||
|
||||
plugin = self.project_manager.get_project(full_id)
|
||||
|
||||
if not plugin:
|
||||
return f"Error: Plugin instance not found for {full_id}"
|
||||
|
||||
# Find the original handler method in the plugin
|
||||
# The original per-site tool name was: {plugin_type}_{site_id}_{action}
|
||||
original_tool_name = f"{plugin_type}_{site_info.site_id}_{action}"
|
||||
|
||||
# Get all tools from plugin and find the matching handler
|
||||
tools = plugin.get_tools()
|
||||
handler = None
|
||||
|
||||
for tool in tools:
|
||||
if tool["name"] == original_tool_name:
|
||||
handler = tool.get("handler")
|
||||
break
|
||||
|
||||
if not handler:
|
||||
return (
|
||||
f"Error: Handler not found for {original_tool_name}. "
|
||||
f"This may be a plugin implementation issue."
|
||||
)
|
||||
|
||||
# Filter out None values from kwargs to avoid validation errors
|
||||
# WordPress API doesn't accept None values in query parameters
|
||||
filtered_kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
||||
|
||||
# Call the handler with filtered kwargs
|
||||
result = await handler(**filtered_kwargs)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in unified handler for {plugin_type}_{action}: {e}", exc_info=True
|
||||
)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
return unified_handler
|
||||
|
||||
def generate_all_unified_tools(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate unified tools for all registered plugin types.
|
||||
|
||||
Returns:
|
||||
List of all unified tool definitions
|
||||
"""
|
||||
all_tools = []
|
||||
|
||||
# Get all plugin types from registry
|
||||
from plugins import registry
|
||||
|
||||
plugin_types = registry.get_registered_types()
|
||||
|
||||
for plugin_type in plugin_types:
|
||||
tools = self.generate_unified_tools(plugin_type)
|
||||
all_tools.extend(tools)
|
||||
|
||||
self.logger.info(
|
||||
f"Generated {len(all_tools)} total unified tools "
|
||||
f"across {len(plugin_types)} plugin types"
|
||||
)
|
||||
|
||||
return all_tools
|
||||
483
core/user_auth.py
Normal file
483
core/user_auth.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""User authentication system with OAuth Social Login (GitHub + Google).
|
||||
|
||||
Handles OAuth 2.0 authorization flows, token exchange, user info fetching,
|
||||
session creation (JWT cookies), and registration rate limiting.
|
||||
|
||||
This module is for user-facing authentication (Track E.2). The existing
|
||||
admin authentication (core/auth.py, core/dashboard/auth.py) is unchanged.
|
||||
|
||||
Usage:
|
||||
auth = initialize_user_auth(
|
||||
github_client_id="...",
|
||||
github_client_secret="...",
|
||||
public_url="https://mcp.example.com",
|
||||
)
|
||||
url, state = auth.get_authorization_url("github")
|
||||
# ... user redirected, callback received ...
|
||||
user_info = await auth.exchange_code("github", code)
|
||||
token = auth.create_user_session(user_id="...", email="...", name="...", role="user")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OAuth provider endpoints
|
||||
_GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
|
||||
_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
_GITHUB_USER_URL = "https://api.github.com/user"
|
||||
_GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
|
||||
|
||||
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||
|
||||
# State expiry: 10 minutes
|
||||
_STATE_EXPIRY_SECONDS = 600
|
||||
|
||||
# Registration rate limit: 3 per IP per hour
|
||||
_REG_RATE_LIMIT = 3
|
||||
_REG_RATE_WINDOW = 3600 # 1 hour
|
||||
|
||||
|
||||
class OAuthProvider:
|
||||
"""OAuth provider constants (for type hints / constants)."""
|
||||
|
||||
GITHUB = "github"
|
||||
GOOGLE = "google"
|
||||
|
||||
|
||||
class UserAuth:
|
||||
"""OAuth Social Login and user session management.
|
||||
|
||||
Manages OAuth authorization URL generation, code-for-token exchange,
|
||||
user info fetching, JWT session creation/validation, and registration
|
||||
rate limiting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
github_client_id: str | None = None,
|
||||
github_client_secret: str | None = None,
|
||||
google_client_id: str | None = None,
|
||||
google_client_secret: str | None = None,
|
||||
public_url: str | None = None,
|
||||
session_secret: str | None = None,
|
||||
session_expiry_hours: int = 168, # 7 days
|
||||
) -> None:
|
||||
"""Initialize user authentication.
|
||||
|
||||
Args:
|
||||
github_client_id: GitHub OAuth App client ID.
|
||||
github_client_secret: GitHub OAuth App client secret.
|
||||
google_client_id: Google OAuth client ID.
|
||||
google_client_secret: Google OAuth client secret.
|
||||
public_url: Public-facing URL of the server (for callbacks).
|
||||
session_secret: Secret key for JWT session signing.
|
||||
session_expiry_hours: Session token lifetime in hours.
|
||||
"""
|
||||
self._github_client_id = github_client_id
|
||||
self._github_client_secret = github_client_secret
|
||||
self._google_client_id = google_client_id
|
||||
self._google_client_secret = google_client_secret
|
||||
self._public_url = (public_url or "").rstrip("/")
|
||||
|
||||
self._session_secret = session_secret or os.getenv(
|
||||
"DASHBOARD_SESSION_SECRET",
|
||||
os.getenv("OAUTH_JWT_SECRET_KEY", secrets.token_hex(32)),
|
||||
)
|
||||
self._session_expiry_hours = int(
|
||||
os.getenv("SESSION_EXPIRY_HOURS", str(session_expiry_hours))
|
||||
)
|
||||
|
||||
# CSRF state tokens: state -> timestamp
|
||||
self._pending_states: dict[str, float] = {}
|
||||
|
||||
# Registration rate limiting: IP -> [timestamps]
|
||||
self._registration_records: dict[str, list[float]] = {}
|
||||
|
||||
providers = self.available_providers()
|
||||
if providers and not self._public_url:
|
||||
logger.warning(
|
||||
"OAuth providers configured (%s) but PUBLIC_URL is not set. "
|
||||
"OAuth login will fail until PUBLIC_URL is configured.",
|
||||
providers,
|
||||
)
|
||||
logger.info(
|
||||
"UserAuth initialized: providers=%s, session_expiry=%dh",
|
||||
providers,
|
||||
self._session_expiry_hours,
|
||||
)
|
||||
|
||||
# ── Provider availability ─────────────────────────────────
|
||||
|
||||
def available_providers(self) -> list[str]:
|
||||
"""Return list of configured OAuth providers.
|
||||
|
||||
Returns:
|
||||
List of provider name strings (e.g. ["github", "google"]).
|
||||
"""
|
||||
providers: list[str] = []
|
||||
if self._github_client_id and self._github_client_secret:
|
||||
providers.append("github")
|
||||
if self._google_client_id and self._google_client_secret:
|
||||
providers.append("google")
|
||||
return providers
|
||||
|
||||
# ── OAuth URL generation ──────────────────────────────────
|
||||
|
||||
def get_authorization_url(self, provider: str) -> tuple[str, str]:
|
||||
"""Generate OAuth authorization URL with CSRF state.
|
||||
|
||||
Args:
|
||||
provider: "github" or "google".
|
||||
|
||||
Returns:
|
||||
Tuple of (authorization_url, state_token).
|
||||
|
||||
Raises:
|
||||
ValueError: If provider is unsupported or not configured.
|
||||
"""
|
||||
if not self._public_url:
|
||||
raise ValueError(
|
||||
"PUBLIC_URL environment variable must be set for OAuth login to work. "
|
||||
"Example: PUBLIC_URL=https://mcp.example.com"
|
||||
)
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
self._pending_states[state] = time.time()
|
||||
self._cleanup_expired_states()
|
||||
|
||||
callback_url = f"{self._public_url}/auth/callback/{provider}"
|
||||
|
||||
if provider == "github":
|
||||
if not self._github_client_id:
|
||||
raise ValueError("GitHub OAuth not configured")
|
||||
params = {
|
||||
"client_id": self._github_client_id,
|
||||
"redirect_uri": callback_url,
|
||||
"scope": "read:user user:email",
|
||||
"state": state,
|
||||
}
|
||||
return f"{_GITHUB_AUTH_URL}?{urlencode(params)}", state
|
||||
|
||||
if provider == "google":
|
||||
if not self._google_client_id:
|
||||
raise ValueError("Google OAuth not configured")
|
||||
params = {
|
||||
"client_id": self._google_client_id,
|
||||
"redirect_uri": callback_url,
|
||||
"scope": "openid email profile",
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"access_type": "online",
|
||||
}
|
||||
return f"{_GOOGLE_AUTH_URL}?{urlencode(params)}", state
|
||||
|
||||
raise ValueError(f"Unsupported provider: {provider}")
|
||||
|
||||
# ── State validation ──────────────────────────────────────
|
||||
|
||||
def validate_state(self, state: str) -> bool:
|
||||
"""Validate and consume an OAuth state token (one-time use).
|
||||
|
||||
Args:
|
||||
state: The state parameter from the callback.
|
||||
|
||||
Returns:
|
||||
True if valid and not expired, False otherwise.
|
||||
"""
|
||||
timestamp = self._pending_states.pop(state, None)
|
||||
if timestamp is None:
|
||||
return False
|
||||
return not (time.time() - timestamp > _STATE_EXPIRY_SECONDS)
|
||||
|
||||
def _cleanup_expired_states(self) -> None:
|
||||
"""Remove expired state tokens."""
|
||||
now = time.time()
|
||||
expired = [s for s, t in self._pending_states.items() if now - t > _STATE_EXPIRY_SECONDS]
|
||||
for s in expired:
|
||||
del self._pending_states[s]
|
||||
|
||||
# ── Token exchange ────────────────────────────────────────
|
||||
|
||||
async def exchange_code(self, provider: str, code: str) -> dict:
|
||||
"""Exchange authorization code for user info.
|
||||
|
||||
Args:
|
||||
provider: "github" or "google".
|
||||
code: Authorization code from callback.
|
||||
|
||||
Returns:
|
||||
Dict with keys: provider, provider_id, email, name, avatar_url.
|
||||
|
||||
Raises:
|
||||
ValueError: If token exchange or user info fetch fails.
|
||||
"""
|
||||
if provider == "github":
|
||||
return await self._exchange_github(code)
|
||||
if provider == "google":
|
||||
return await self._exchange_google(code)
|
||||
raise ValueError(f"Unsupported provider: {provider}")
|
||||
|
||||
async def _exchange_github(self, code: str) -> dict:
|
||||
"""Exchange GitHub authorization code for user info.
|
||||
|
||||
Args:
|
||||
code: GitHub authorization code.
|
||||
|
||||
Returns:
|
||||
Dict with provider, provider_id, email, name, avatar_url.
|
||||
"""
|
||||
callback_url = f"{self._public_url}/auth/callback/github"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Exchange code for access token
|
||||
token_resp = await client.post(
|
||||
_GITHUB_TOKEN_URL,
|
||||
data={
|
||||
"client_id": self._github_client_id,
|
||||
"client_secret": self._github_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": callback_url,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
raise ValueError(f"Failed to exchange GitHub code: {token_resp.text}")
|
||||
|
||||
token_data = token_resp.json()
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError(f"Failed to exchange GitHub code: {token_data}")
|
||||
|
||||
# Fetch user info
|
||||
user_resp = await client.get(
|
||||
_GITHUB_USER_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
user_data = user_resp.json()
|
||||
|
||||
email = user_data.get("email")
|
||||
if not email:
|
||||
# Fetch from /user/emails endpoint (private email fallback)
|
||||
emails_resp = await client.get(
|
||||
_GITHUB_EMAILS_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if emails_resp.status_code == 200:
|
||||
emails = emails_resp.json()
|
||||
primary = next(
|
||||
(e for e in emails if e.get("primary") and e.get("verified")),
|
||||
None,
|
||||
)
|
||||
if primary:
|
||||
email = primary["email"]
|
||||
elif emails:
|
||||
email = emails[0]["email"]
|
||||
|
||||
return {
|
||||
"provider": "github",
|
||||
"provider_id": str(user_data["id"]),
|
||||
"email": email,
|
||||
"name": user_data.get("name") or user_data.get("login"),
|
||||
"avatar_url": user_data.get("avatar_url"),
|
||||
}
|
||||
|
||||
async def _exchange_google(self, code: str) -> dict:
|
||||
"""Exchange Google authorization code for user info.
|
||||
|
||||
Args:
|
||||
code: Google authorization code.
|
||||
|
||||
Returns:
|
||||
Dict with provider, provider_id, email, name, avatar_url.
|
||||
"""
|
||||
callback_url = f"{self._public_url}/auth/callback/google"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Exchange code for access token
|
||||
token_resp = await client.post(
|
||||
_GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"client_id": self._google_client_id,
|
||||
"client_secret": self._google_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": callback_url,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
raise ValueError(f"Failed to exchange Google code: {token_resp.text}")
|
||||
|
||||
token_data = token_resp.json()
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise ValueError(f"Failed to exchange Google code: {token_data}")
|
||||
|
||||
# Fetch user info
|
||||
user_resp = await client.get(
|
||||
_GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
user_data = user_resp.json()
|
||||
|
||||
return {
|
||||
"provider": "google",
|
||||
"provider_id": str(user_data["sub"]),
|
||||
"email": user_data.get("email"),
|
||||
"name": user_data.get("name"),
|
||||
"avatar_url": user_data.get("picture"),
|
||||
}
|
||||
|
||||
# ── Registration rate limiting ────────────────────────────
|
||||
|
||||
def check_registration_rate(self, client_ip: str) -> bool:
|
||||
"""Check if IP is within registration rate limit.
|
||||
|
||||
Allows up to 3 registrations per IP per hour. Expired records
|
||||
are cleaned up automatically.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address.
|
||||
|
||||
Returns:
|
||||
True if registration is allowed, False if rate limited.
|
||||
"""
|
||||
now = time.time()
|
||||
records = self._registration_records.get(client_ip, [])
|
||||
# Keep only records within the window
|
||||
records = [t for t in records if now - t < _REG_RATE_WINDOW]
|
||||
self._registration_records[client_ip] = records
|
||||
return len(records) < _REG_RATE_LIMIT
|
||||
|
||||
def record_registration(self, client_ip: str) -> None:
|
||||
"""Record a registration event for rate limiting.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address.
|
||||
"""
|
||||
if client_ip not in self._registration_records:
|
||||
self._registration_records[client_ip] = []
|
||||
self._registration_records[client_ip].append(time.time())
|
||||
|
||||
# ── Session management ────────────────────────────────────
|
||||
|
||||
def create_user_session(
|
||||
self,
|
||||
user_id: str,
|
||||
email: str,
|
||||
name: str | None,
|
||||
role: str,
|
||||
) -> str:
|
||||
"""Create a JWT session token for an OAuth user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID from database.
|
||||
email: User email.
|
||||
name: User display name.
|
||||
role: User role ('user' or 'admin').
|
||||
|
||||
Returns:
|
||||
JWT token string.
|
||||
"""
|
||||
now = time.time()
|
||||
payload = {
|
||||
"uid": user_id,
|
||||
"email": email,
|
||||
"name": name or "",
|
||||
"role": role,
|
||||
"type": "oauth_user",
|
||||
"iat": now,
|
||||
"exp": now + self._session_expiry_hours * 3600,
|
||||
}
|
||||
return jwt.encode(payload, self._session_secret, algorithm="HS256")
|
||||
|
||||
def validate_user_session(self, token: str) -> dict | None:
|
||||
"""Validate a user session JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string.
|
||||
|
||||
Returns:
|
||||
Dict with user_id, email, name, role, type -- or None if invalid.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, self._session_secret, algorithms=["HS256"])
|
||||
return {
|
||||
"user_id": payload["uid"],
|
||||
"email": payload["email"],
|
||||
"name": payload.get("name", ""),
|
||||
"role": payload.get("role", "user"),
|
||||
"type": payload.get("type", "oauth_user"),
|
||||
}
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug("User session expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.debug("Invalid user session token: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────
|
||||
|
||||
_user_auth: UserAuth | None = None
|
||||
|
||||
|
||||
def initialize_user_auth(
|
||||
github_client_id: str | None = None,
|
||||
github_client_secret: str | None = None,
|
||||
google_client_id: str | None = None,
|
||||
google_client_secret: str | None = None,
|
||||
public_url: str | None = None,
|
||||
**kwargs,
|
||||
) -> UserAuth:
|
||||
"""Initialize the global UserAuth singleton.
|
||||
|
||||
Reads from env vars if arguments are not provided:
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET,
|
||||
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
||||
PUBLIC_URL.
|
||||
|
||||
Args:
|
||||
github_client_id: GitHub OAuth App client ID.
|
||||
github_client_secret: GitHub OAuth App client secret.
|
||||
google_client_id: Google OAuth client ID.
|
||||
google_client_secret: Google OAuth client secret.
|
||||
public_url: Public-facing URL of the server.
|
||||
**kwargs: Additional keyword args passed to UserAuth.
|
||||
|
||||
Returns:
|
||||
The initialized UserAuth instance.
|
||||
"""
|
||||
global _user_auth
|
||||
_user_auth = UserAuth(
|
||||
github_client_id=github_client_id or os.getenv("GITHUB_CLIENT_ID"),
|
||||
github_client_secret=github_client_secret or os.getenv("GITHUB_CLIENT_SECRET"),
|
||||
google_client_id=google_client_id or os.getenv("GOOGLE_CLIENT_ID"),
|
||||
google_client_secret=google_client_secret or os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
public_url=public_url or os.getenv("PUBLIC_URL", ""),
|
||||
**kwargs,
|
||||
)
|
||||
return _user_auth
|
||||
|
||||
|
||||
def get_user_auth() -> UserAuth:
|
||||
"""Get the global UserAuth singleton.
|
||||
|
||||
Returns:
|
||||
The UserAuth singleton instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If initialize_user_auth() has not been called.
|
||||
"""
|
||||
if _user_auth is None:
|
||||
raise RuntimeError("UserAuth not initialized. Call initialize_user_auth() first.")
|
||||
return _user_auth
|
||||
378
core/user_endpoints.py
Normal file
378
core/user_endpoints.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Per-user MCP endpoint handler (Track E.3).
|
||||
|
||||
Handles MCP JSON-RPC requests for user-owned sites at
|
||||
``/u/{user_id}/{alias}/mcp``. Implements the Streamable HTTP transport
|
||||
protocol directly (no per-user FastMCP instances).
|
||||
|
||||
Flow:
|
||||
1. Validate user API key (Bearer token)
|
||||
2. Look up user's site from SQLite
|
||||
3. Decrypt credentials
|
||||
4. For tools/list: return plugin tools (without ``site`` param)
|
||||
5. For tools/call: create plugin instance, call method, return result
|
||||
|
||||
Usage:
|
||||
# In server.py route registration:
|
||||
from core.user_endpoints import user_mcp_handler
|
||||
Route("/u/{user_id}/{alias}/mcp", endpoint=user_mcp_handler, methods=["POST"])
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-user rate limiting defaults
|
||||
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
|
||||
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
|
||||
|
||||
# In-memory rate limit tracking: user_id -> list of timestamps
|
||||
_rate_limits: dict[str, list[float]] = {}
|
||||
|
||||
# Cache for tool schemas per plugin type (computed once)
|
||||
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||
"""Check per-user rate limits.
|
||||
|
||||
Args:
|
||||
user_id: User UUID.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, error_message). error_message is empty if allowed.
|
||||
"""
|
||||
now = time.time()
|
||||
timestamps = _rate_limits.setdefault(user_id, [])
|
||||
|
||||
# Prune old entries (older than 1 hour)
|
||||
cutoff_hr = now - 3600
|
||||
_rate_limits[user_id] = [t for t in timestamps if t > cutoff_hr]
|
||||
timestamps = _rate_limits[user_id]
|
||||
|
||||
# Check per-minute limit
|
||||
cutoff_min = now - 60
|
||||
recent_min = sum(1 for t in timestamps if t > cutoff_min)
|
||||
if recent_min >= USER_RATE_LIMIT_PER_MIN:
|
||||
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute"
|
||||
|
||||
# Check per-hour limit
|
||||
if len(timestamps) >= USER_RATE_LIMIT_PER_HR:
|
||||
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour"
|
||||
|
||||
timestamps.append(now)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
||||
"""Get MCP tool definitions for a plugin type (cached).
|
||||
|
||||
Returns tool schemas with the ``site`` parameter removed
|
||||
(auto-injected for user endpoints).
|
||||
"""
|
||||
if plugin_type in _tool_schema_cache:
|
||||
return _tool_schema_cache[plugin_type]
|
||||
|
||||
from core.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_plugin_type(plugin_type)
|
||||
|
||||
result = []
|
||||
for tool_def in tools:
|
||||
schema = deepcopy(tool_def.input_schema)
|
||||
# Remove 'site' parameter (auto-injected)
|
||||
if "properties" in schema:
|
||||
schema["properties"].pop("site", None)
|
||||
if "required" in schema and "site" in schema["required"]:
|
||||
schema["required"] = [r for r in schema["required"] if r != "site"]
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": tool_def.name,
|
||||
"description": tool_def.description,
|
||||
"inputSchema": schema,
|
||||
}
|
||||
)
|
||||
|
||||
_tool_schema_cache[plugin_type] = result
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
plugin_type: str,
|
||||
config_dict: dict[str, Any],
|
||||
) -> Any:
|
||||
"""Execute a tool by creating a plugin instance and calling the method.
|
||||
|
||||
Uses the same pattern as unified_handler in tool_generator.py.
|
||||
"""
|
||||
from plugins import registry as plugin_registry
|
||||
|
||||
if not plugin_registry.is_registered(plugin_type):
|
||||
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
||||
|
||||
method_name = tool_name
|
||||
# Strip plugin_type prefix: "wordpress_list_posts" → "list_posts"
|
||||
prefix = f"{plugin_type}_"
|
||||
if method_name.startswith(prefix):
|
||||
method_name = method_name[len(prefix) :]
|
||||
|
||||
try:
|
||||
plugin_instance = plugin_registry.create_instance(
|
||||
plugin_type,
|
||||
project_id=f"user_{config_dict.get('alias', 'unknown')}",
|
||||
config=config_dict,
|
||||
)
|
||||
|
||||
if not hasattr(plugin_instance, method_name):
|
||||
return {"type": "text", "text": f"Error: Method '{method_name}' not found"}
|
||||
|
||||
method = getattr(plugin_instance, method_name)
|
||||
|
||||
# Process arguments (parse JSON strings, filter None/empty)
|
||||
filtered_args = {}
|
||||
for key, value in arguments.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped == "":
|
||||
continue
|
||||
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
||||
stripped.startswith("[") and stripped.endswith("]")
|
||||
):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
filtered_args[key] = value
|
||||
|
||||
result = await method(**filtered_args)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Tool execution error %s: %s", tool_name, e, exc_info=True)
|
||||
return {"type": "text", "text": f"Error: {type(e).__name__}: {e}"}
|
||||
|
||||
|
||||
def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
||||
"""Build a JSON-RPC error response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": code, "message": message},
|
||||
}
|
||||
|
||||
|
||||
def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]:
|
||||
"""Build a JSON-RPC success response."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
|
||||
async def user_mcp_handler(request: Request) -> Response:
|
||||
"""Handle MCP JSON-RPC requests for user endpoints.
|
||||
|
||||
Route: POST /u/{user_id}/{alias}/mcp
|
||||
|
||||
Validates user API key, looks up the site, and handles MCP protocol
|
||||
methods (initialize, tools/list, tools/call).
|
||||
"""
|
||||
user_id = request.path_params.get("user_id", "")
|
||||
alias = request.path_params.get("alias", "")
|
||||
|
||||
if not user_id or not alias:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Invalid endpoint path"),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# --- Authentication ---
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Missing Authorization header"),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
api_key = auth_header[7:] # Strip "Bearer "
|
||||
|
||||
try:
|
||||
from core.user_keys import get_user_key_manager
|
||||
|
||||
key_mgr = get_user_key_manager()
|
||||
key_info = await key_mgr.validate_key(api_key)
|
||||
except RuntimeError:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Authentication service unavailable"),
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
if key_info is None:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Invalid API key"),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Ensure the API key belongs to the user in the URL
|
||||
if key_info["user_id"] != user_id:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# --- Rate Limiting ---
|
||||
allowed, rate_msg = _check_user_rate_limit(user_id)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, rate_msg),
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
# --- Look up site ---
|
||||
try:
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
site = await db.get_site_by_alias(user_id, alias)
|
||||
except RuntimeError:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Database unavailable"),
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
if site is None:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, f"Site '{alias}' not found"),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if site["status"] == "disabled":
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Site is disabled"),
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# --- Parse JSON-RPC body ---
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32700, "Parse error"),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
req_id = body.get("id")
|
||||
method = body.get("method", "")
|
||||
params = body.get("params", {})
|
||||
|
||||
# --- Handle MCP methods ---
|
||||
|
||||
if method == "initialize":
|
||||
version = "3.1.0"
|
||||
return JSONResponse(
|
||||
_jsonrpc_result(
|
||||
req_id,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": f"mcphub-{alias}",
|
||||
"version": version,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
# Notification — no response needed
|
||||
return Response(status_code=204)
|
||||
|
||||
elif method == "tools/list":
|
||||
tools = _get_tools_for_plugin(site["plugin_type"])
|
||||
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
# Verify tool belongs to this plugin type
|
||||
plugin_prefix = f"{site['plugin_type']}_"
|
||||
if not tool_name.startswith(plugin_prefix):
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not available for this site")
|
||||
)
|
||||
|
||||
# Check required scope
|
||||
from core.tool_registry import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
tool_def = registry.get_by_name(tool_name)
|
||||
if not tool_def:
|
||||
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
|
||||
|
||||
required_scope = tool_def.required_scope
|
||||
key_scopes = key_info.get("scopes", "").split()
|
||||
|
||||
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
|
||||
required_level = scope_hierarchy.get(required_scope, 0)
|
||||
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
|
||||
|
||||
if key_level < required_level:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(
|
||||
req_id,
|
||||
-32600,
|
||||
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
|
||||
)
|
||||
)
|
||||
|
||||
# Decrypt credentials
|
||||
try:
|
||||
from core.encryption import get_credential_encryption
|
||||
|
||||
encryptor = get_credential_encryption()
|
||||
credentials = encryptor.decrypt_credentials(site["credentials"], site["id"])
|
||||
except Exception as e:
|
||||
logger.error("Credential decryption failed for site %s: %s", site["id"], e)
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(req_id, -32603, "Failed to decrypt site credentials")
|
||||
)
|
||||
|
||||
# Build config dict for plugin instantiation
|
||||
config_dict = {
|
||||
"site_url": site["url"],
|
||||
"url": site["url"],
|
||||
"alias": alias,
|
||||
**credentials,
|
||||
}
|
||||
|
||||
result = await _execute_tool(tool_name, arguments, site["plugin_type"], config_dict)
|
||||
|
||||
# Format result as MCP content
|
||||
if isinstance(result, str):
|
||||
content = [{"type": "text", "text": result}]
|
||||
elif isinstance(result, dict) and "type" in result:
|
||||
content = [result]
|
||||
elif isinstance(result, list):
|
||||
content = result
|
||||
else:
|
||||
content = [{"type": "text", "text": json.dumps(result, default=str)}]
|
||||
|
||||
return JSONResponse(_jsonrpc_result(req_id, {"content": content}))
|
||||
|
||||
else:
|
||||
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Method '{method}' not supported"))
|
||||
249
core/user_keys.py
Normal file
249
core/user_keys.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""User API Key management for the Live Platform (Track E.3).
|
||||
|
||||
Provides bcrypt-hashed API keys for authenticating MCP client connections
|
||||
to per-user endpoints (``/u/{user_id}/{alias}/mcp``).
|
||||
|
||||
Key format: ``mhu_<32 random urlsafe chars>``
|
||||
Lookup: ``key_prefix`` column (first 8 chars after ``mhu_``) for indexed DB lookup,
|
||||
then bcrypt verification on the matching row.
|
||||
|
||||
Usage:
|
||||
from core.user_keys import initialize_user_key_manager, get_user_key_manager
|
||||
|
||||
initialize_user_key_manager()
|
||||
mgr = get_user_key_manager()
|
||||
result = await mgr.create_key(user_id, "Claude Desktop")
|
||||
# result["key"] is shown once to the user
|
||||
info = await mgr.validate_key("mhu_...")
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Key format constants
|
||||
KEY_PREFIX_TAG = "mhu_"
|
||||
KEY_RANDOM_BYTES = 32 # urlsafe_b64 of 32 bytes = 43 chars
|
||||
KEY_PREFIX_LEN = 8 # chars after "mhu_" stored for fast DB lookup
|
||||
|
||||
# Validation cache TTL
|
||||
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
class UserKeyManager:
|
||||
"""Manages per-user API keys with bcrypt hashing.
|
||||
|
||||
Keys are stored in the ``user_api_keys`` SQLite table via
|
||||
:class:`core.database.Database`. Each key is bcrypt-hashed;
|
||||
a plaintext ``key_prefix`` column enables indexed lookup without
|
||||
scanning all rows.
|
||||
|
||||
An in-memory cache avoids repeated bcrypt verification for the
|
||||
same key within a 5-minute window.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
||||
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
||||
|
||||
async def create_key(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
scopes: str = "read write",
|
||||
expires_in_days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new API key for a user.
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
name: Human label (e.g. "Claude Desktop").
|
||||
scopes: Ignored — always "read write" (full access).
|
||||
expires_in_days: Optional expiry in days from now. None = never.
|
||||
|
||||
Returns:
|
||||
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||
``scopes``, ``created_at``, ``expires_at``.
|
||||
"""
|
||||
from core.database import get_database
|
||||
|
||||
# All user API keys get full access — scopes param is ignored
|
||||
scopes = "read write"
|
||||
|
||||
raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES)
|
||||
key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
expires_at = None
|
||||
if expires_in_days is not None:
|
||||
expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat()
|
||||
|
||||
db = get_database()
|
||||
row = await db.create_api_key(
|
||||
user_id=user_id,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
name=name,
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
logger.info("Created user API key %s for user %s", row["id"], user_id)
|
||||
return {
|
||||
"key": raw_key, # shown once
|
||||
"key_id": row["id"],
|
||||
"name": row["name"],
|
||||
"scopes": row["scopes"],
|
||||
"created_at": row["created_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
}
|
||||
|
||||
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||
"""Validate an API key and return its metadata.
|
||||
|
||||
Uses an in-memory cache to avoid repeated bcrypt verification.
|
||||
|
||||
Args:
|
||||
api_key: The raw API key string (e.g. ``mhu_...``).
|
||||
|
||||
Returns:
|
||||
Dict with ``key_id``, ``user_id``, ``scopes`` if valid, else None.
|
||||
"""
|
||||
if not api_key or not api_key.startswith(KEY_PREFIX_TAG):
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
cached = self._cache.get(api_key)
|
||||
if cached is not None:
|
||||
key_id, user_id, scopes, cached_at = cached
|
||||
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||
# Update usage in background (fire-and-forget via DB)
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
|
||||
else:
|
||||
del self._cache[api_key]
|
||||
|
||||
# Extract prefix for DB lookup
|
||||
key_prefix = api_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
row = await db.get_api_key_by_prefix(key_prefix)
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
# Verify bcrypt hash
|
||||
if not bcrypt.checkpw(api_key.encode(), row["key_hash"].encode()):
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
if row["expires_at"] is not None:
|
||||
expires = datetime.fromisoformat(row["expires_at"])
|
||||
if expires < datetime.now(UTC):
|
||||
return None
|
||||
|
||||
# Update usage
|
||||
await db.update_api_key_usage(row["id"])
|
||||
|
||||
# Cache the result
|
||||
self._cache[api_key] = (
|
||||
row["id"],
|
||||
row["user_id"],
|
||||
row["scopes"],
|
||||
time.time(),
|
||||
)
|
||||
|
||||
return {
|
||||
"key_id": row["id"],
|
||||
"user_id": row["user_id"],
|
||||
"scopes": row["scopes"],
|
||||
}
|
||||
|
||||
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""List all API keys for a user (without hashes).
|
||||
|
||||
Args:
|
||||
user_id: Owner's UUID.
|
||||
|
||||
Returns:
|
||||
List of key metadata dicts.
|
||||
"""
|
||||
from core.database import get_database
|
||||
|
||||
db = get_database()
|
||||
return await db.get_api_keys_by_user(user_id)
|
||||
|
||||
async def delete_key(self, key_id: str, user_id: str) -> bool:
|
||||
"""Delete an API key.
|
||||
|
||||
Also invalidates the validation cache for any cached key matching
|
||||
this key_id.
|
||||
|
||||
Args:
|
||||
key_id: API key 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_api_key(key_id, user_id)
|
||||
|
||||
if deleted:
|
||||
# Purge from cache
|
||||
to_remove = [k for k, v in self._cache.items() if v[0] == key_id]
|
||||
for k in to_remove:
|
||||
del self._cache[k]
|
||||
logger.info("Deleted user API key %s for user %s", key_id, user_id)
|
||||
|
||||
return deleted
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the entire validation cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# Singleton
|
||||
_manager: UserKeyManager | None = None
|
||||
|
||||
|
||||
def initialize_user_key_manager() -> UserKeyManager:
|
||||
"""Create and store the singleton UserKeyManager."""
|
||||
global _manager
|
||||
_manager = UserKeyManager()
|
||||
logger.info("UserKeyManager initialized")
|
||||
return _manager
|
||||
|
||||
|
||||
def get_user_key_manager() -> UserKeyManager:
|
||||
"""Get the singleton UserKeyManager.
|
||||
|
||||
Returns:
|
||||
The UserKeyManager singleton.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not initialized.
|
||||
"""
|
||||
if _manager is None:
|
||||
raise RuntimeError(
|
||||
"UserKeyManager not initialized. Call initialize_user_key_manager() first."
|
||||
)
|
||||
return _manager
|
||||
79
docker-compose.coolify.yaml
Normal file
79
docker-compose.coolify.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
# ===================================
|
||||
# MCP Hub — Docker Compose (Coolify)
|
||||
# ===================================
|
||||
#
|
||||
# Coolify auto-reads the 'environment' block below and shows
|
||||
# each variable in the UI for editing. Just deploy and fill values.
|
||||
#
|
||||
# Setup:
|
||||
# 1. New Resource → Docker Compose → point to this file
|
||||
# 2. Fill environment variables in Coolify UI
|
||||
# 3. Set domain (e.g., mcp.yourdomain.com)
|
||||
# 4. Deploy
|
||||
# ===================================
|
||||
|
||||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mcphub
|
||||
restart: unless-stopped
|
||||
|
||||
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
|
||||
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# === REQUIRED ===
|
||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
|
||||
|
||||
# === OAUTH SOCIAL LOGIN (for user registration) ===
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
|
||||
|
||||
# === LOGGING ===
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
|
||||
# === SITE MANAGEMENT (E.3) ===
|
||||
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
|
||||
- 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:-}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||
- mcp-logs:/app/logs # Audit logs, health reports
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
# Coolify uses GID 988 for Docker socket
|
||||
group_add:
|
||||
- "988"
|
||||
|
||||
volumes:
|
||||
mcp-data:
|
||||
driver: local
|
||||
mcp-logs:
|
||||
driver: local
|
||||
@@ -1,110 +1,55 @@
|
||||
# ===================================
|
||||
# MCP Hub — Docker Compose Configuration
|
||||
# MCP Hub — Docker Compose (Standalone)
|
||||
# ===================================
|
||||
#
|
||||
# Build Pack: Docker Compose
|
||||
# ⚠️ CRITICAL RULES FOR COOLIFY:
|
||||
# 1. NO host port mappings: Use "8000" NOT "8000:8000"
|
||||
# 2. Listen on 0.0.0.0 (NOT localhost)
|
||||
# 3. Health checks for ALL services
|
||||
# 4. Use environment variables for ALL configs
|
||||
#
|
||||
# Usage:
|
||||
# cp env.example .env # edit with your values
|
||||
# docker compose up -d
|
||||
# curl http://localhost:8000/health
|
||||
# open http://localhost:8000/dashboard
|
||||
#
|
||||
# For Coolify deployment, use docker-compose.coolify.yaml instead.
|
||||
# ===================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: airano/mcphub:latest
|
||||
container_name: mcphub
|
||||
restart: unless-stopped
|
||||
|
||||
# ⚠️ CRITICAL: Only container port (NO host port mapping)
|
||||
# Coolify will handle routing via domain
|
||||
ports:
|
||||
- "8000"
|
||||
|
||||
# Environment variables
|
||||
environment:
|
||||
# Master API key
|
||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||
|
||||
# Logging
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# === OAuth 2.1 Configuration ===
|
||||
# Required for OAuth authentication
|
||||
- OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
|
||||
- OAUTH_JWT_ALGORITHM=${OAUTH_JWT_ALGORITHM:-HS256}
|
||||
- OAUTH_ACCESS_TOKEN_TTL=${OAUTH_ACCESS_TOKEN_TTL:-3600}
|
||||
- OAUTH_REFRESH_TOKEN_TTL=${OAUTH_REFRESH_TOKEN_TTL:-604800}
|
||||
- OAUTH_STORAGE_TYPE=${OAUTH_STORAGE_TYPE:-json}
|
||||
- OAUTH_STORAGE_PATH=${OAUTH_STORAGE_PATH:-/app/data}
|
||||
|
||||
# OAuth Authorization Security
|
||||
# IMPORTANT: API Key is always required for authorization (OAuth manual mode)
|
||||
# Use 'required' (recommended) to enforce API Key authentication
|
||||
- OAUTH_AUTH_MODE=${OAUTH_AUTH_MODE:-required}
|
||||
# OAUTH_TRUSTED_DOMAINS is deprecated - API Key always required for security
|
||||
- OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
||||
|
||||
# === WordPress Projects ===
|
||||
# Configure WordPress sites in Coolify environment variables
|
||||
# Format: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
||||
#
|
||||
# Required variables for each site:
|
||||
# WORDPRESS_SITE1_URL=https://your-site.com
|
||||
# WORDPRESS_SITE1_USERNAME=admin
|
||||
# WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
#
|
||||
# Optional (for WP-CLI tools):
|
||||
# WORDPRESS_SITE1_CONTAINER=wordpress_container_name
|
||||
# WORDPRESS_SITE1_ALIAS=myblog
|
||||
#
|
||||
# ⚠️ DO NOT add example values here - configure in Coolify!
|
||||
# The server will auto-discover all WORDPRESS_* variables at startup.
|
||||
|
||||
# === Other Plugins ===
|
||||
# Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus plugins
|
||||
# auto-discover their environment variables at startup.
|
||||
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||
# No need to pre-define them here.
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Docker socket access for WP-CLI tools
|
||||
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
|
||||
|
||||
volumes:
|
||||
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||
- mcp-logs:/app/logs # Audit logs, health reports
|
||||
# Docker socket — only needed for WP-CLI tools (WordPress Advanced plugin)
|
||||
# Remove if you don't use wp_cache_flush, wp_transient_*, etc.
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- mcp-data:/app/data # Persistent storage for API keys
|
||||
- mcp-logs:/app/logs # Persistent logs (audit, health)
|
||||
|
||||
# Grant access to Docker socket by adding user to docker group
|
||||
# The GID varies by host (988, 999, 133 are common)
|
||||
# Coolify typically uses 988
|
||||
# Docker socket GID — adjust to match your host's docker group
|
||||
# Check with: getent group docker | cut -d: -f3
|
||||
group_add:
|
||||
- "988" # Docker group GID (adjust if needed)
|
||||
- "999"
|
||||
|
||||
# Network - Coolify will handle this
|
||||
# networks:
|
||||
# - coolify
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
mcp-data:
|
||||
driver: local
|
||||
mcp-logs:
|
||||
driver: local
|
||||
|
||||
# Networks managed by Coolify
|
||||
# networks:
|
||||
# coolify:
|
||||
# external: true
|
||||
|
||||
@@ -48,14 +48,18 @@ For each WordPress site you want to manage:
|
||||
git clone https://github.com/airano-ir/mcphub.git
|
||||
cd mcphub
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
# Edit .env — set MASTER_API_KEY and add your site credentials (see Configuration below)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: PyPI
|
||||
After starting, see [Verify Installation](#verify-installation) below.
|
||||
|
||||
### Option 2: Docker Hub (No Clone)
|
||||
|
||||
```bash
|
||||
pip install mcphub-server
|
||||
# 1. Create a .env file (see Configuration section below)
|
||||
# 2. Run:
|
||||
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||
```
|
||||
|
||||
### Option 3: From Source
|
||||
@@ -66,7 +70,7 @@ cd mcphub
|
||||
pip install -e .
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
python server.py --transport sse --port 8000
|
||||
python server.py --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
### Option 4: Automated Setup Scripts
|
||||
@@ -125,7 +129,7 @@ Edit the `.env` file with your credentials:
|
||||
|
||||
```bash
|
||||
# ============================================
|
||||
# Required
|
||||
# Authentication (recommended — auto-generates temp key if omitted)
|
||||
# ============================================
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
@@ -167,6 +171,126 @@ RATE_LIMIT_PER_HOUR=1000
|
||||
RATE_LIMIT_PER_DAY=10000
|
||||
```
|
||||
|
||||
### WordPress Plugin Requirements
|
||||
|
||||
Some tools require specific WordPress plugins or infrastructure:
|
||||
|
||||
| Tools | Requirement |
|
||||
|-------|-------------|
|
||||
| `wordpress_get_post_seo`, `wordpress_update_post_seo`, `wordpress_get_product_seo`, `wordpress_update_product_seo` | **Yoast SEO** or **RankMath** |
|
||||
| `wordpress_wp_cache_*`, `wordpress_wp_db_*`, `wordpress_wp_plugin_*`, `wordpress_wp_theme_*`, `wordpress_wp_core_*`, `wordpress_wp_search_replace_dry_run` (15 tools) | Docker socket + `CONTAINER` env var |
|
||||
| `wordpress_advanced_*` database/system tools | Docker socket + `CONTAINER` env var |
|
||||
| `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) |
|
||||
|
||||
### Docker Socket for WP-CLI / WordPress Advanced
|
||||
|
||||
WP-CLI and WordPress Advanced system tools require Docker socket access:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcphub:
|
||||
image: airano/mcphub:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
WORDPRESS_SITE1_URL: https://your-site.com
|
||||
WORDPRESS_SITE1_USERNAME: admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_CONTAINER: wordpress-container-name
|
||||
WORDPRESS_SITE1_ALIAS: mysite
|
||||
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-admin
|
||||
```
|
||||
|
||||
Without Docker socket:
|
||||
- WP-CLI tools return "not available" errors
|
||||
- WordPress Advanced database/system tools are unavailable
|
||||
- All REST API tools (bulk operations, content management) work normally
|
||||
|
||||
### Environment Variable Reference
|
||||
|
||||
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
|
||||
|
||||
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
|
||||
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
|
||||
|
||||
#### WordPress
|
||||
```env
|
||||
WORDPRESS_SITE1_URL=https://your-site.com # Required
|
||||
WORDPRESS_SITE1_USERNAME=admin # Required
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx # Required (WordPress Application Password)
|
||||
WORDPRESS_SITE1_ALIAS=my-site # Recommended
|
||||
WORDPRESS_SITE1_CONTAINER=wp-container # Optional (for WP-CLI)
|
||||
```
|
||||
|
||||
#### WooCommerce
|
||||
```env
|
||||
WOOCOMMERCE_SITE1_URL=https://your-site.com # Required
|
||||
WOOCOMMERCE_SITE1_CONSUMER_KEY=ck_xxx # Required
|
||||
WOOCOMMERCE_SITE1_CONSUMER_SECRET=cs_xxx # Required
|
||||
WOOCOMMERCE_SITE1_ALIAS=my-shop # Recommended
|
||||
```
|
||||
|
||||
#### WordPress Advanced
|
||||
```env
|
||||
WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com # Required
|
||||
WORDPRESS_ADVANCED_SITE1_USERNAME=admin # Required
|
||||
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx # Required
|
||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=wp-container # Required (for WP-CLI tools)
|
||||
WORDPRESS_ADVANCED_SITE1_ALIAS=my-site-admin # Recommended
|
||||
```
|
||||
|
||||
#### Gitea
|
||||
```env
|
||||
GITEA_SITE1_URL=https://gitea.example.com # Required
|
||||
GITEA_SITE1_TOKEN=your-access-token # Required
|
||||
GITEA_SITE1_ALIAS=my-gitea # Recommended
|
||||
```
|
||||
|
||||
#### n8n
|
||||
```env
|
||||
N8N_SITE1_URL=https://n8n.example.com # Required
|
||||
N8N_SITE1_API_KEY=your-api-key # Required
|
||||
N8N_SITE1_ALIAS=my-n8n # Recommended
|
||||
```
|
||||
|
||||
#### Supabase
|
||||
```env
|
||||
SUPABASE_SITE1_URL=https://your-project.supabase.co # Required
|
||||
SUPABASE_SITE1_API_KEY=your-service-role-key # Required
|
||||
SUPABASE_SITE1_ALIAS=my-supabase # Recommended
|
||||
```
|
||||
|
||||
#### OpenPanel
|
||||
```env
|
||||
OPENPANEL_SITE1_URL=https://openpanel.example.com # Required
|
||||
OPENPANEL_SITE1_CLIENT_ID=your-client-id # Required
|
||||
OPENPANEL_SITE1_CLIENT_SECRET=your-client-secret # Required
|
||||
OPENPANEL_SITE1_ALIAS=my-analytics # Recommended
|
||||
```
|
||||
|
||||
#### Appwrite
|
||||
```env
|
||||
APPWRITE_SITE1_URL=https://appwrite.example.com # Required
|
||||
APPWRITE_SITE1_API_KEY=your-api-key # Required
|
||||
APPWRITE_SITE1_PROJECT_ID=your-project-id # Required
|
||||
APPWRITE_SITE1_ALIAS=my-appwrite # Recommended
|
||||
```
|
||||
|
||||
#### Directus
|
||||
```env
|
||||
DIRECTUS_SITE1_URL=https://directus.example.com # Required
|
||||
DIRECTUS_SITE1_TOKEN=your-admin-token # Required
|
||||
DIRECTUS_SITE1_ALIAS=my-directus # Recommended
|
||||
```
|
||||
|
||||
> **Note:** Use `APP_PASSWORD` (WordPress Application Password), not `PASSWORD`.
|
||||
|
||||
### Configuration Tips
|
||||
|
||||
- **Site Aliases**: Use friendly names like `myblog`, `mystore`, or `mygitea`
|
||||
@@ -178,10 +302,10 @@ RATE_LIMIT_PER_DAY=10000
|
||||
|
||||
## Running the Server
|
||||
|
||||
### SSE Transport (for remote AI clients)
|
||||
### Streamable HTTP Transport (for remote AI clients)
|
||||
|
||||
```bash
|
||||
python server.py --transport sse --port 8000
|
||||
python server.py --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
### Stdio Transport (for Claude Desktop local)
|
||||
@@ -190,26 +314,60 @@ python server.py --transport sse --port 8000
|
||||
python server.py
|
||||
```
|
||||
|
||||
### Verify Server is Running
|
||||
### Verify Installation
|
||||
|
||||
Check logs for:
|
||||
After starting (via Docker or locally), wait ~30 seconds for the server to initialize, then:
|
||||
|
||||
```
|
||||
INFO: MCP Hub initialized
|
||||
INFO: Registered 589 tools
|
||||
INFO: Server ready
|
||||
```
|
||||
|
||||
Or test the health endpoint:
|
||||
**1. Check health:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Expected: {"status": "ok", "tools_loaded": 596, ...}
|
||||
```
|
||||
|
||||
**2. Open the web dashboard:**
|
||||
|
||||
Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY`.
|
||||
|
||||
The dashboard lets you:
|
||||
- View all connected sites and their health status
|
||||
- Create and manage per-project API keys
|
||||
- View audit logs
|
||||
- Monitor rate limits
|
||||
|
||||
**3. Check container status (Docker only):**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
# Look for Status: "Up (healthy)"
|
||||
# Note: Health check starts after 40 seconds — "starting" is normal initially
|
||||
|
||||
# View logs if something is wrong:
|
||||
docker compose logs -f mcphub
|
||||
```
|
||||
|
||||
**4. Troubleshooting:**
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Container exits immediately | Check logs: `docker compose logs 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` |
|
||||
|
||||
---
|
||||
|
||||
## Connect Your AI Client
|
||||
|
||||
All requests require **Bearer token** authentication via the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
> `X-API-Key` header and query parameter auth are **not** supported.
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `claude_desktop_config.json`:
|
||||
@@ -217,8 +375,9 @@ Add to `claude_desktop_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
@@ -234,9 +393,9 @@ Add to `.mcp.json` in your project:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "http",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
@@ -249,8 +408,8 @@ Add to `.mcp.json` in your project:
|
||||
|
||||
Go to **Settings > MCP Servers > Add Server**:
|
||||
|
||||
- **Name**: MCP Hub
|
||||
- **URL**: `https://your-server:8000/mcp`
|
||||
- **Name**: MCP Hub WordPress
|
||||
- **URL**: `http://your-server:8000/wordpress/mcp`
|
||||
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
||||
|
||||
### VS Code + Copilot
|
||||
@@ -260,9 +419,9 @@ Add to `.vscode/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"mcphub-wordpress": {
|
||||
"type": "http",
|
||||
"url": "http://your-server:8000/wordpress/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
@@ -279,24 +438,26 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
2. In ChatGPT, add MCP server: `https://your-server:8000/mcp`
|
||||
3. ChatGPT auto-discovers OAuth metadata and registers
|
||||
|
||||
> **Important**: Use `"type": "streamableHttp"` for Claude Desktop and `"type": "http"` for VS Code/Claude Code. Using `"type": "sse"` will cause `400 Bad Request` errors.
|
||||
|
||||
---
|
||||
|
||||
## Using MCP Tools
|
||||
|
||||
### 589 Tools Across 9 Plugins
|
||||
### 596 Tools Across 9 Plugins
|
||||
|
||||
| Plugin | Tools | Env Prefix |
|
||||
|--------|-------|------------|
|
||||
| WordPress | 67 | `WORDPRESS_` |
|
||||
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
||||
| WordPress Advanced | 22 | `WORDPRESS_` (same sites, advanced ops) |
|
||||
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` |
|
||||
| Gitea | 56 | `GITEA_` |
|
||||
| n8n | 56 | `N8N_` |
|
||||
| Supabase | 70 | `SUPABASE_` |
|
||||
| OpenPanel | 73 | `OPENPANEL_` |
|
||||
| Appwrite | 100 | `APPWRITE_` |
|
||||
| Directus | 100 | `DIRECTUS_` |
|
||||
| System | 17 | (no config needed) |
|
||||
| System | 24 | (no config needed) |
|
||||
|
||||
### Unified Tool Pattern
|
||||
|
||||
@@ -313,16 +474,40 @@ The `site` parameter accepts either a **site_id** (e.g., `site1`) or an **alias*
|
||||
|
||||
### Multi-Endpoint Architecture
|
||||
|
||||
Use specific endpoints to limit tool access:
|
||||
Use the most specific endpoint for your use case to minimize token usage:
|
||||
|
||||
```
|
||||
/mcp → All 589 tools (Master API Key)
|
||||
/system/mcp → System tools only (17 tools)
|
||||
/wordpress/mcp → WordPress tools (67 tools)
|
||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||
/gitea/mcp → Gitea tools (56 tools)
|
||||
/project/{alias}/mcp → Per-project (auto-injects site)
|
||||
```
|
||||
| Endpoint | Use Case | Tools | `site` param? |
|
||||
|----------|----------|------:|:-------------:|
|
||||
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
||||
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
||||
| `/system/mcp` | System administration | 24 | N/A |
|
||||
| `/mcp` | Admin & discovery only | 596 | Yes |
|
||||
|
||||
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (596 tools) wastes context tokens and degrades AI response quality.
|
||||
|
||||
**Available plugin endpoints:**
|
||||
|
||||
| Endpoint | Plugin | Tools |
|
||||
|----------|--------|------:|
|
||||
| `/wordpress/mcp` | WordPress | 67 |
|
||||
| `/woocommerce/mcp` | WooCommerce | 28 |
|
||||
| `/wordpress-advanced/mcp` | WordPress Advanced | 22 |
|
||||
| `/gitea/mcp` | Gitea | 56 |
|
||||
| `/n8n/mcp` | n8n | 56 |
|
||||
| `/supabase/mcp` | Supabase | 70 |
|
||||
| `/openpanel/mcp` | OpenPanel | 73 |
|
||||
| `/appwrite/mcp` | Appwrite | 100 |
|
||||
| `/directus/mcp` | Directus | 100 |
|
||||
| `/system/mcp` | System Management | 24 |
|
||||
|
||||
**Plugin endpoint vs Project endpoint:**
|
||||
|
||||
| Feature | Plugin (`/wordpress/mcp`) | Project (`/project/{alias}/mcp`) |
|
||||
|---------|:------------------------:|:-------------------------------:|
|
||||
| `list_sites` tool | Yes | No |
|
||||
| `site` parameter needed | Yes | No (pre-scoped) |
|
||||
| Tool count | N + 1 (includes `list_sites`) | N |
|
||||
| Multi-site support | Yes | No (single site) |
|
||||
|
||||
---
|
||||
|
||||
@@ -334,34 +519,40 @@ Use specific endpoints to limit tool access:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After starting, verify the installation:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health # server health
|
||||
open http://localhost:8000/dashboard # web dashboard
|
||||
```
|
||||
|
||||
See [Verify Installation](#verify-installation) for detailed steps.
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
docker compose logs -f mcphub
|
||||
|
||||
# Check status
|
||||
# Check status (look for "healthy")
|
||||
docker compose ps
|
||||
|
||||
# Restart
|
||||
# Restart (needed after .env changes)
|
||||
docker compose restart
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Rebuild
|
||||
# Rebuild (after code changes)
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### Health Check
|
||||
### Adding Sites After Startup
|
||||
|
||||
```bash
|
||||
# Check container health
|
||||
docker compose ps
|
||||
|
||||
# Test API endpoint
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
1. Edit your `.env` file to add new site credentials
|
||||
2. Restart the container: `docker compose restart`
|
||||
3. Verify: `curl http://localhost:8000/health` — check that tools are loaded
|
||||
4. The dashboard at http://localhost:8000/dashboard will show the new sites
|
||||
|
||||
---
|
||||
|
||||
@@ -412,7 +603,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Explore the full tool list**: See the [README](../README.md) for all 589 tools
|
||||
1. **Explore the full tool list**: See the [README](../README.md) for all 596 tools
|
||||
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
|
||||
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
|
||||
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
||||
|
||||
168
env.example
Normal file
168
env.example
Normal file
@@ -0,0 +1,168 @@
|
||||
# ===================================
|
||||
# MCP Hub — Environment Configuration
|
||||
# ===================================
|
||||
#
|
||||
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
|
||||
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||
#
|
||||
# After editing, run:
|
||||
# docker compose up -d
|
||||
# curl http://localhost:8000/health # verify server
|
||||
# open http://localhost:8000/dashboard # web dashboard
|
||||
# ===================================
|
||||
|
||||
# ============================================
|
||||
# REQUIRED
|
||||
# ============================================
|
||||
|
||||
# Master API key for authentication (also used to log into the dashboard)
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# Encryption key for user credentials (REQUIRED for Live Platform / Track E)
|
||||
# Generate with: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Dashboard session secret (REQUIRED for production)
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
DASHBOARD_SESSION_SECRET=
|
||||
|
||||
# ============================================
|
||||
# WORDPRESS SITES
|
||||
# ============================================
|
||||
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
||||
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
|
||||
#
|
||||
# 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 ---
|
||||
# SUPABASE_PROJECT1_URL=https://xxxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_API_KEY=your_supabase_api_key
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE=your_service_role_key
|
||||
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
||||
|
||||
# --- 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
|
||||
|
||||
# ============================================
|
||||
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||
# ============================================
|
||||
# Only needed if you want ChatGPT or Claude to auto-register via OAuth.
|
||||
# For local testing with API key auth, you can skip these.
|
||||
|
||||
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||
# OAUTH_BASE_URL=https://your-public-domain.com
|
||||
|
||||
# ============================================
|
||||
# OAUTH SOCIAL LOGIN (optional — for user registration)
|
||||
# ============================================
|
||||
# Enable GitHub and/or Google login for the Live Platform.
|
||||
# Create OAuth apps:
|
||||
# GitHub: https://github.com/settings/developers → New OAuth App
|
||||
# - Callback URL: https://your-domain.com/auth/callback/github
|
||||
# Google: https://console.cloud.google.com/apis/credentials → Create OAuth Client
|
||||
# - Callback URL: https://your-domain.com/auth/callback/google
|
||||
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# PUBLIC_URL=https://mcp.example.com
|
||||
# SESSION_EXPIRY_HOURS=168
|
||||
|
||||
# === SITE MANAGEMENT (E.3) ===
|
||||
# MAX_SITES_PER_USER=10
|
||||
# USER_RATE_LIMIT_PER_MIN=30
|
||||
# USER_RATE_LIMIT_PER_HR=500
|
||||
|
||||
# ============================================
|
||||
# ADVANCED (optional — defaults are fine)
|
||||
# ============================================
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Database path (default: data/mcphub.db)
|
||||
# DATABASE_PATH=/app/data/mcphub.db
|
||||
|
||||
# Rate limits (per client)
|
||||
# RATE_LIMIT_PER_MINUTE=60
|
||||
# RATE_LIMIT_PER_HOUR=1000
|
||||
# RATE_LIMIT_PER_DAY=10000
|
||||
@@ -112,7 +112,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
||||
try:
|
||||
health = await client.health_check()
|
||||
info["health"] = health
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get current user to verify connectivity
|
||||
@@ -123,7 +123,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
||||
"email": user.get("email"),
|
||||
"role": user.get("role"),
|
||||
}
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info["instance_url"] = client.site_url
|
||||
|
||||
@@ -173,7 +173,7 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
||||
# Variable exists, update it
|
||||
await client.update_variable(key, value)
|
||||
updated.append(key)
|
||||
except:
|
||||
except Exception:
|
||||
# Variable doesn't exist, create it
|
||||
await client.create_variable(key, value)
|
||||
created.append(key)
|
||||
|
||||
@@ -236,8 +236,8 @@ async def get_profile(client: OpenPanelClient, project_id: str, profile_id: str)
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Profile event fetch is optional; fall through to generic response
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
|
||||
@@ -161,21 +161,21 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
||||
try:
|
||||
tables = await client.list_tables()
|
||||
stats["tables"] = len(tables) if isinstance(tables, list) else 0
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get schema count
|
||||
try:
|
||||
schemas = await client.list_schemas()
|
||||
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get extension count
|
||||
try:
|
||||
extensions = await client.list_extensions()
|
||||
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get database size and version
|
||||
@@ -186,7 +186,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
||||
if isinstance(size_result, list) and len(size_result) > 0:
|
||||
stats["size"] = size_result[0].get("size", "unknown")
|
||||
stats["version"] = size_result[0].get("version", "unknown")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get connection info
|
||||
@@ -196,7 +196,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
||||
)
|
||||
if isinstance(conn_result, list) and len(conn_result) > 0:
|
||||
stats["active_connections"] = conn_result[0].get("connections", 0)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
|
||||
@@ -237,7 +237,7 @@ async def get_storage_stats(client: SupabaseClient) -> str:
|
||||
if isinstance(files, list):
|
||||
bucket_info["file_count"] = len(files)
|
||||
stats["total_files"] += len(files)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stats["bucket_details"].append(bucket_info)
|
||||
@@ -333,7 +333,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
||||
await client.list_schemas()
|
||||
|
||||
info["services_available"].append(service)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try to get PostgreSQL version
|
||||
@@ -341,7 +341,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
||||
version_result = await client.execute_sql("SELECT version()")
|
||||
if isinstance(version_result, list) and len(version_result) > 0:
|
||||
info["postgres_version"] = version_result[0].get("version", "unknown")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -5,9 +5,11 @@ Handles all HTTP communication with WordPress REST API.
|
||||
Separates API communication from business logic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -25,6 +27,23 @@ class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionError(Exception):
|
||||
"""Raised when a network connection to the site fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Transient HTTP status codes that are worth retrying
|
||||
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
|
||||
|
||||
# Default request timeout in seconds
|
||||
_REQUEST_TIMEOUT = 30
|
||||
|
||||
# Retry configuration
|
||||
_MAX_RETRIES = 2
|
||||
_RETRY_BACKOFF_BASE = 1.0 # seconds
|
||||
|
||||
|
||||
class WordPressClient:
|
||||
"""
|
||||
WordPress REST API client for HTTP communication.
|
||||
@@ -140,37 +159,124 @@ class WordPressClient:
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||
|
||||
# Make request
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.request(
|
||||
method, url, params=params, json=json_data, data=data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
# Handle errors with structured error messages
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
# Make request with retry for transient errors
|
||||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||
last_exception = None
|
||||
|
||||
# Parse structured error response
|
||||
error_info = self._parse_error_response(
|
||||
response.status, error_text, use_woocommerce
|
||||
for attempt in range(_MAX_RETRIES + 1):
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method, url, params=params, json=json_data, data=data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
# Handle errors with structured error messages
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
|
||||
# Retry on transient server errors (502, 503, 504, 429)
|
||||
if response.status in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
|
||||
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||
self.logger.warning(
|
||||
f"Transient error {response.status} from {url}, "
|
||||
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
# Parse structured error response
|
||||
error_info = self._parse_error_response(
|
||||
response.status, error_text, use_woocommerce
|
||||
)
|
||||
|
||||
# Log the error for debugging
|
||||
self.logger.error(
|
||||
f"API error: {error_info['error_code']} - {error_info['message']}"
|
||||
)
|
||||
|
||||
# Raise appropriate exception
|
||||
if response.status in (401, 403):
|
||||
raise AuthenticationError(
|
||||
f"[{error_info['error_code']}] {error_info['message']}"
|
||||
)
|
||||
|
||||
raise Exception(f"[{error_info['error_code']}] {error_info['message']}")
|
||||
|
||||
# Return JSON response
|
||||
return await response.json()
|
||||
|
||||
except (AuthenticationError, ConfigurationError):
|
||||
raise # Never retry auth/config errors
|
||||
|
||||
except TimeoutError:
|
||||
last_exception = ConnectionError(
|
||||
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||
f"The site at {self.site_url} is not responding. "
|
||||
"Possible causes: site is overloaded, network is slow, "
|
||||
"or the server is down."
|
||||
)
|
||||
|
||||
# Log the error for debugging
|
||||
self.logger.error(
|
||||
f"API error: {error_info['error_code']} - {error_info['message']}"
|
||||
)
|
||||
|
||||
# Raise appropriate exception
|
||||
if response.status in (401, 403):
|
||||
raise AuthenticationError(
|
||||
f"[{error_info['error_code']}] {error_info['message']}"
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||
self.logger.warning(
|
||||
f"Timeout connecting to {url}, "
|
||||
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
raise Exception(f"[{error_info['error_code']}] {error_info['message']}")
|
||||
except aiohttp.ClientConnectorCertificateError as e:
|
||||
raise ConnectionError(
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The site's SSL certificate is invalid or expired. "
|
||||
f"Details: {e}"
|
||||
) from e
|
||||
|
||||
# Return JSON response
|
||||
return await response.json()
|
||||
except aiohttp.ClientConnectorDNSError as e:
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
) from e
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
os_error = getattr(e, "os_error", None)
|
||||
if isinstance(os_error, socket.gaierror):
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
raise ConnectionError(
|
||||
f"DNS resolution failed for '{host}'. "
|
||||
"The domain name could not be found. "
|
||||
"Please check that the URL is correct."
|
||||
) from e
|
||||
|
||||
raise ConnectionError(
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable. Possible causes: "
|
||||
"wrong URL, server is down, firewall blocking, or wrong port."
|
||||
) from e
|
||||
|
||||
except aiohttp.InvalidURL:
|
||||
raise ConnectionError(
|
||||
f"Invalid URL: {self.site_url}. "
|
||||
"Please provide a valid URL starting with https:// or http://."
|
||||
)
|
||||
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
last_exception = ConnectionError(
|
||||
f"Network error connecting to {self.site_url}: {e}"
|
||||
)
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||
self.logger.warning(
|
||||
f"Network error for {url}: {e}, "
|
||||
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
# All retries exhausted
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
def _parse_error_response(
|
||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||
@@ -408,28 +514,33 @@ class WordPressClient:
|
||||
Dict with 'available' bool and version info
|
||||
"""
|
||||
try:
|
||||
# Try to access WooCommerce system status endpoint
|
||||
response = await self.get("system_status", use_woocommerce=True)
|
||||
return {
|
||||
"available": True,
|
||||
"version": response.get("environment", {}).get("version", "unknown"),
|
||||
}
|
||||
except Exception:
|
||||
return {"available": False, "version": None}
|
||||
except AuthenticationError:
|
||||
return {"available": False, "version": None, "reason": "authentication_failed"}
|
||||
except ConnectionError as e:
|
||||
return {"available": False, "version": None, "reason": str(e)}
|
||||
except Exception as e:
|
||||
self.logger.debug(f"WooCommerce check failed: {e}")
|
||||
return {"available": False, "version": None, "reason": str(e)}
|
||||
|
||||
async def check_site_health(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check WordPress site health and accessibility.
|
||||
|
||||
Returns:
|
||||
Dict with health status information
|
||||
Dict with health status information including specific error diagnosis.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(f"{self.site_url}/wp-json") as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return {
|
||||
result = {
|
||||
"healthy": True,
|
||||
"accessible": True,
|
||||
"name": data.get("name", "Unknown"),
|
||||
@@ -437,15 +548,100 @@ class WordPressClient:
|
||||
"url": data.get("url", self.site_url),
|
||||
"routes": len(data.get("routes", {})),
|
||||
}
|
||||
else:
|
||||
|
||||
# Test authentication with an authenticated request
|
||||
try:
|
||||
await self.get("users/me")
|
||||
result["auth_valid"] = True
|
||||
except Exception:
|
||||
result["auth_valid"] = False
|
||||
result["auth_warning"] = (
|
||||
"Site accessible but credentials may be invalid"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# Detect REST API disabled (common with security plugins)
|
||||
if response.status in (403, 404):
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"message": f"Site returned status {response.status}",
|
||||
"accessible": True,
|
||||
"error_type": "rest_api_disabled",
|
||||
"message": (
|
||||
f"WordPress REST API returned {response.status}. "
|
||||
"The REST API may be disabled by a security plugin "
|
||||
"(e.g., Wordfence, iThemes Security, Disable REST API). "
|
||||
"Please ensure the REST API is enabled for MCP Hub to work."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
|
||||
if response.status == 401:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": True,
|
||||
"error_type": "auth_required",
|
||||
"message": (
|
||||
"WordPress REST API requires authentication even for discovery. "
|
||||
"This may be caused by a security plugin restricting public access."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": True,
|
||||
"error_type": "unexpected_status",
|
||||
"message": f"Site returned HTTP {response.status}.",
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"message": f"Health check failed: {str(e)}",
|
||||
"error_type": "timeout",
|
||||
"message": (
|
||||
f"Site at {self.site_url} did not respond within 10 seconds. "
|
||||
"The server may be overloaded or down."
|
||||
),
|
||||
}
|
||||
|
||||
except aiohttp.ClientConnectorDNSError:
|
||||
host = self.site_url.split("://")[-1].split("/")[0]
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"error_type": "dns_failure",
|
||||
"message": (
|
||||
f"DNS resolution failed for '{host}'. " "Please check that the URL is correct."
|
||||
),
|
||||
}
|
||||
|
||||
except aiohttp.ClientConnectorCertificateError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"error_type": "ssl_error",
|
||||
"message": (
|
||||
f"SSL certificate error for {self.site_url}. "
|
||||
"The certificate may be expired or invalid."
|
||||
),
|
||||
}
|
||||
|
||||
except aiohttp.ClientConnectorError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"error_type": "connection_refused",
|
||||
"message": (
|
||||
f"Cannot connect to {self.site_url}. "
|
||||
"The server is unreachable — check URL, firewall, or server status."
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Health check failed with unexpected error: {e}")
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"error_type": "unknown",
|
||||
"message": f"Health check failed: {e}",
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ class MenusHandler:
|
||||
# Try custom endpoint first, fallback to standard if not available
|
||||
try:
|
||||
menus = await self.client.get("menus", use_custom_namespace=True)
|
||||
except:
|
||||
except Exception:
|
||||
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
|
||||
menus = await self.client.get("navigation")
|
||||
|
||||
@@ -216,7 +216,7 @@ class MenusHandler:
|
||||
# Get menu details
|
||||
try:
|
||||
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
|
||||
except:
|
||||
except Exception:
|
||||
menu = await self.client.get(f"navigation/{menu_id}")
|
||||
|
||||
# Get menu items
|
||||
@@ -256,7 +256,7 @@ class MenusHandler:
|
||||
|
||||
try:
|
||||
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
|
||||
except:
|
||||
except Exception:
|
||||
# Try navigation endpoint
|
||||
menu = await self.client.post("navigation", json_data=data)
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ Security:
|
||||
- WP-CLI installation check
|
||||
- Timeout protection (30s default)
|
||||
- Graceful error handling
|
||||
|
||||
Phase 5.1: Cache Management (4 tools)
|
||||
Phase 5.2: Database & Plugin/Theme Info (7 tools)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -75,10 +72,7 @@ class WPCLIManager:
|
||||
Check if the Docker container exists and is running.
|
||||
|
||||
Returns:
|
||||
bool: True if container exists and is running
|
||||
|
||||
Raises:
|
||||
Exception: If docker command fails or container not found
|
||||
bool: True if container exists and is running, False otherwise
|
||||
"""
|
||||
try:
|
||||
# First, test if we have Docker socket access
|
||||
@@ -93,15 +87,12 @@ class WPCLIManager:
|
||||
|
||||
if test_process.returncode != 0:
|
||||
error_msg = test_stderr.decode().strip()
|
||||
self.logger.error(f"Cannot access Docker daemon: {error_msg}")
|
||||
raise Exception(
|
||||
f"Cannot access Docker daemon. This is likely a permissions issue. "
|
||||
f"Error: {error_msg}. "
|
||||
f"Please ensure:\n"
|
||||
f"1. Docker socket is mounted: /var/run/docker.sock\n"
|
||||
f"2. User has permission to access Docker (member of docker group)\n"
|
||||
f"3. Docker daemon is running on the host"
|
||||
self.logger.warning(
|
||||
f"Docker daemon not accessible for container '{self.container_name}': "
|
||||
f"{error_msg}. WP-CLI features will be unavailable. "
|
||||
f"Mount /var/run/docker.sock to enable WP-CLI."
|
||||
)
|
||||
return False
|
||||
|
||||
docker_version = test_stdout.decode().strip()
|
||||
self.logger.debug(f"Docker access OK - Server version: {docker_version}")
|
||||
@@ -151,15 +142,14 @@ class WPCLIManager:
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
self.logger.error("Docker command timed out")
|
||||
raise Exception("Docker command timed out after 5 seconds")
|
||||
self.logger.warning(
|
||||
f"Docker command timed out for container '{self.container_name}'. "
|
||||
f"WP-CLI features will be unavailable."
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
# Re-raise exceptions with full context
|
||||
if "Cannot access Docker" in str(e) or "not found" in str(e):
|
||||
raise
|
||||
else:
|
||||
self.logger.error(f"Error checking container: {e}")
|
||||
raise Exception(f"Failed to check container existence: {str(e)}")
|
||||
self.logger.warning(f"Docker check failed for '{self.container_name}': {e}")
|
||||
return False
|
||||
|
||||
async def _check_wp_cli_available(self) -> bool:
|
||||
"""
|
||||
@@ -609,7 +599,7 @@ class WPCLIManager:
|
||||
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
size_str = "unknown"
|
||||
except:
|
||||
except (ValueError, IndexError, OSError):
|
||||
size_str = "unknown"
|
||||
|
||||
return {
|
||||
@@ -1208,7 +1198,7 @@ class WPCLIManager:
|
||||
site_cmd = "core version"
|
||||
version_result = await self._execute_wp_cli(site_cmd)
|
||||
current_version = version_result.get("message", "unknown").strip()
|
||||
except:
|
||||
except Exception:
|
||||
current_version = "unknown"
|
||||
|
||||
return {
|
||||
@@ -1230,7 +1220,7 @@ class WPCLIManager:
|
||||
try:
|
||||
version_result = await self._execute_wp_cli("core version")
|
||||
old_version = version_result.get("message", "unknown").strip()
|
||||
except:
|
||||
except Exception:
|
||||
old_version = "unknown"
|
||||
|
||||
# Execute update (long timeout for downloads)
|
||||
@@ -1240,7 +1230,7 @@ class WPCLIManager:
|
||||
try:
|
||||
version_result = await self._execute_wp_cli("core version")
|
||||
new_version = version_result.get("message", "unknown").strip()
|
||||
except:
|
||||
except Exception:
|
||||
new_version = "unknown"
|
||||
|
||||
# Determine update type
|
||||
|
||||
@@ -362,7 +362,7 @@ class SystemHandler:
|
||||
try:
|
||||
db_result = await self.wp_cli.execute_command(db_cmd)
|
||||
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
||||
except:
|
||||
except (ValueError, KeyError, Exception):
|
||||
sizes["database_size_mb"] = 0.0
|
||||
|
||||
# Calculate total
|
||||
@@ -456,7 +456,7 @@ class SystemHandler:
|
||||
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
||||
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
||||
schedules = {s.get("name"): s for s in schedules_data}
|
||||
except:
|
||||
except (json.JSONDecodeError, KeyError, Exception):
|
||||
schedules = {}
|
||||
|
||||
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
||||
@@ -514,7 +514,7 @@ class SystemHandler:
|
||||
size_result = await self.wp_cli.execute_command(size_cmd)
|
||||
log_size_bytes = int(size_result.get("output", "0").strip())
|
||||
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
||||
except:
|
||||
except (ValueError, KeyError, Exception):
|
||||
log_size_mb = 0.0
|
||||
|
||||
# Read log file (last N lines)
|
||||
|
||||
@@ -112,30 +112,57 @@ class WordPressAdvancedPlugin(BasePlugin):
|
||||
Dict with health status and WP-CLI availability
|
||||
"""
|
||||
try:
|
||||
# Test WP-CLI access (primary requirement for wordpress_advanced)
|
||||
wp_cli_version = await self.system.wp_cli_version()
|
||||
wp_cli_available = bool(wp_cli_version.get("version"))
|
||||
|
||||
# Test REST API access with a public endpoint
|
||||
# Test REST API access by hitting /wp-json/ directly
|
||||
# NOTE: self.client.get("/") hits /wp-json/wp/v2/ which doesn't
|
||||
# return a "name" field. We need /wp-json/ for the site index.
|
||||
rest_api_available = False
|
||||
try:
|
||||
# Use a public endpoint that doesn't require authentication
|
||||
site_info = await self.client.get("/")
|
||||
rest_api_available = bool(site_info.get("name"))
|
||||
except Exception as e:
|
||||
self.logger.warning(f"REST API check failed (non-critical): {e}")
|
||||
rest_api_available = False
|
||||
import aiohttp
|
||||
|
||||
return {
|
||||
"healthy": wp_cli_available, # Only WP-CLI is critical for wordpress_advanced
|
||||
site_url = self.client.site_url
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{site_url}/wp-json/",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
rest_api_available = bool(data.get("name"))
|
||||
except Exception as e:
|
||||
self.logger.warning(f"REST API check failed: {e}")
|
||||
|
||||
# Test authentication with an authenticated request
|
||||
auth_valid = False
|
||||
if rest_api_available:
|
||||
try:
|
||||
await self.client.get("users/me")
|
||||
auth_valid = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Test WP-CLI access (optional — only needed for database/system tools)
|
||||
wp_cli_available = False
|
||||
try:
|
||||
wp_cli_version = await self.system.wp_cli_version()
|
||||
wp_cli_available = bool(wp_cli_version.get("version"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {
|
||||
"healthy": rest_api_available,
|
||||
"wp_cli_available": wp_cli_available,
|
||||
"rest_api_available": rest_api_available,
|
||||
"auth_valid": auth_valid,
|
||||
"features": {
|
||||
"database_operations": wp_cli_available,
|
||||
"bulk_operations": rest_api_available,
|
||||
"system_operations": wp_cli_available,
|
||||
},
|
||||
}
|
||||
if not auth_valid and rest_api_available:
|
||||
result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||
return result
|
||||
except Exception as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcphub-server"
|
||||
version = "3.0.0"
|
||||
version = "3.1.0"
|
||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||
authors = [
|
||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||
@@ -37,7 +37,9 @@ dependencies = [
|
||||
"authlib>=1.5.0",
|
||||
"PyJWT>=2.8.0",
|
||||
"cryptography>=46.0.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"jinja2>=3.1.2",
|
||||
"bcrypt>=4.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -66,11 +68,15 @@ Changelog = "https://github.com/airano-ir/mcphub/releases"
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["server", "server_multi"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "plugins*"]
|
||||
exclude = ["tests*", "data*", "logs*", "temp*", "scripts*", "docs*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"core" = ["templates/**/*.html"]
|
||||
"*" = ["*.html", "*.css", "*.js", "*.json"]
|
||||
|
||||
# ====================================
|
||||
|
||||
@@ -17,7 +17,13 @@ python-dotenv>=1.0.0
|
||||
# OAuth 2.1 Infrastructure
|
||||
authlib>=1.5.0 # OAuth 2.1 server/client implementation
|
||||
PyJWT>=2.8.0 # JWT encoding/decoding
|
||||
cryptography>=46.0.0 # For RSA/ECDSA JWT signing
|
||||
cryptography>=46.0.0 # For RSA/ECDSA JWT signing + AES-256-GCM encryption
|
||||
|
||||
# Template engine for OAuth Authorization Page (Phase E)
|
||||
# Template engine for dashboard and OAuth pages
|
||||
jinja2>=3.1.2 # HTML template rendering
|
||||
|
||||
# Database (Track E — Live Platform)
|
||||
aiosqlite>=0.20.0 # Async SQLite for user data, sites, API keys
|
||||
|
||||
# User API key hashing (Track E.3)
|
||||
bcrypt>=4.0.0 # Secure password hashing for user API keys
|
||||
|
||||
72
server.json
Normal file
72
server.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.airano-ir/mcphub",
|
||||
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 596 tools, multi-site support, and OAuth 2.1 authentication.",
|
||||
"title": "MCP Hub",
|
||||
"websiteUrl": "https://github.com/airano-ir/mcphub",
|
||||
"repository": {
|
||||
"url": "https://github.com/airano-ir/mcphub",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "3.1.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"registryBaseUrl": "https://pypi.org",
|
||||
"identifier": "mcphub-server",
|
||||
"version": "3.1.0",
|
||||
"runtimeHint": "uvx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"environmentVariables": [
|
||||
{
|
||||
"name": "MASTER_API_KEY",
|
||||
"description": "Master API key for authentication (also used for dashboard login)",
|
||||
"isRequired": true,
|
||||
"isSecret": true
|
||||
},
|
||||
{
|
||||
"name": "WORDPRESS_SITE1_URL",
|
||||
"description": "WordPress site URL (e.g., https://example.com)"
|
||||
},
|
||||
{
|
||||
"name": "WORDPRESS_SITE1_USERNAME",
|
||||
"description": "WordPress admin username"
|
||||
},
|
||||
{
|
||||
"name": "WORDPRESS_SITE1_APP_PASSWORD",
|
||||
"description": "WordPress Application Password",
|
||||
"isSecret": true
|
||||
},
|
||||
{
|
||||
"name": "GITEA_SITE1_URL",
|
||||
"description": "Gitea instance URL"
|
||||
},
|
||||
{
|
||||
"name": "GITEA_SITE1_TOKEN",
|
||||
"description": "Gitea API token",
|
||||
"isSecret": true
|
||||
},
|
||||
{
|
||||
"name": "N8N_SITE1_URL",
|
||||
"description": "n8n instance URL"
|
||||
},
|
||||
{
|
||||
"name": "N8N_SITE1_API_KEY",
|
||||
"description": "n8n API key",
|
||||
"isSecret": true
|
||||
},
|
||||
{
|
||||
"name": "SUPABASE_SITE1_URL",
|
||||
"description": "Supabase project URL"
|
||||
},
|
||||
{
|
||||
"name": "SUPABASE_SITE1_SERVICE_ROLE_KEY",
|
||||
"description": "Supabase service role key",
|
||||
"isSecret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,7 +19,7 @@ Benefits:
|
||||
- Scalable architecture
|
||||
|
||||
Usage:
|
||||
python server_multi.py --transport sse --port 8000
|
||||
python server_multi.py --transport streamable-http --port 8000
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
64
smithery.yaml
Normal file
64
smithery.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
|
||||
|
||||
startCommand:
|
||||
type: stdio
|
||||
configSchema:
|
||||
# JSON Schema defining the configuration options for the MCP.
|
||||
type: object
|
||||
required:
|
||||
- masterApiKey
|
||||
properties:
|
||||
masterApiKey:
|
||||
type: string
|
||||
description: Master API key for authentication. Generate with python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
wordpressSite1Url:
|
||||
type: string
|
||||
description: WordPress site URL (e.g., https://example.com)
|
||||
wordpressSite1Username:
|
||||
type: string
|
||||
description: WordPress admin username
|
||||
wordpressSite1AppPassword:
|
||||
type: string
|
||||
description: WordPress Application Password (generate in WP admin > Users > Profile > Application Passwords)
|
||||
giteaSite1Url:
|
||||
type: string
|
||||
description: Gitea instance URL (e.g., https://gitea.example.com)
|
||||
giteaSite1Token:
|
||||
type: string
|
||||
description: Gitea API token
|
||||
n8nSite1Url:
|
||||
type: string
|
||||
description: n8n instance URL (e.g., https://n8n.example.com)
|
||||
n8nSite1ApiKey:
|
||||
type: string
|
||||
description: n8n API key
|
||||
supabaseSite1Url:
|
||||
type: string
|
||||
description: Supabase project URL
|
||||
supabaseSite1ServiceRoleKey:
|
||||
type: string
|
||||
description: Supabase service role key
|
||||
commandFunction:
|
||||
# A function that produces the CLI command to start the MCP on stdio.
|
||||
|-
|
||||
(config) => {
|
||||
const env = { MASTER_API_KEY: config.masterApiKey };
|
||||
if (config.wordpressSite1Url) {
|
||||
env.WORDPRESS_SITE1_URL = config.wordpressSite1Url;
|
||||
env.WORDPRESS_SITE1_USERNAME = config.wordpressSite1Username || '';
|
||||
env.WORDPRESS_SITE1_APP_PASSWORD = config.wordpressSite1AppPassword || '';
|
||||
}
|
||||
if (config.giteaSite1Url) {
|
||||
env.GITEA_SITE1_URL = config.giteaSite1Url;
|
||||
env.GITEA_SITE1_TOKEN = config.giteaSite1Token || '';
|
||||
}
|
||||
if (config.n8nSite1Url) {
|
||||
env.N8N_SITE1_URL = config.n8nSite1Url;
|
||||
env.N8N_SITE1_API_KEY = config.n8nSite1ApiKey || '';
|
||||
}
|
||||
if (config.supabaseSite1Url) {
|
||||
env.SUPABASE_SITE1_URL = config.supabaseSite1Url;
|
||||
env.SUPABASE_SITE1_SERVICE_ROLE_KEY = config.supabaseSite1ServiceRoleKey || '';
|
||||
}
|
||||
return { command: 'uvx', args: ['mcphub-server'], env };
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
||||
|
||||
<!-- Vazirmatn Font for Persian -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- HTMX for dynamic updates -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for simple interactions -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Sidebar transition */
|
||||
.sidebar-transition {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator pulse */
|
||||
.status-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* RTL adjustments */
|
||||
[dir="rtl"] .sidebar-icon {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
[dir="ltr"] .sidebar-icon {
|
||||
margin-right: 0.75rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Vazirmatn font for Persian */
|
||||
[dir="rtl"],
|
||||
[dir="rtl"] * {
|
||||
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen" x-data="{ sidebarOpen: true, darkMode: true }">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="sidebar-transition bg-gray-800 border-gray-700 flex flex-col"
|
||||
:class="sidebarOpen ? 'w-64' : 'w-20'"
|
||||
{% if lang == 'fa' %}style="border-left: 1px solid;"{% else %}style="border-right: 1px solid;"{% endif %}
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-700">
|
||||
<div class="flex items-center" x-show="sidebarOpen">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg">MCP Hub</span>
|
||||
</div>
|
||||
<button
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||
{% set nav_items = [
|
||||
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
||||
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', '/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'),
|
||||
('health', t.health, 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', '/dashboard/health'),
|
||||
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
|
||||
] %}
|
||||
|
||||
{% for item_id, label, icon_path, url in nav_items %}
|
||||
<a
|
||||
href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-300 hover:bg-gray-700 hover:text-white{% endif %}"
|
||||
>
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}"/>
|
||||
</svg>
|
||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<a
|
||||
href="/dashboard/logout"
|
||||
class="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto bg-gray-900">
|
||||
<!-- Top Header -->
|
||||
<header class="sticky top-0 z-10 bg-gray-800/95 backdrop-blur border-b border-gray-700">
|
||||
<div class="flex items-center justify-between h-16 px-6">
|
||||
<h1 class="text-xl font-semibold">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
||||
|
||||
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||
<!-- Language Toggle -->
|
||||
<a
|
||||
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||
class="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
||||
</a>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
class="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="{{ t.refresh }}"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Info -->
|
||||
{% if session %}
|
||||
<div class="flex items-center text-sm text-gray-400">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
{{ session.user_type }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,292 +0,0 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.dashboard }}{% endblock %}
|
||||
{% block page_title %}{{ t.overview }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Projects Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{{ t.total_projects }}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.projects_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{{ t.active_api_keys }}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.api_keys_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-green-400 hover:text-green-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tools Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{{ t.total_tools }}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.tools_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-400">
|
||||
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Uptime Card -->
|
||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-400">{{ t.system_uptime }}</p>
|
||||
<p class="text-3xl font-bold text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-orange-400 hover:text-orange-300 flex items-center">
|
||||
{{ t.view_all }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-700">
|
||||
{% if recent_activity %}
|
||||
{% for activity in recent_activity %}
|
||||
<div class="p-4 hover:bg-gray-700/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<!-- Icon based on type -->
|
||||
{% if activity.type == 'tool_call' %}
|
||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif activity.type == 'auth' %}
|
||||
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif activity.level == 'ERROR' %}
|
||||
<div class="w-8 h-8 bg-red-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-8 h-8 bg-gray-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ activity.message }}</p>
|
||||
<p class="text-xs text-gray-400">{{ activity.project }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:19] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 2"/>
|
||||
</svg>
|
||||
<p>{{ t.no_activity }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if recent_activity %}
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||
{{ t.view_all }} →
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
<!-- Projects by Type -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{{ t.projects_by_type }}</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'bg-blue-500',
|
||||
'woocommerce': 'bg-purple-500',
|
||||
'wordpress_advanced': 'bg-indigo-500',
|
||||
'gitea': 'bg-green-500',
|
||||
'n8n': 'bg-orange-500',
|
||||
'supabase': 'bg-emerald-500',
|
||||
'openpanel': 'bg-cyan-500',
|
||||
'appwrite': 'bg-pink-500',
|
||||
'directus': 'bg-violet-500',
|
||||
} %}
|
||||
|
||||
{% set plugin_icons = {
|
||||
'wordpress': 'W',
|
||||
'woocommerce': 'WC',
|
||||
'wordpress_advanced': 'WA',
|
||||
'gitea': 'G',
|
||||
'n8n': 'n8n',
|
||||
'supabase': 'SB',
|
||||
'openpanel': 'OP',
|
||||
'appwrite': 'AW',
|
||||
'directus': 'DI',
|
||||
} %}
|
||||
|
||||
{% if projects_by_type %}
|
||||
{% for plugin_type, count in projects_by_type.items() %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-300">{{ plugin_type|plugin_name }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-center text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Status -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-white">{{ t.health_status }}</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
{% for component, status in health_summary.components.items() %}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-300 capitalize">{{ component }}</span>
|
||||
<div class="flex items-center">
|
||||
{% if status == 'healthy' %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||
{% elif status == 'warning' %}
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-yellow-400">{{ t.warning }}</span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-red-400">{{ t.error }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<p class="text-xs text-gray-500">
|
||||
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
||||
{{ health_summary.last_check[:19] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||
{{ t.view_all }} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support / Donate -->
|
||||
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-white mb-1">
|
||||
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
|
||||
</p>
|
||||
<a
|
||||
href="https://nowpayments.io/donation/airano"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Auto-refresh dashboard every 30 seconds
|
||||
htmx.config.defaultSwapStyle = 'outerHTML';
|
||||
|
||||
// Refresh stats periodically
|
||||
setInterval(function() {
|
||||
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,259 +0,0 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.projects }}{% endblock %}
|
||||
{% block page_title %}{{ t.projects }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Filters -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||
<!-- Plugin Type Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
||||
<select
|
||||
name="plugin_type"
|
||||
onchange="this.form.submit()"
|
||||
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
<option value="">{{ t.all }} Types</option>
|
||||
{% for plugin_type in available_plugin_types %}
|
||||
<option value="{{ plugin_type }}" {% if selected_plugin_type == plugin_type %}selected{% endif %}>
|
||||
{{ plugin_type|plugin_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
onchange="this.form.submit()"
|
||||
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
<option value="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
||||
<option value="healthy" {% if selected_status == 'healthy' %}selected{% endif %}>{{ t.healthy }}</option>
|
||||
<option value="warning" {% if selected_status == 'warning' %}selected{% endif %}>{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</option>
|
||||
<option value="unhealthy" {% if selected_status == 'unhealthy' %}selected{% endif %}>{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
||||
<option value="unknown" {% if selected_status == 'unknown' %}selected{% endif %}>{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value="{{ search_query }}"
|
||||
placeholder="{{ t.search }}..."
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t.search }}
|
||||
</button>
|
||||
|
||||
{% if search_query or selected_plugin_type or selected_status %}
|
||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Projects Table -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
URL
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
{% if projects %}
|
||||
{% for project in projects %}
|
||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
||||
<!-- Plugin Type -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
{% set plugin_colors = {
|
||||
'wordpress': 'bg-blue-500',
|
||||
'woocommerce': 'bg-purple-500',
|
||||
'wordpress_advanced': 'bg-indigo-500',
|
||||
'gitea': 'bg-green-500',
|
||||
'n8n': 'bg-orange-500',
|
||||
'supabase': 'bg-emerald-500',
|
||||
'openpanel': 'bg-cyan-500',
|
||||
'appwrite': 'bg-pink-500',
|
||||
'directus': 'bg-violet-500',
|
||||
} %}
|
||||
{% set plugin_icons = {
|
||||
'wordpress': 'W',
|
||||
'woocommerce': 'WC',
|
||||
'wordpress_advanced': 'WA',
|
||||
'gitea': 'G',
|
||||
'n8n': 'n8n',
|
||||
'supabase': 'SB',
|
||||
'openpanel': 'OP',
|
||||
'appwrite': 'AW',
|
||||
'directus': 'DI',
|
||||
} %}
|
||||
<div class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type, project.plugin_type[:2]|upper) }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-white">{{ project.plugin_type|plugin_name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Site ID -->
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-300 font-mono">{{ project.site_id }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Alias -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.alias %}
|
||||
<span class="px-2 py-1 bg-primary-500/20 text-primary-400 text-xs rounded-lg font-medium">
|
||||
{{ project.alias }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500 text-sm">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- URL -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.url %}
|
||||
<a href="{{ project.url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block" title="{{ project.url }}">
|
||||
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-500 text-sm">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-6 py-4">
|
||||
{% if project.health_status == 'healthy' %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||
</div>
|
||||
{% elif project.health_status == 'warning' %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</span>
|
||||
</div>
|
||||
{% elif project.health_status == 'unhealthy' %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-red-400">{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center">
|
||||
<span class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||
<span class="text-sm text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4">
|
||||
<a
|
||||
href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"
|
||||
>
|
||||
{{ t.view }}
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-400">
|
||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||
</p>
|
||||
{% if search_query or selected_plugin_type %}
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} پروژه
|
||||
{% else %}
|
||||
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of {{ total_count }} projects
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if page_number > 1 %}
|
||||
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == page_number %}
|
||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||
<a href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
<span class="text-gray-500">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_number < total_pages %}
|
||||
<a href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,210 +0,0 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.settings }}{% endblock %}
|
||||
{% block page_title %}{{ t.settings }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Language Settings -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/dashboard/settings?lang=en"
|
||||
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-300 hover:bg-gray-600{% endif %}">
|
||||
English
|
||||
</a>
|
||||
<a href="/dashboard/settings?lang=fa"
|
||||
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-300 hover:bg-gray-600{% endif %}">
|
||||
فارسی
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
زبان فعلی: فارسی
|
||||
{% else %}
|
||||
Current language: English
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Configuration -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.server_mode }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.port }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.log_level }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registered Plugins -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
||||
</h3>
|
||||
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||
{% if lang == 'fa' %}قابلیت آینده{% else %}Coming Soon{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{% if plugins %}
|
||||
{% for plugin in plugins %}
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-700 last:border-0">
|
||||
<div>
|
||||
<p class="text-white font-medium">{{ plugin.name }}</p>
|
||||
<p class="text-sm text-gray-400">{{ plugin.description }}</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
||||
{{ t.active }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="bg-gray-700/30 rounded-lg p-4">
|
||||
<p class="text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{% if lang == 'fa' %}
|
||||
سیستم پلاگین در فازهای آینده اضافه خواهد شد. این امکان به شما اجازه میدهد قابلیتهای سفارشی به MCP Hub اضافه کنید.
|
||||
{% else %}
|
||||
The plugin system will be added in future phases. This will allow you to extend MCP Hub with custom functionality.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-primary-500/20 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xl font-bold text-white">MCP Hub</h4>
|
||||
<p class="text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ about.version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ about.mcp_version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ about.python_version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ about.tools_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
<p class="text-sm text-gray-400">
|
||||
{% if lang == 'fa' %}
|
||||
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
||||
{% else %}
|
||||
MCP Hub is a multi-purpose MCP server that enables connection to various services including WordPress, Gitea, n8n, and other services.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Information -->
|
||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||
<p class="text-white font-mono">{{ session.user_type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ session.created_at[:19] if session.created_at else '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
||||
<p class="text-white font-mono text-sm">{{ session.expires_at[:19] if session.expires_at else '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
<a href="/dashboard/logout"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
||||
{{ t.logout }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to verify per-project API key isolation.
|
||||
|
||||
This test checks that:
|
||||
1. Per-project API key can access its own project
|
||||
2. Per-project API key CANNOT access other projects
|
||||
3. Global API key can access all projects
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Mock setup
|
||||
os.environ["MASTER_API_KEY"] = "test_master_key_123"
|
||||
os.environ["WORDPRESS_SITE1_URL"] = "https://site1.example.com"
|
||||
os.environ["WORDPRESS_SITE1_USERNAME"] = "admin"
|
||||
os.environ["WORDPRESS_SITE1_APP_PASSWORD"] = "password1"
|
||||
os.environ["WORDPRESS_SITE4_URL"] = "https://site4.example.com"
|
||||
os.environ["WORDPRESS_SITE4_USERNAME"] = "admin"
|
||||
os.environ["WORDPRESS_SITE4_APP_PASSWORD"] = "password4"
|
||||
|
||||
# Import after env setup
|
||||
from core.api_keys import get_api_key_manager
|
||||
from core.project_manager import get_project_manager
|
||||
from core.site_registry import get_site_registry
|
||||
from core.unified_tools import UnifiedToolGenerator
|
||||
|
||||
print("=" * 60)
|
||||
print("Testing Per-Project API Key Isolation")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize
|
||||
api_key_manager = get_api_key_manager()
|
||||
project_manager = get_project_manager()
|
||||
site_registry = get_site_registry()
|
||||
|
||||
# Discover sites
|
||||
from plugins import registry as plugin_registry
|
||||
|
||||
plugin_types = plugin_registry.get_registered_types()
|
||||
site_registry.discover_sites(plugin_types)
|
||||
|
||||
print(f"\nDiscovered sites: {list(site_registry.sites.keys())}")
|
||||
|
||||
# Create API keys
|
||||
print("\n1. Creating API keys...")
|
||||
|
||||
# Global key
|
||||
global_key = api_key_manager.create_key(
|
||||
project_id="*", scope="admin", description="Global test key"
|
||||
)
|
||||
print(f" ✓ Global key created: {global_key}")
|
||||
|
||||
# Per-project key for wordpress_site4
|
||||
site4_key = api_key_manager.create_key(
|
||||
project_id="wordpress_site4", scope="admin", description="Site4 only key"
|
||||
)
|
||||
print(f" ✓ Site4 key created: {site4_key}")
|
||||
|
||||
# Create unified tool generator
|
||||
unified_gen = UnifiedToolGenerator(project_manager)
|
||||
unified_tools = unified_gen.generate_all_unified_tools()
|
||||
print(f"\n2. Generated {len(unified_tools)} unified tools")
|
||||
|
||||
# Find wordpress_list_posts tool
|
||||
list_posts_tool = None
|
||||
for tool in unified_tools:
|
||||
if tool["name"] == "wordpress_list_posts":
|
||||
list_posts_tool = tool
|
||||
break
|
||||
|
||||
if not list_posts_tool:
|
||||
print(" ✗ wordpress_list_posts tool not found!")
|
||||
exit(1)
|
||||
|
||||
print(" ✓ Found wordpress_list_posts tool")
|
||||
|
||||
|
||||
async def test_access(key_token, site_id, expected_result):
|
||||
"""Test if a key can access a site"""
|
||||
from server import _api_key_context
|
||||
|
||||
# Validate key and set context (simulating middleware)
|
||||
key_id = api_key_manager.validate_key(
|
||||
key_token, project_id="*", required_scope="read", skip_project_check=True
|
||||
)
|
||||
|
||||
if key_id:
|
||||
key = api_key_manager.keys.get(key_id)
|
||||
_api_key_context.set(
|
||||
{
|
||||
"key_id": key_id,
|
||||
"project_id": key.project_id,
|
||||
"scope": key.scope,
|
||||
"is_global": key.project_id == "*",
|
||||
}
|
||||
)
|
||||
|
||||
# Try to call the handler
|
||||
handler = list_posts_tool["handler"]
|
||||
result = await handler(site=site_id, per_page=1)
|
||||
|
||||
# Check result
|
||||
is_error = isinstance(result, str) and result.startswith("Error: Access denied")
|
||||
|
||||
if expected_result == "allowed":
|
||||
if not is_error:
|
||||
print(" ✓ Access allowed as expected")
|
||||
return True
|
||||
else:
|
||||
print(" ✗ FAIL: Access denied but should be allowed!")
|
||||
print(f" Result: {result[:100]}")
|
||||
return False
|
||||
else: # expected_result == "denied"
|
||||
if is_error:
|
||||
print(" ✓ Access denied as expected")
|
||||
return True
|
||||
else:
|
||||
print(" ✗ FAIL: Access allowed but should be denied!")
|
||||
print(f" Result: {result[:100] if isinstance(result, str) else str(result)[:100]}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_tests():
|
||||
"""Run all test cases"""
|
||||
print("\n3. Testing access control...")
|
||||
|
||||
all_pass = True
|
||||
|
||||
# Test 1: Per-project key accessing its own project
|
||||
print("\n Test 1: Per-project key (site4) → site4")
|
||||
all_pass &= await test_access(site4_key, "site4", "allowed")
|
||||
|
||||
# Test 2: Per-project key accessing different project
|
||||
print("\n Test 2: Per-project key (site4) → site1")
|
||||
all_pass &= await test_access(site4_key, "site1", "denied")
|
||||
|
||||
# Test 3: Global key accessing any project
|
||||
print("\n Test 3: Global key → site1")
|
||||
all_pass &= await test_access(global_key, "site1", "allowed")
|
||||
|
||||
print("\n Test 4: Global key → site4")
|
||||
all_pass &= await test_access(global_key, "site4", "allowed")
|
||||
|
||||
return all_pass
|
||||
|
||||
|
||||
# Run tests
|
||||
result = asyncio.run(run_tests())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if result:
|
||||
print("✅ All tests PASSED!")
|
||||
print("Per-project API key isolation is working correctly!")
|
||||
else:
|
||||
print("❌ Some tests FAILED!")
|
||||
print("Per-project API key isolation has issues!")
|
||||
print("=" * 60)
|
||||
|
||||
exit(0 if result else 1)
|
||||
@@ -100,8 +100,6 @@ class IntegrationTester:
|
||||
# Check handlers initialized
|
||||
assert hasattr(plugin, "posts"), "Missing posts handler"
|
||||
assert hasattr(plugin, "media"), "Missing media handler"
|
||||
assert hasattr(plugin, "products"), "Missing products handler"
|
||||
assert hasattr(plugin, "orders"), "Missing orders handler"
|
||||
|
||||
self.add_result(
|
||||
"wordpress_plugin_init", "passed", "WordPress plugin initializes correctly"
|
||||
@@ -158,11 +156,6 @@ class IntegrationTester:
|
||||
"CommentsHandler",
|
||||
"UsersHandler",
|
||||
"SiteHandler",
|
||||
"ProductsHandler",
|
||||
"OrdersHandler",
|
||||
"CustomersHandler",
|
||||
"ReportsHandler",
|
||||
"CouponsHandler",
|
||||
"SEOHandler",
|
||||
"WPCLIHandler",
|
||||
"MenusHandler",
|
||||
|
||||
154
tests/test_config_snippets.py
Normal file
154
tests/test_config_snippets.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for MCP client configuration snippet generation (core/config_snippets.py)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config_snippets import generate_config, get_supported_clients
|
||||
|
||||
# ── Test Data ─────────────────────────────────────────────────
|
||||
|
||||
BASE_URL = "https://mcp.example.com"
|
||||
USER_ID = "abc123-uuid"
|
||||
ALIAS = "myblog"
|
||||
API_KEY = "mhu_testapikey1234567890abcdefghijklmnopqr"
|
||||
EXPECTED_ENDPOINT = f"{BASE_URL}/u/{USER_ID}/{ALIAS}/mcp"
|
||||
|
||||
|
||||
# ── Supported Clients ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSupportedClients:
|
||||
"""Test get_supported_clients function."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_supported_clients(self):
|
||||
"""Should return exactly 5 supported client types."""
|
||||
clients = get_supported_clients()
|
||||
assert len(clients) == 5
|
||||
client_ids = [c["id"] for c in clients]
|
||||
assert "claude_desktop" in client_ids
|
||||
assert "claude_code" in client_ids
|
||||
assert "cursor" in client_ids
|
||||
assert "vscode" in client_ids
|
||||
assert "chatgpt" in client_ids
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_supported_clients_have_labels(self):
|
||||
"""Each client should have id, label, and description."""
|
||||
for client in get_supported_clients():
|
||||
assert "id" in client
|
||||
assert "label" in client
|
||||
assert "description" in client
|
||||
assert len(client["label"]) > 0
|
||||
assert len(client["description"]) > 0
|
||||
|
||||
|
||||
# ── Claude Desktop / Claude Code Format ──────────────────────
|
||||
|
||||
|
||||
class TestClaudeFormat:
|
||||
"""Test Claude Desktop and Claude Code config generation."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_claude_desktop_format(self):
|
||||
"""Claude Desktop config should be valid JSON with mcpServers."""
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_desktop")
|
||||
config = json.loads(snippet)
|
||||
assert "mcpServers" in config
|
||||
server_name = f"mcphub-{ALIAS}"
|
||||
assert server_name in config["mcpServers"]
|
||||
server = config["mcpServers"][server_name]
|
||||
assert server["url"] == EXPECTED_ENDPOINT
|
||||
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_claude_code_format(self):
|
||||
"""Claude Code config should use the same mcpServers format."""
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_code")
|
||||
config = json.loads(snippet)
|
||||
assert "mcpServers" in config
|
||||
server_name = f"mcphub-{ALIAS}"
|
||||
assert server_name in config["mcpServers"]
|
||||
|
||||
|
||||
# ── Cursor / VS Code Format ─────────────────────────────────
|
||||
|
||||
|
||||
class TestCursorVSCodeFormat:
|
||||
"""Test Cursor and VS Code config generation."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cursor_format(self):
|
||||
"""Cursor config should be valid JSON with mcp.servers."""
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "cursor")
|
||||
config = json.loads(snippet)
|
||||
assert "mcp" in config
|
||||
assert "servers" in config["mcp"]
|
||||
server_name = f"mcphub-{ALIAS}"
|
||||
assert server_name in config["mcp"]["servers"]
|
||||
server = config["mcp"]["servers"][server_name]
|
||||
assert server["url"] == EXPECTED_ENDPOINT
|
||||
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_vscode_format(self):
|
||||
"""VS Code config should use the same mcp.servers format as Cursor."""
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "vscode")
|
||||
config = json.loads(snippet)
|
||||
assert "mcp" in config
|
||||
assert "servers" in config["mcp"]
|
||||
|
||||
|
||||
# ── ChatGPT Format ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChatGPTFormat:
|
||||
"""Test ChatGPT config generation."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_chatgpt_format(self):
|
||||
"""ChatGPT config should return the raw endpoint URL only."""
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "chatgpt")
|
||||
assert snippet == EXPECTED_ENDPOINT
|
||||
# Should NOT be JSON
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(snippet)
|
||||
|
||||
|
||||
# ── Error Handling ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error conditions."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_client_type(self):
|
||||
"""Unsupported client type should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported client type"):
|
||||
generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "unknown_client")
|
||||
|
||||
|
||||
# ── URL Correctness ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestURLCorrectness:
|
||||
"""Test that generated configs contain the correct endpoint URL."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_config_contains_correct_url(self):
|
||||
"""All config formats should contain /u/{user_id}/{alias}/mcp."""
|
||||
for client_type in ("claude_desktop", "claude_code", "cursor", "vscode", "chatgpt"):
|
||||
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, client_type)
|
||||
assert (
|
||||
f"/u/{USER_ID}/{ALIAS}/mcp" in snippet
|
||||
), f"{client_type} config missing expected URL path"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_trailing_slash_stripped(self):
|
||||
"""Base URL with trailing slash should not produce double slashes."""
|
||||
snippet = generate_config(
|
||||
"https://mcp.example.com/", USER_ID, ALIAS, API_KEY, "claude_desktop"
|
||||
)
|
||||
assert "//u/" not in snippet
|
||||
assert f"/u/{USER_ID}/{ALIAS}/mcp" in snippet
|
||||
@@ -64,22 +64,18 @@ class TestPluginDisplayNames:
|
||||
|
||||
|
||||
class TestLanguageDetection:
|
||||
"""Test language detection from headers and query params."""
|
||||
"""Test language detection from query params (default English)."""
|
||||
|
||||
def test_default_english(self):
|
||||
"""Should default to English."""
|
||||
assert detect_language(None) == "en"
|
||||
|
||||
def test_detect_farsi_from_header(self):
|
||||
"""Should detect Farsi from Accept-Language header."""
|
||||
assert detect_language("fa-IR,fa;q=0.9,en;q=0.8") == "fa"
|
||||
def test_header_ignored(self):
|
||||
"""Accept-Language header should be ignored (always default to English)."""
|
||||
assert detect_language("fa-IR,fa;q=0.9,en;q=0.8") == "en"
|
||||
|
||||
def test_detect_farsi_short(self):
|
||||
"""Should detect 'fa' in Accept-Language."""
|
||||
assert detect_language("fa") == "fa"
|
||||
|
||||
def test_query_param_overrides_header(self):
|
||||
"""Query parameter should override Accept-Language header."""
|
||||
def test_query_param_farsi(self):
|
||||
"""Should detect Farsi from explicit query parameter."""
|
||||
assert detect_language("en-US", query_lang="fa") == "fa"
|
||||
|
||||
def test_query_param_english(self):
|
||||
@@ -87,8 +83,8 @@ class TestLanguageDetection:
|
||||
assert detect_language("fa-IR", query_lang="en") == "en"
|
||||
|
||||
def test_invalid_query_lang_ignored(self):
|
||||
"""Invalid query lang should be ignored, fall back to header."""
|
||||
assert detect_language("fa-IR", query_lang="de") == "fa"
|
||||
"""Invalid query lang should be ignored, default to English."""
|
||||
assert detect_language("fa-IR", query_lang="de") == "en"
|
||||
|
||||
def test_english_header(self):
|
||||
"""Should return English for English header."""
|
||||
@@ -181,6 +177,27 @@ class TestDashboardAuthValidation:
|
||||
assert user_type == "master"
|
||||
assert key_id is None
|
||||
|
||||
def test_temp_key_via_auth_manager(self, auth, monkeypatch):
|
||||
"""Should accept temp key from AuthManager when no env MASTER_API_KEY."""
|
||||
from core.auth import AuthManager
|
||||
|
||||
# Simulate AuthManager with a temp key that DashboardAuth doesn't know about
|
||||
temp_key = "temp-generated-key-12345"
|
||||
mgr = AuthManager.__new__(AuthManager)
|
||||
mgr.master_api_key = temp_key
|
||||
mgr._is_temporary_key = True
|
||||
mgr.project_keys = {}
|
||||
|
||||
monkeypatch.setattr("core.auth.get_auth_manager", lambda: mgr)
|
||||
|
||||
# DashboardAuth has master_api_key=None (no env var)
|
||||
auth_no_env = DashboardAuth(secret_key="test-secret", master_api_key=None)
|
||||
|
||||
is_valid, user_type, key_id = auth_no_env.validate_api_key(temp_key)
|
||||
assert is_valid is True
|
||||
assert user_type == "master"
|
||||
assert key_id is None
|
||||
|
||||
def test_invalid_key_rejected(self, auth):
|
||||
"""Should reject invalid API key."""
|
||||
is_valid, user_type, key_id = auth.validate_api_key("wrong-key")
|
||||
@@ -354,3 +371,40 @@ class TestDashboardCookieManagement:
|
||||
break
|
||||
assert cookie_header is not None
|
||||
assert "mcp_dashboard_session=" in cookie_header
|
||||
|
||||
|
||||
def test_dashboard_connect_page(monkeypatch):
|
||||
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
|
||||
from server import create_multi_endpoint_app
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import core.dashboard.routes
|
||||
import core.site_api
|
||||
import core.user_keys
|
||||
|
||||
app = create_multi_endpoint_app()
|
||||
client = TestClient(app)
|
||||
|
||||
def mock_req(*args):
|
||||
return {"user_id": "abc", "type": "user"}, None
|
||||
|
||||
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
|
||||
|
||||
async def mock_sites(*args):
|
||||
return [{"alias": "Test", "plugin_type": "dummy"}]
|
||||
|
||||
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
|
||||
|
||||
class MockKeyMgr:
|
||||
async def list_keys(self, *a):
|
||||
return [
|
||||
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
|
||||
|
||||
resp = client.get("/dashboard/connect")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "Test" in resp.text
|
||||
assert "Key" in resp.text
|
||||
|
||||
577
tests/test_database.py
Normal file
577
tests/test_database.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""Tests for SQLite database backend (core/database.py)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from core.database import SCHEMA_VERSION, Database, get_database, initialize_database
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(tmp_path):
|
||||
"""Provide an initialized Database using a temp directory."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
database = Database(db_path)
|
||||
await database.initialize()
|
||||
yield database
|
||||
await database.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_row(db):
|
||||
"""Create and return a sample user dict."""
|
||||
return await db.create_user(
|
||||
email="alice@example.com",
|
||||
name="Alice",
|
||||
provider="github",
|
||||
provider_id="gh-111",
|
||||
avatar_url="https://example.com/alice.png",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def second_user(db):
|
||||
"""Create and return a second sample user dict."""
|
||||
return await db.create_user(
|
||||
email="bob@example.com",
|
||||
name="Bob",
|
||||
provider="google",
|
||||
provider_id="gg-222",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def site_row(db, user_row):
|
||||
"""Create and return a sample site dict."""
|
||||
return await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="myblog",
|
||||
url="https://myblog.example.com",
|
||||
credentials=b"encrypted-blob-data",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSchemaCreation:
|
||||
"""Test that the database schema is created correctly."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_tables_exist(self, db):
|
||||
"""All expected tables should exist after initialization."""
|
||||
rows = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
table_names = {row["name"] for row in rows}
|
||||
expected = {"users", "sites", "user_api_keys", "connection_tokens", "schema_version"}
|
||||
assert expected.issubset(table_names)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_schema_version_set(self, db):
|
||||
"""schema_version table should contain version 1 after init."""
|
||||
row = await db.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||
assert row is not None
|
||||
assert row["v"] == SCHEMA_VERSION
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserCRUD:
|
||||
"""Test user create, read, and update operations."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_user(self, db):
|
||||
"""Should create a user and return a dict with all fields."""
|
||||
user = await db.create_user(
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
provider="github",
|
||||
provider_id="gh-999",
|
||||
avatar_url="https://example.com/avatar.png",
|
||||
)
|
||||
assert user["email"] == "test@example.com"
|
||||
assert user["name"] == "Test User"
|
||||
assert user["provider"] == "github"
|
||||
assert user["provider_id"] == "gh-999"
|
||||
assert user["avatar_url"] == "https://example.com/avatar.png"
|
||||
assert user["role"] == "user"
|
||||
assert user["id"] is not None
|
||||
assert user["created_at"] is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_user_admin_role(self, db):
|
||||
"""Should allow creating a user with admin role."""
|
||||
user = await db.create_user(
|
||||
email="admin@example.com",
|
||||
name="Admin",
|
||||
provider="github",
|
||||
provider_id="gh-admin",
|
||||
role="admin",
|
||||
)
|
||||
assert user["role"] == "admin"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id(self, db, user_row):
|
||||
"""Should retrieve a user by their UUID."""
|
||||
fetched = await db.get_user_by_id(user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["email"] == "alice@example.com"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id_not_found(self, db):
|
||||
"""Should return None for a non-existent user ID."""
|
||||
fetched = await db.get_user_by_id("non-existent-uuid")
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider(self, db, user_row):
|
||||
"""Should retrieve a user by provider and provider_id."""
|
||||
fetched = await db.get_user_by_provider("github", "gh-111")
|
||||
assert fetched is not None
|
||||
assert fetched["id"] == user_row["id"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider_not_found(self, db):
|
||||
"""Should return None for a non-existent provider combo."""
|
||||
fetched = await db.get_user_by_provider("github", "does-not-exist")
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_last_login(self, db, user_row):
|
||||
"""Should update the last_login timestamp."""
|
||||
original_login = user_row["last_login"]
|
||||
# Small delay so the timestamps differ
|
||||
await asyncio.sleep(0.01)
|
||||
await db.update_user_last_login(user_row["id"])
|
||||
|
||||
fetched = await db.get_user_by_id(user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["last_login"] != original_login
|
||||
assert fetched["last_login"] > original_login
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User uniqueness constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserConstraints:
|
||||
"""Test UNIQUE constraints on the users table."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_email_raises(self, db, user_row):
|
||||
"""Inserting a user with a duplicate email should raise IntegrityError."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_user(
|
||||
email="alice@example.com", # duplicate
|
||||
name="Alice Clone",
|
||||
provider="google",
|
||||
provider_id="gg-unique",
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_provider_raises(self, db, user_row):
|
||||
"""Inserting a user with duplicate provider+provider_id should raise."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_user(
|
||||
email="other@example.com",
|
||||
name="Other",
|
||||
provider="github", # same provider
|
||||
provider_id="gh-111", # same provider_id
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteCRUD:
|
||||
"""Test site create, read, update, and delete operations."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site(self, db, user_row):
|
||||
"""Should create a site and return a dict with all fields."""
|
||||
site = await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="testsite",
|
||||
url="https://test.example.com",
|
||||
credentials=b"encrypted-data",
|
||||
)
|
||||
assert site["plugin_type"] == "wordpress"
|
||||
assert site["alias"] == "testsite"
|
||||
assert site["url"] == "https://test.example.com"
|
||||
assert site["credentials"] == b"encrypted-data"
|
||||
assert site["status"] == "pending"
|
||||
assert site["user_id"] == user_row["id"]
|
||||
assert site["id"] is not None
|
||||
assert site["created_at"] is not None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user(self, db, user_row):
|
||||
"""Should return all sites for a user."""
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="site-a",
|
||||
url="https://a.example.com",
|
||||
credentials=b"cred-a",
|
||||
)
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="gitea",
|
||||
alias="site-b",
|
||||
url="https://b.example.com",
|
||||
credentials=b"cred-b",
|
||||
)
|
||||
|
||||
sites = await db.get_sites_by_user(user_row["id"])
|
||||
assert len(sites) == 2
|
||||
aliases = {s["alias"] for s in sites}
|
||||
assert aliases == {"site-a", "site-b"}
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user_empty(self, db, user_row):
|
||||
"""Should return empty list when user has no sites."""
|
||||
sites = await db.get_sites_by_user(user_row["id"])
|
||||
assert sites == []
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site(self, db, user_row, site_row):
|
||||
"""Should retrieve a site by ID with user scoping."""
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["alias"] == "myblog"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site(self, db, user_row, site_row):
|
||||
"""Should delete a site and return True."""
|
||||
deleted = await db.delete_site(site_row["id"], user_row["id"])
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site_not_found(self, db, user_row):
|
||||
"""Should return False when deleting a non-existent site."""
|
||||
deleted = await db.delete_site("no-such-id", user_row["id"])
|
||||
assert deleted is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteIsolation:
|
||||
"""Test that site access is properly scoped to the owning user."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site_wrong_user_returns_none(self, db, user_row, second_user, site_row):
|
||||
"""get_site with wrong user_id should return None."""
|
||||
fetched = await db.get_site(site_row["id"], second_user["id"])
|
||||
assert fetched is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_site_wrong_user_returns_false(self, db, user_row, second_user, site_row):
|
||||
"""delete_site with wrong user_id should return False and not delete."""
|
||||
deleted = await db.delete_site(site_row["id"], second_user["id"])
|
||||
assert deleted is False
|
||||
|
||||
# Verify it's still there for the real owner
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Site alias constraints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSiteAliasConstraints:
|
||||
"""Test UNIQUE(user_id, alias) constraint on the sites table."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_duplicate_alias_same_user_raises(self, db, user_row, site_row):
|
||||
"""Duplicate alias for the same user should raise IntegrityError."""
|
||||
with pytest.raises(aiosqlite.IntegrityError):
|
||||
await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="gitea",
|
||||
alias="myblog", # duplicate alias for same user
|
||||
url="https://other.example.com",
|
||||
credentials=b"cred",
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_same_alias_different_users_succeeds(self, db, user_row, second_user):
|
||||
"""Same alias for different users should succeed."""
|
||||
site1 = await db.create_site(
|
||||
user_id=user_row["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="shared-alias",
|
||||
url="https://a.example.com",
|
||||
credentials=b"cred-a",
|
||||
)
|
||||
site2 = await db.create_site(
|
||||
user_id=second_user["id"],
|
||||
plugin_type="wordpress",
|
||||
alias="shared-alias",
|
||||
url="https://b.example.com",
|
||||
credentials=b"cred-b",
|
||||
)
|
||||
assert site1["id"] != site2["id"]
|
||||
assert site1["alias"] == site2["alias"] == "shared-alias"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCascadeDelete:
|
||||
"""Test that deleting a user cascades to their sites."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_user_cascades_sites(self, db, user_row, site_row):
|
||||
"""Deleting a user should also delete their sites."""
|
||||
# Verify site exists
|
||||
site = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert site is not None
|
||||
|
||||
# Delete the user directly
|
||||
await db.execute("DELETE FROM users WHERE id = ?", (user_row["id"],))
|
||||
|
||||
# Site should be gone
|
||||
row = await db.fetchone("SELECT * FROM sites WHERE id = ?", (site_row["id"],))
|
||||
assert row is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_site_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateSiteStatus:
|
||||
"""Test site status updates."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status(self, db, user_row, site_row):
|
||||
"""Should update status and status_msg."""
|
||||
await db.update_site_status(site_row["id"], "active", "Connected successfully")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "active"
|
||||
assert fetched["status_msg"] == "Connected successfully"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_to_error(self, db, user_row, site_row):
|
||||
"""Should allow setting error status with a message."""
|
||||
await db.update_site_status(site_row["id"], "error", "Connection refused")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "error"
|
||||
assert fetched["status_msg"] == "Connection refused"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_clears_message(self, db, user_row, site_row):
|
||||
"""Should clear status_msg when set to None."""
|
||||
await db.update_site_status(site_row["id"], "active", "All good")
|
||||
await db.update_site_status(site_row["id"], "disabled")
|
||||
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "disabled"
|
||||
assert fetched["status_msg"] is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_with_user_id(self, db, user_row, site_row):
|
||||
"""Should update when user_id matches the site owner."""
|
||||
await db.update_site_status(site_row["id"], "active", "OK", user_id=user_row["id"])
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "active"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_update_site_status_wrong_user_id_no_effect(self, db, user_row, site_row):
|
||||
"""Should not update when user_id doesn't match."""
|
||||
await db.update_site_status(site_row["id"], "active", "OK", user_id="wrong-user-id")
|
||||
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["status"] == "pending" # unchanged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
"""Test async context manager support."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_context_manager(self, tmp_path):
|
||||
"""async with Database(...) as db: should work."""
|
||||
db_path = str(tmp_path / "ctx_test.db")
|
||||
async with Database(db_path) as db:
|
||||
user = await db.create_user(
|
||||
email="ctx@example.com",
|
||||
name="Context",
|
||||
provider="github",
|
||||
provider_id="gh-ctx",
|
||||
)
|
||||
assert user["email"] == "ctx@example.com"
|
||||
|
||||
# After exit, connection should be closed
|
||||
assert db._conn is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRAGMA checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPragmas:
|
||||
"""Test that WAL mode and foreign keys are enabled."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_wal_mode_enabled(self, db):
|
||||
"""PRAGMA journal_mode should be WAL."""
|
||||
row = await db.fetchone("PRAGMA journal_mode")
|
||||
assert row is not None
|
||||
assert row["journal_mode"].lower() == "wal"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_foreign_keys_enabled(self, db):
|
||||
"""PRAGMA foreign_keys should be ON (1)."""
|
||||
row = await db.fetchone("PRAGMA foreign_keys")
|
||||
assert row is not None
|
||||
assert row["foreign_keys"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concurrent access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConcurrentAccess:
|
||||
"""Test that two Database instances on the same file don't deadlock."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_concurrent_instances(self, tmp_path):
|
||||
"""Two Database instances on the same file should not deadlock."""
|
||||
db_path = str(tmp_path / "concurrent.db")
|
||||
|
||||
async with Database(db_path) as db1, Database(db_path) as db2:
|
||||
user1 = await db1.create_user(
|
||||
email="user1@example.com",
|
||||
name="User 1",
|
||||
provider="github",
|
||||
provider_id="gh-1",
|
||||
)
|
||||
user2 = await db2.create_user(
|
||||
email="user2@example.com",
|
||||
name="User 2",
|
||||
provider="google",
|
||||
provider_id="gg-2",
|
||||
)
|
||||
|
||||
# Both users should be visible from either connection
|
||||
fetched1 = await db2.get_user_by_id(user1["id"])
|
||||
fetched2 = await db1.get_user_by_id(user2["id"])
|
||||
assert fetched1 is not None
|
||||
assert fetched2 is not None
|
||||
assert fetched1["email"] == "user1@example.com"
|
||||
assert fetched2["email"] == "user2@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyResults:
|
||||
"""Test behavior with empty or non-existent data."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_id_nonexistent(self, db):
|
||||
"""get_user_by_id with a fake UUID should return None."""
|
||||
result = await db.get_user_by_id("00000000-0000-0000-0000-000000000000")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_by_provider_nonexistent(self, db):
|
||||
"""get_user_by_provider with a fake combo should return None."""
|
||||
result = await db.get_user_by_provider("unknown_provider", "fake_id")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_site_nonexistent(self, db):
|
||||
"""get_site with fake IDs should return None."""
|
||||
result = await db.get_site("fake-site-id", "fake-user-id")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_sites_by_user_nonexistent(self, db):
|
||||
"""get_sites_by_user with a fake user ID should return empty list."""
|
||||
result = await db.get_sites_by_user("fake-user-id")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModuleHelpers:
|
||||
"""Test get_database() and initialize_database() helpers."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_database_before_init_raises(self, monkeypatch):
|
||||
"""get_database() should raise RuntimeError if not initialized."""
|
||||
# Reset the module-level singleton
|
||||
import core.database as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Database not initialized"):
|
||||
get_database()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_initialize_and_get_database(self, tmp_path, monkeypatch):
|
||||
"""initialize_database() should set the singleton, get_database() returns it."""
|
||||
import core.database as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
|
||||
db_path = str(tmp_path / "singleton_test.db")
|
||||
db = await initialize_database(db_path)
|
||||
try:
|
||||
assert db is get_database()
|
||||
|
||||
# Should be functional
|
||||
user = await db.create_user(
|
||||
email="singleton@example.com",
|
||||
name="Singleton",
|
||||
provider="github",
|
||||
provider_id="gh-singleton",
|
||||
)
|
||||
assert user["email"] == "singleton@example.com"
|
||||
finally:
|
||||
await db.close()
|
||||
monkeypatch.setattr(db_module, "_database", None)
|
||||
115
tests/test_dynamic_endpoints_integration.py
Normal file
115
tests/test_dynamic_endpoints_integration.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Integration tests for the per-user dynamic MCP endpoints.
|
||||
Uses Starlette TestClient to simulate real HTTP requests through the server.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from server import create_multi_endpoint_app
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
app = create_multi_endpoint_app()
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_managers():
|
||||
"""Mock the core managers to avoid needing a real database or keys."""
|
||||
|
||||
# Mock Site Manager
|
||||
mock_site_manager = MagicMock()
|
||||
mock_site_manager.list_all_sites.return_value = []
|
||||
|
||||
# Mock Key Manager
|
||||
mock_key_manager = AsyncMock()
|
||||
mock_key_manager.validate_key.return_value = {
|
||||
"key_id": "test-key-123",
|
||||
"user_id": "user-123",
|
||||
"scopes": "read write",
|
||||
}
|
||||
|
||||
# Mock Database
|
||||
mock_db = AsyncMock()
|
||||
mock_db.get_site_by_alias.return_value = {
|
||||
"id": "site-123",
|
||||
"user_id": "user-123",
|
||||
"plugin_type": "wordpress",
|
||||
"alias": "myblog",
|
||||
"url": "https://example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("server.get_site_manager", return_value=mock_site_manager),
|
||||
patch("core.user_keys.get_user_key_manager", return_value=mock_key_manager),
|
||||
patch("core.database.get_database", return_value=mock_db),
|
||||
):
|
||||
yield {"key_manager": mock_key_manager, "db": mock_db}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dynamic_endpoint_unauthorized():
|
||||
"""Test that requests without API key are rejected."""
|
||||
response = client.post(
|
||||
"/u/user-123/myblog/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dynamic_endpoint_invalid_method(mock_managers):
|
||||
"""Test that unauthorized methods or valid requests with bad structure return JSON-RPC errors."""
|
||||
response = client.post(
|
||||
"/u/user-123/myblog/mcp",
|
||||
headers={"Authorization": "Bearer mhu_test-key"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "invalid_method"},
|
||||
)
|
||||
assert response.status_code == 200 # JSON-RPC errors are 200 OK
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert "not supported" in data["error"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dynamic_endpoint_initialize(mock_managers):
|
||||
"""Test standard MCP initialize on the dynamic endpoint."""
|
||||
response = client.post(
|
||||
"/u/user-123/myblog/mcp",
|
||||
headers={"Authorization": "Bearer mhu_test-key"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-client", "version": "1.0.0"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert "capabilities" in data["result"]
|
||||
assert "serverInfo" in data["result"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dynamic_endpoint_mismatched_user(mock_managers):
|
||||
"""Test that if the key belongs to a different user, it's rejected."""
|
||||
mock_managers["key_manager"].validate_key.return_value = {
|
||||
"key_id": "test-key-123",
|
||||
"user_id": "different-user-456",
|
||||
"scopes": "read write",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/u/user-123/myblog/mcp",
|
||||
headers={"Authorization": "Bearer mhu_test-key"},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert "does not match" in data["error"]["message"]
|
||||
315
tests/test_encryption.py
Normal file
315
tests/test_encryption.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Tests for Credential Encryption (core/encryption.py)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
from core.encryption import (
|
||||
CredentialEncryption,
|
||||
get_credential_encryption,
|
||||
initialize_credential_encryption,
|
||||
)
|
||||
|
||||
# A valid base64-encoded 32-byte key for testing
|
||||
TEST_KEY = base64.b64encode(os.urandom(32)).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption():
|
||||
"""Create a CredentialEncryption instance with a test key."""
|
||||
return CredentialEncryption(encryption_key=TEST_KEY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _clear_singleton():
|
||||
"""Reset the global singleton before and after each test that uses it."""
|
||||
import core.encryption as mod
|
||||
|
||||
original = mod._credential_encryption
|
||||
mod._credential_encryption = None
|
||||
yield
|
||||
mod._credential_encryption = original
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEncryptDecrypt:
|
||||
"""Test basic encrypt/decrypt round-trips."""
|
||||
|
||||
def test_round_trip(self, encryption):
|
||||
"""Encrypt then decrypt should return the original plaintext."""
|
||||
plaintext = "Hello, World!"
|
||||
site_id = "site_001"
|
||||
|
||||
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||
result = encryption.decrypt(cipherdata, site_id)
|
||||
|
||||
assert result == plaintext
|
||||
|
||||
def test_credentials_round_trip(self, encryption):
|
||||
"""encrypt_credentials then decrypt_credentials should return the same dict."""
|
||||
credentials = {
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
"api_key": "sk-1234567890",
|
||||
}
|
||||
site_id = "site_002"
|
||||
|
||||
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||
|
||||
assert result == credentials
|
||||
|
||||
def test_empty_credentials(self, encryption):
|
||||
"""Empty dict should encrypt and decrypt correctly."""
|
||||
credentials = {}
|
||||
site_id = "site_empty"
|
||||
|
||||
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_unicode_credentials(self, encryption):
|
||||
"""Non-ASCII characters in credentials should survive round-trip."""
|
||||
credentials = {
|
||||
"username": "\u06a9\u0627\u0631\u0628\u0631",
|
||||
"password": "\u0631\u0645\u0632\u0639\u0628\u0648\u0631-\u0627\u06cc\u0645\u0646",
|
||||
"display_name": "\u5f20\u4e09\u7684\u535a\u5ba2",
|
||||
"notes": "Emoji \u2764\ufe0f test \U0001f680",
|
||||
}
|
||||
site_id = "site_unicode"
|
||||
|
||||
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||
|
||||
assert result == credentials
|
||||
|
||||
def test_large_payload(self, encryption):
|
||||
"""A large JSON payload (~10KB) should encrypt and decrypt correctly."""
|
||||
credentials = {f"key_{i}": f"value_{i}_{'x' * 100}" for i in range(85)}
|
||||
json_size = len(json.dumps(credentials))
|
||||
assert json_size > 10000, f"Payload should be >10KB, got {json_size}"
|
||||
|
||||
site_id = "site_large"
|
||||
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||
|
||||
assert result == credentials
|
||||
|
||||
def test_empty_string_encrypt_decrypt(self, encryption):
|
||||
"""Empty string should encrypt and decrypt correctly."""
|
||||
cipherdata = encryption.encrypt("", "site_empty_str")
|
||||
result = encryption.decrypt(cipherdata, "site_empty_str")
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSiteIsolation:
|
||||
"""Test that different site_ids produce different ciphertext and keys."""
|
||||
|
||||
def test_different_site_ids_produce_different_ciphertext(self, encryption):
|
||||
"""Same plaintext encrypted with different site_ids should differ."""
|
||||
plaintext = "same-secret-value"
|
||||
cipher_a = encryption.encrypt(plaintext, "site_alpha")
|
||||
cipher_b = encryption.encrypt(plaintext, "site_beta")
|
||||
|
||||
# Ciphertext should differ (different derived keys + different nonces)
|
||||
assert cipher_a != cipher_b
|
||||
|
||||
def test_wrong_site_id_fails_to_decrypt(self, encryption):
|
||||
"""Decrypting with the wrong site_id should raise InvalidTag."""
|
||||
plaintext = "secret-data"
|
||||
cipherdata = encryption.encrypt(plaintext, "correct_site")
|
||||
|
||||
with pytest.raises(InvalidTag):
|
||||
encryption.decrypt(cipherdata, "wrong_site")
|
||||
|
||||
def test_wrong_site_id_fails_credentials(self, encryption):
|
||||
"""decrypt_credentials with wrong site_id should raise InvalidTag."""
|
||||
credentials = {"username": "admin", "password": "secret"}
|
||||
cipherdata = encryption.encrypt_credentials(credentials, "correct_site")
|
||||
|
||||
with pytest.raises(InvalidTag):
|
||||
encryption.decrypt_credentials(cipherdata, "wrong_site")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTampering:
|
||||
"""Test that tampered ciphertext is detected."""
|
||||
|
||||
def test_tampered_ciphertext_fails(self, encryption):
|
||||
"""Modifying a byte in the ciphertext should cause decryption to fail."""
|
||||
plaintext = "sensitive-data"
|
||||
site_id = "site_tamper"
|
||||
|
||||
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||
|
||||
# Tamper with a byte in the ciphertext portion (after version + nonce)
|
||||
tampered = bytearray(cipherdata)
|
||||
tampered[16] ^= 0xFF # Flip bits in a ciphertext byte
|
||||
tampered = bytes(tampered)
|
||||
|
||||
with pytest.raises(InvalidTag):
|
||||
encryption.decrypt(tampered, site_id)
|
||||
|
||||
def test_tampered_nonce_fails(self, encryption):
|
||||
"""Modifying the nonce should cause decryption to fail."""
|
||||
plaintext = "sensitive-data"
|
||||
site_id = "site_nonce_tamper"
|
||||
|
||||
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||
|
||||
tampered = bytearray(cipherdata)
|
||||
tampered[1] ^= 0xFF # Flip bits in the nonce (byte 1, after version byte)
|
||||
tampered = bytes(tampered)
|
||||
|
||||
with pytest.raises(InvalidTag):
|
||||
encryption.decrypt(tampered, site_id)
|
||||
|
||||
def test_truncated_cipherdata_fails(self, encryption):
|
||||
"""Truncated cipherdata should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
encryption.decrypt(b"short", "site_trunc")
|
||||
|
||||
def test_unsupported_version_fails(self, encryption):
|
||||
"""Cipherdata with wrong version byte should raise ValueError."""
|
||||
cipherdata = encryption.encrypt("test", "site_ver")
|
||||
# Replace version byte with 0x99
|
||||
bad_version = b"\x99" + cipherdata[1:]
|
||||
with pytest.raises(ValueError, match="Unsupported encryption format version"):
|
||||
encryption.decrypt(bad_version, "site_ver")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestKeyValidation:
|
||||
"""Test encryption key validation."""
|
||||
|
||||
def test_missing_key_raises_valueerror(self, monkeypatch):
|
||||
"""Missing ENCRYPTION_KEY should raise ValueError."""
|
||||
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
||||
CredentialEncryption()
|
||||
|
||||
def test_invalid_base64_raises_valueerror(self):
|
||||
"""Non-base64 key should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="valid base64"):
|
||||
CredentialEncryption(encryption_key="not-valid-base64!!!")
|
||||
|
||||
def test_wrong_length_key_raises_valueerror(self):
|
||||
"""Key that decodes to wrong number of bytes should raise ValueError."""
|
||||
short_key = base64.b64encode(b"too-short").decode()
|
||||
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
||||
CredentialEncryption(encryption_key=short_key)
|
||||
|
||||
def test_16_byte_key_raises_valueerror(self):
|
||||
"""A 16-byte key (AES-128) should be rejected; we require 32 bytes."""
|
||||
key_16 = base64.b64encode(os.urandom(16)).decode()
|
||||
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
||||
CredentialEncryption(encryption_key=key_16)
|
||||
|
||||
def test_valid_key_from_env(self, monkeypatch):
|
||||
"""ENCRYPTION_KEY from env should be accepted when valid."""
|
||||
key = base64.b64encode(os.urandom(32)).decode()
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||
|
||||
enc = CredentialEncryption()
|
||||
# Should work without error
|
||||
cipherdata = enc.encrypt("test", "site")
|
||||
assert enc.decrypt(cipherdata, "site") == "test"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestKeyDerivation:
|
||||
"""Test HKDF key derivation properties."""
|
||||
|
||||
def test_deterministic(self, encryption):
|
||||
"""Same site_id should always produce the same derived key."""
|
||||
key_a = encryption._derive_key("site_deterministic")
|
||||
key_b = encryption._derive_key("site_deterministic")
|
||||
assert key_a == key_b
|
||||
|
||||
def test_different_sites_different_keys(self, encryption):
|
||||
"""Different site_ids should produce different derived keys."""
|
||||
key_a = encryption._derive_key("site_one")
|
||||
key_b = encryption._derive_key("site_two")
|
||||
assert key_a != key_b
|
||||
|
||||
def test_derived_key_length(self, encryption):
|
||||
"""Derived key should be 32 bytes."""
|
||||
key = encryption._derive_key("any_site")
|
||||
assert len(key) == 32
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNonceUniqueness:
|
||||
"""Test that random nonces ensure ciphertext uniqueness."""
|
||||
|
||||
def test_same_plaintext_different_ciphertext(self, encryption):
|
||||
"""Two encryptions of the same plaintext should produce different ciphertext."""
|
||||
plaintext = "identical-value"
|
||||
site_id = "site_nonce"
|
||||
|
||||
cipher_a = encryption.encrypt(plaintext, site_id)
|
||||
cipher_b = encryption.encrypt(plaintext, site_id)
|
||||
|
||||
# Both should decrypt to the same value
|
||||
assert encryption.decrypt(cipher_a, site_id) == plaintext
|
||||
assert encryption.decrypt(cipher_b, site_id) == plaintext
|
||||
|
||||
# But the ciphertext should differ (different random nonces)
|
||||
assert cipher_a != cipher_b
|
||||
|
||||
def test_nonce_is_12_bytes(self, encryption):
|
||||
"""The nonce prefix should be exactly 12 bytes (after version byte)."""
|
||||
cipherdata = encryption.encrypt("test", "site_nonce_len")
|
||||
# Minimum size: 1 (version) + 12 (nonce) + 0 (empty plaintext encrypted) + 16 (tag)
|
||||
assert len(cipherdata) >= 29
|
||||
# Version byte is 0x01
|
||||
assert cipherdata[0:1] == b"\x01"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSingleton:
|
||||
"""Test the module-level singleton getter."""
|
||||
|
||||
@pytest.mark.usefixtures("_clear_singleton")
|
||||
def test_get_credential_encryption_returns_instance(self, monkeypatch):
|
||||
"""get_credential_encryption should return a CredentialEncryption instance."""
|
||||
key = base64.b64encode(os.urandom(32)).decode()
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||
|
||||
enc = get_credential_encryption()
|
||||
assert isinstance(enc, CredentialEncryption)
|
||||
|
||||
@pytest.mark.usefixtures("_clear_singleton")
|
||||
def test_singleton_returns_same_instance(self, monkeypatch):
|
||||
"""Calling get_credential_encryption twice should return the same object."""
|
||||
key = base64.b64encode(os.urandom(32)).decode()
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||
|
||||
enc_a = get_credential_encryption()
|
||||
enc_b = get_credential_encryption()
|
||||
assert enc_a is enc_b
|
||||
|
||||
@pytest.mark.usefixtures("_clear_singleton")
|
||||
def test_singleton_raises_without_key(self, monkeypatch):
|
||||
"""get_credential_encryption should raise ValueError if ENCRYPTION_KEY is missing."""
|
||||
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
||||
get_credential_encryption()
|
||||
|
||||
@pytest.mark.usefixtures("_clear_singleton")
|
||||
def test_initialize_with_explicit_key(self, monkeypatch):
|
||||
"""initialize_credential_encryption should accept an explicit key."""
|
||||
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||
key = base64.b64encode(os.urandom(32)).decode()
|
||||
|
||||
enc = initialize_credential_encryption(key)
|
||||
assert isinstance(enc, CredentialEncryption)
|
||||
# Should be the same as get_credential_encryption now
|
||||
assert get_credential_encryption() is enc
|
||||
353
tests/test_site_api.py
Normal file
353
tests/test_site_api.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Tests for Site Management API (core/site_api.py)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.site_api import (
|
||||
MAX_SITES_PER_USER,
|
||||
create_user_site,
|
||||
delete_user_site,
|
||||
get_credential_fields,
|
||||
get_user_sites,
|
||||
validate_credentials,
|
||||
validate_site_connection,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create and patch a mock database instance."""
|
||||
db = AsyncMock()
|
||||
db.count_sites_by_user = AsyncMock(return_value=0)
|
||||
db.get_site_by_alias = AsyncMock(return_value=None)
|
||||
db.get_site = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"status_msg": "Connection verified",
|
||||
"created_at": "2026-02-19T12:00:00Z",
|
||||
}
|
||||
)
|
||||
db.get_sites_by_user = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"created_at": "2026-02-19T12:00:00Z",
|
||||
},
|
||||
]
|
||||
)
|
||||
db.delete_site = AsyncMock(return_value=True)
|
||||
db.execute = AsyncMock()
|
||||
db.update_site_status = AsyncMock()
|
||||
with patch("core.database.get_database", return_value=db):
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_encryption():
|
||||
"""Create and patch a mock encryption instance."""
|
||||
enc = MagicMock()
|
||||
enc.encrypt_credentials = MagicMock(return_value=b"encrypted-blob")
|
||||
enc.decrypt_credentials = MagicMock(
|
||||
return_value={
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
}
|
||||
)
|
||||
with patch("core.encryption.get_credential_encryption", return_value=enc):
|
||||
yield enc
|
||||
|
||||
|
||||
# ── Credential Field Definitions ─────────────────────────────
|
||||
|
||||
|
||||
class TestCredentialFields:
|
||||
"""Test credential field definitions and retrieval."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_credential_fields_all_plugins(self):
|
||||
"""All 9 plugin types should return non-empty credential field lists."""
|
||||
expected_plugins = [
|
||||
"wordpress",
|
||||
"woocommerce",
|
||||
"wordpress_advanced",
|
||||
"gitea",
|
||||
"n8n",
|
||||
"supabase",
|
||||
"openpanel",
|
||||
"appwrite",
|
||||
"directus",
|
||||
]
|
||||
for plugin_type in expected_plugins:
|
||||
fields = get_credential_fields(plugin_type)
|
||||
assert len(fields) > 0, f"{plugin_type} returned empty fields"
|
||||
for field in fields:
|
||||
assert "name" in field
|
||||
assert "label" in field
|
||||
assert "type" in field
|
||||
assert "required" in field
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_credential_fields_invalid(self):
|
||||
"""Unknown plugin type should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unknown plugin type"):
|
||||
get_credential_fields("nonexistent_plugin")
|
||||
|
||||
|
||||
# ── Credential Validation ────────────────────────────────────
|
||||
|
||||
|
||||
class TestCredentialValidation:
|
||||
"""Test credential validation logic."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_credentials_valid(self):
|
||||
"""Valid WordPress credentials should pass validation."""
|
||||
valid, errors = validate_credentials(
|
||||
"wordpress",
|
||||
{
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
},
|
||||
)
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_credentials_missing_required(self):
|
||||
"""Missing required fields should return errors."""
|
||||
valid, errors = validate_credentials(
|
||||
"wordpress",
|
||||
{
|
||||
"username": "admin",
|
||||
# app_password is missing
|
||||
},
|
||||
)
|
||||
assert valid is False
|
||||
assert len(errors) == 1
|
||||
assert "Application Password" in errors[0]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_credentials_optional_field(self):
|
||||
"""Optional fields (like container) should not cause errors when missing."""
|
||||
valid, errors = validate_credentials(
|
||||
"wordpress_advanced",
|
||||
{
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
# container is optional — should not cause error
|
||||
},
|
||||
)
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_validate_credentials_empty_string_required(self):
|
||||
"""Empty string for required field should fail validation."""
|
||||
valid, errors = validate_credentials(
|
||||
"wordpress",
|
||||
{
|
||||
"username": "admin",
|
||||
"app_password": " ", # whitespace only
|
||||
},
|
||||
)
|
||||
assert valid is False
|
||||
assert len(errors) == 1
|
||||
|
||||
|
||||
# ── Site Creation ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSiteCreation:
|
||||
"""Test site creation flow."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site_success(self, mock_db, mock_encryption):
|
||||
"""Creating a site with skip_validation should succeed."""
|
||||
result = await create_user_site(
|
||||
user_id="user-uuid-001",
|
||||
plugin_type="wordpress",
|
||||
alias="myblog",
|
||||
url="https://myblog.example.com",
|
||||
credentials={"username": "admin", "app_password": "xxxx xxxx xxxx xxxx"},
|
||||
skip_validation=True,
|
||||
)
|
||||
assert result["alias"] == "myblog"
|
||||
assert result["plugin_type"] == "wordpress"
|
||||
# Credentials blob should be stripped from the response
|
||||
assert "credentials" not in result
|
||||
# DB execute should have been called to insert
|
||||
mock_db.execute.assert_called_once()
|
||||
# Encryption should have been used
|
||||
mock_encryption.encrypt_credentials.assert_called_once()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site_alias_too_short(self, mock_db, mock_encryption):
|
||||
"""Alias shorter than 2 characters should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="2-50 characters"):
|
||||
await create_user_site(
|
||||
user_id="user-uuid-001",
|
||||
plugin_type="wordpress",
|
||||
alias="a",
|
||||
url="https://example.com",
|
||||
credentials={"username": "admin", "app_password": "xxxx"},
|
||||
skip_validation=True,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site_alias_invalid_chars(self, mock_db, mock_encryption):
|
||||
"""Alias with invalid characters should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="letters, numbers, hyphens, and underscores"):
|
||||
await create_user_site(
|
||||
user_id="user-uuid-001",
|
||||
plugin_type="wordpress",
|
||||
alias="my blog!",
|
||||
url="https://example.com",
|
||||
credentials={"username": "admin", "app_password": "xxxx"},
|
||||
skip_validation=True,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site_max_limit(self, mock_db, mock_encryption):
|
||||
"""Exceeding MAX_SITES_PER_USER should raise ValueError."""
|
||||
mock_db.count_sites_by_user.return_value = MAX_SITES_PER_USER
|
||||
with pytest.raises(ValueError, match="Site limit reached"):
|
||||
await create_user_site(
|
||||
user_id="user-uuid-001",
|
||||
plugin_type="wordpress",
|
||||
alias="toomany",
|
||||
url="https://example.com",
|
||||
credentials={"username": "admin", "app_password": "xxxx"},
|
||||
skip_validation=True,
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_site_duplicate_alias(self, mock_db, mock_encryption):
|
||||
"""Duplicate alias for the same user should raise ValueError."""
|
||||
mock_db.get_site_by_alias.return_value = {"id": "existing-site", "alias": "myblog"}
|
||||
with pytest.raises(ValueError, match="already in use"):
|
||||
await create_user_site(
|
||||
user_id="user-uuid-001",
|
||||
plugin_type="wordpress",
|
||||
alias="myblog",
|
||||
url="https://example.com",
|
||||
credentials={"username": "admin", "app_password": "xxxx"},
|
||||
skip_validation=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Site Retrieval and Deletion ──────────────────────────────
|
||||
|
||||
|
||||
class TestSiteRetrievalDeletion:
|
||||
"""Test site list and delete operations."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_user_sites(self, mock_db):
|
||||
"""get_user_sites should return sites without credential blobs."""
|
||||
sites = await get_user_sites("user-uuid-001")
|
||||
assert len(sites) == 1
|
||||
assert sites[0]["alias"] == "myblog"
|
||||
# Credentials should be stripped
|
||||
assert "credentials" not in sites[0]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_user_site(self, mock_db):
|
||||
"""delete_user_site should return True when site exists."""
|
||||
deleted = await delete_user_site("site-uuid-001", "user-uuid-001")
|
||||
assert deleted is True
|
||||
mock_db.delete_site.assert_called_once_with("site-uuid-001", "user-uuid-001")
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_user_site_not_found(self, mock_db):
|
||||
"""delete_user_site should return False when site doesn't exist."""
|
||||
mock_db.delete_site.return_value = False
|
||||
deleted = await delete_user_site("nonexistent", "user-uuid-001")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
# ── Connection Validation ────────────────────────────────────
|
||||
|
||||
|
||||
class TestConnectionValidation:
|
||||
"""Test live connection validation with mocked HTTP."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_connection_success(self):
|
||||
"""Successful HTTP response should return (True, 'OK')."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.text = AsyncMock(return_value="OK")
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(return_value=mock_response)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||
ok, msg = await validate_site_connection(
|
||||
"wordpress",
|
||||
"https://myblog.example.com",
|
||||
{"username": "admin", "app_password": "xxxx"},
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert msg == "OK"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_connection_timeout(self):
|
||||
"""Timeout should return (False, message about timeout)."""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(side_effect=TimeoutError("timed out"))
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||
ok, msg = await validate_site_connection(
|
||||
"wordpress",
|
||||
"https://slow-site.example.com",
|
||||
{"username": "admin", "app_password": "xxxx"},
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert "timed out" in msg.lower()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_connection_auth_failure(self):
|
||||
"""HTTP 401 should return (False, message about authentication)."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 401
|
||||
mock_response.text = AsyncMock(return_value="Unauthorized")
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(return_value=mock_response)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||
ok, msg = await validate_site_connection(
|
||||
"wordpress",
|
||||
"https://myblog.example.com",
|
||||
{"username": "admin", "app_password": "wrong"},
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert "authentication" in msg.lower()
|
||||
40
tests/test_tenant_isolation.py
Normal file
40
tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for tenant isolation in Dashboard routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.dashboard.routes import get_all_projects
|
||||
from core.site_manager import SiteConfig, SiteManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_projects_tenant_isolation(monkeypatch):
|
||||
"""Normal user should only see their own sites, ignoring global ones."""
|
||||
|
||||
# Mock SiteManager with some global and user sites
|
||||
mgr = SiteManager()
|
||||
|
||||
# Global site (no user_id)
|
||||
mgr.register_site(SiteConfig(site_id="global1", plugin_type="wordpress"))
|
||||
|
||||
# User 123's site
|
||||
mgr.register_site(SiteConfig(site_id="user1site", plugin_type="wordpress", user_id="user-123"))
|
||||
|
||||
monkeypatch.setattr("core.site_manager.get_site_manager", lambda: mgr)
|
||||
|
||||
# Normal user 456 (has no sites)
|
||||
session_456 = {"user_id": "user-456", "type": "user"}
|
||||
res = await get_all_projects(user_session=session_456)
|
||||
assert len(res["projects"]) == 0
|
||||
|
||||
# Normal user 123 (has 1 site)
|
||||
session_123 = {"user_id": "user-123", "type": "user"}
|
||||
res = await get_all_projects(user_session=session_123)
|
||||
assert len(res["projects"]) == 1
|
||||
assert res["projects"][0]["site_id"] == "user1site"
|
||||
|
||||
# Master user (sees all)
|
||||
class DummyMaster:
|
||||
user_type = "master"
|
||||
|
||||
res = await get_all_projects(user_session=DummyMaster())
|
||||
assert len(res["projects"]) == 2
|
||||
444
tests/test_user_auth.py
Normal file
444
tests/test_user_auth.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""Tests for the user authentication system (OAuth Social Login)."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.user_auth import (
|
||||
OAuthProvider,
|
||||
UserAuth,
|
||||
get_user_auth,
|
||||
initialize_user_auth,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton():
|
||||
"""Reset the global UserAuth singleton between tests."""
|
||||
import core.user_auth as mod
|
||||
|
||||
mod._user_auth = None
|
||||
yield
|
||||
mod._user_auth = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_auth():
|
||||
"""Create a UserAuth instance with test credentials."""
|
||||
return UserAuth(
|
||||
github_client_id="gh_test_id",
|
||||
github_client_secret="gh_test_secret",
|
||||
google_client_id="google_test_id",
|
||||
google_client_secret="google_test_secret",
|
||||
public_url="https://mcp.example.com",
|
||||
)
|
||||
|
||||
|
||||
# ── OAuth URL Generation ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestOAuthURLGeneration:
|
||||
"""Test OAuth authorization URL generation."""
|
||||
|
||||
def test_github_auth_url(self, user_auth):
|
||||
"""GitHub auth URL should point to correct endpoint."""
|
||||
url, state = user_auth.get_authorization_url("github")
|
||||
assert "github.com/login/oauth/authorize" in url
|
||||
assert "client_id=gh_test_id" in url
|
||||
assert "state=" in url
|
||||
assert len(state) == 64 # 32 bytes hex
|
||||
|
||||
def test_google_auth_url(self, user_auth):
|
||||
"""Google auth URL should point to correct endpoint."""
|
||||
url, state = user_auth.get_authorization_url("google")
|
||||
assert "accounts.google.com/o/oauth2/v2/auth" in url
|
||||
assert "client_id=google_test_id" in url
|
||||
assert "state=" in url
|
||||
|
||||
def test_github_callback_url(self, user_auth):
|
||||
"""GitHub auth URL should include correct callback URL."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
url, _ = user_auth.get_authorization_url("github")
|
||||
decoded = unquote(url)
|
||||
assert "redirect_uri=" in url
|
||||
assert "mcp.example.com" in decoded
|
||||
assert "/auth/callback/github" in decoded
|
||||
|
||||
def test_google_callback_url(self, user_auth):
|
||||
"""Google auth URL should include correct callback URL."""
|
||||
from urllib.parse import unquote
|
||||
|
||||
url, _ = user_auth.get_authorization_url("google")
|
||||
decoded = unquote(url)
|
||||
assert "redirect_uri=" in url
|
||||
assert "/auth/callback/google" in decoded
|
||||
|
||||
def test_google_scopes(self, user_auth):
|
||||
"""Google auth URL should request openid, email, and profile scopes."""
|
||||
url, _ = user_auth.get_authorization_url("google")
|
||||
assert "openid" in url
|
||||
assert "email" in url
|
||||
assert "profile" in url
|
||||
|
||||
def test_invalid_provider(self, user_auth):
|
||||
"""Unsupported provider should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported provider"):
|
||||
user_auth.get_authorization_url("twitter")
|
||||
|
||||
|
||||
# ── State Validation ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStateValidation:
|
||||
"""Test OAuth state parameter validation (CSRF protection)."""
|
||||
|
||||
def test_valid_state(self, user_auth):
|
||||
"""A freshly generated state should validate successfully."""
|
||||
_, state = user_auth.get_authorization_url("github")
|
||||
assert user_auth.validate_state(state) is True
|
||||
|
||||
def test_invalid_state(self, user_auth):
|
||||
"""An unknown state string should not validate."""
|
||||
assert user_auth.validate_state("invalid_state_value") is False
|
||||
|
||||
def test_state_consumed_after_use(self, user_auth):
|
||||
"""State token should be one-time use (consumed after validation)."""
|
||||
_, state = user_auth.get_authorization_url("github")
|
||||
assert user_auth.validate_state(state) is True
|
||||
assert user_auth.validate_state(state) is False # Second use fails
|
||||
|
||||
def test_state_expiry(self, user_auth):
|
||||
"""State token should be rejected after expiry (10 minutes)."""
|
||||
_, state = user_auth.get_authorization_url("github")
|
||||
# Manually expire the state (11+ minutes ago)
|
||||
user_auth._pending_states[state] = time.time() - 700
|
||||
assert user_auth.validate_state(state) is False
|
||||
|
||||
|
||||
# ── Token Exchange (Mocked) ──────────────────────────────────
|
||||
|
||||
|
||||
class TestTokenExchange:
|
||||
"""Test OAuth token exchange with mocked HTTP responses."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_token_exchange(self, user_auth):
|
||||
"""GitHub code exchange should return correct user info."""
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {
|
||||
"access_token": "gho_test_token",
|
||||
}
|
||||
|
||||
mock_user_response = MagicMock()
|
||||
mock_user_response.status_code = 200
|
||||
mock_user_response.json.return_value = {
|
||||
"id": 12345,
|
||||
"login": "testuser",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||
mock_client.get = AsyncMock(return_value=mock_user_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
user_info = await user_auth.exchange_code("github", "test_code")
|
||||
|
||||
assert user_info["provider"] == "github"
|
||||
assert user_info["provider_id"] == "12345"
|
||||
assert user_info["email"] == "test@example.com"
|
||||
assert user_info["name"] == "Test User"
|
||||
assert user_info["avatar_url"] == ("https://avatars.githubusercontent.com/u/12345")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_email_fallback(self, user_auth):
|
||||
"""When primary email is None, fetch from /user/emails endpoint."""
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {
|
||||
"access_token": "gho_test_token",
|
||||
}
|
||||
|
||||
mock_user_response = MagicMock()
|
||||
mock_user_response.status_code = 200
|
||||
mock_user_response.json.return_value = {
|
||||
"id": 12345,
|
||||
"login": "testuser",
|
||||
"name": "Test User",
|
||||
"email": None,
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
|
||||
}
|
||||
|
||||
mock_emails_response = MagicMock()
|
||||
mock_emails_response.status_code = 200
|
||||
mock_emails_response.json.return_value = [
|
||||
{
|
||||
"email": "secondary@example.com",
|
||||
"primary": False,
|
||||
"verified": True,
|
||||
},
|
||||
{
|
||||
"email": "primary@example.com",
|
||||
"primary": True,
|
||||
"verified": True,
|
||||
},
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||
mock_client.get = AsyncMock(side_effect=[mock_user_response, mock_emails_response])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
user_info = await user_auth.exchange_code("github", "test_code")
|
||||
|
||||
assert user_info["email"] == "primary@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_token_exchange(self, user_auth):
|
||||
"""Google code exchange should return correct user info."""
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {
|
||||
"access_token": "ya29.test_token",
|
||||
}
|
||||
|
||||
mock_user_response = MagicMock()
|
||||
mock_user_response.status_code = 200
|
||||
mock_user_response.json.return_value = {
|
||||
"sub": "google_67890",
|
||||
"email": "test@gmail.com",
|
||||
"name": "Test Google User",
|
||||
"picture": "https://lh3.googleusercontent.com/photo.jpg",
|
||||
"email_verified": True,
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||
mock_client.get = AsyncMock(return_value=mock_user_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
user_info = await user_auth.exchange_code("google", "test_code")
|
||||
|
||||
assert user_info["provider"] == "google"
|
||||
assert user_info["provider_id"] == "google_67890"
|
||||
assert user_info["email"] == "test@gmail.com"
|
||||
assert user_info["name"] == "Test Google User"
|
||||
assert user_info["avatar_url"] == ("https://lh3.googleusercontent.com/photo.jpg")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_exchange_failure(self, user_auth):
|
||||
"""Failed token exchange should raise ValueError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
mock_response.json.return_value = {
|
||||
"error": "bad_verification_code",
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to exchange"):
|
||||
await user_auth.exchange_code("github", "bad_code")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exchange_unsupported_provider(self, user_auth):
|
||||
"""Unsupported provider in exchange_code should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported provider"):
|
||||
await user_auth.exchange_code("twitter", "code")
|
||||
|
||||
|
||||
# ── Registration Rate Limiting ────────────────────────────────
|
||||
|
||||
|
||||
class TestRegistrationRateLimit:
|
||||
"""Test registration rate limiting (3 per IP per hour)."""
|
||||
|
||||
def test_under_limit(self, user_auth):
|
||||
"""IP under the limit should be allowed."""
|
||||
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||
user_auth.record_registration("1.2.3.4")
|
||||
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||
|
||||
def test_at_limit(self, user_auth):
|
||||
"""IP at the limit (3 registrations) should be blocked."""
|
||||
for _ in range(3):
|
||||
user_auth.record_registration("1.2.3.4")
|
||||
assert user_auth.check_registration_rate("1.2.3.4") is False
|
||||
|
||||
def test_different_ips(self, user_auth):
|
||||
"""Rate limiting should be per-IP."""
|
||||
for _ in range(3):
|
||||
user_auth.record_registration("1.2.3.4")
|
||||
assert user_auth.check_registration_rate("5.6.7.8") is True
|
||||
|
||||
def test_expired_records_cleaned(self, user_auth):
|
||||
"""Expired records should be cleaned up, allowing new registrations."""
|
||||
user_auth.record_registration("1.2.3.4")
|
||||
# Manually expire the record (over 1 hour ago)
|
||||
user_auth._registration_records["1.2.3.4"] = [time.time() - 3700]
|
||||
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||
|
||||
|
||||
# ── Session Creation ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUserSession:
|
||||
"""Test user session JWT creation and validation."""
|
||||
|
||||
def test_create_user_session(self, user_auth):
|
||||
"""create_user_session should return a non-empty JWT string."""
|
||||
token = user_auth.create_user_session(
|
||||
user_id="uuid-123",
|
||||
email="test@example.com",
|
||||
name="Test",
|
||||
role="user",
|
||||
)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_validate_user_session(self, user_auth):
|
||||
"""validate_user_session should decode a valid token correctly."""
|
||||
token = user_auth.create_user_session(
|
||||
user_id="uuid-123",
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
role="user",
|
||||
)
|
||||
session = user_auth.validate_user_session(token)
|
||||
assert session is not None
|
||||
assert session["user_id"] == "uuid-123"
|
||||
assert session["email"] == "test@example.com"
|
||||
assert session["name"] == "Test User"
|
||||
assert session["role"] == "user"
|
||||
assert session["type"] == "oauth_user"
|
||||
|
||||
def test_invalid_token(self, user_auth):
|
||||
"""validate_user_session should return None for garbage tokens."""
|
||||
assert user_auth.validate_user_session("garbage.token.here") is None
|
||||
|
||||
def test_expired_token(self, user_auth):
|
||||
"""validate_user_session should return None for expired tokens."""
|
||||
import jwt as pyjwt
|
||||
|
||||
payload = {
|
||||
"uid": "uuid-123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test",
|
||||
"role": "user",
|
||||
"type": "oauth_user",
|
||||
"iat": time.time() - 7200,
|
||||
"exp": time.time() - 3600, # Expired 1 hour ago
|
||||
}
|
||||
token = pyjwt.encode(payload, user_auth._session_secret, algorithm="HS256")
|
||||
assert user_auth.validate_user_session(token) is None
|
||||
|
||||
def test_session_with_none_name(self, user_auth):
|
||||
"""Session with None name should store empty string."""
|
||||
token = user_auth.create_user_session(
|
||||
user_id="uuid-456",
|
||||
email="noname@example.com",
|
||||
name=None,
|
||||
role="user",
|
||||
)
|
||||
session = user_auth.validate_user_session(token)
|
||||
assert session is not None
|
||||
assert session["name"] == ""
|
||||
|
||||
|
||||
# ── Singleton Pattern ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
"""Test module-level singleton pattern."""
|
||||
|
||||
def test_initialize_and_get(self):
|
||||
"""initialize_user_auth should set the singleton retrievable by get."""
|
||||
auth = initialize_user_auth(
|
||||
github_client_id="gh_id",
|
||||
github_client_secret="gh_secret",
|
||||
google_client_id="g_id",
|
||||
google_client_secret="g_secret",
|
||||
public_url="https://example.com",
|
||||
)
|
||||
assert get_user_auth() is auth
|
||||
|
||||
def test_get_without_init_raises(self):
|
||||
"""get_user_auth before init should raise RuntimeError."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
get_user_auth()
|
||||
|
||||
|
||||
# ── Provider Config ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProviderConfig:
|
||||
"""Test provider availability detection."""
|
||||
|
||||
def test_available_providers_both(self, user_auth):
|
||||
"""Both providers configured should both appear."""
|
||||
providers = user_auth.available_providers()
|
||||
assert "github" in providers
|
||||
assert "google" in providers
|
||||
|
||||
def test_no_github_if_missing_creds(self):
|
||||
"""Missing GitHub creds should exclude github from providers."""
|
||||
auth = UserAuth(
|
||||
google_client_id="g_id",
|
||||
google_client_secret="g_secret",
|
||||
public_url="https://example.com",
|
||||
)
|
||||
providers = auth.available_providers()
|
||||
assert "github" not in providers
|
||||
assert "google" in providers
|
||||
|
||||
def test_no_google_if_missing_creds(self):
|
||||
"""Missing Google creds should exclude google from providers."""
|
||||
auth = UserAuth(
|
||||
github_client_id="gh_id",
|
||||
github_client_secret="gh_secret",
|
||||
public_url="https://example.com",
|
||||
)
|
||||
providers = auth.available_providers()
|
||||
assert "github" in providers
|
||||
assert "google" not in providers
|
||||
|
||||
def test_no_providers_if_none_configured(self):
|
||||
"""No credentials at all should return empty providers list."""
|
||||
auth = UserAuth(public_url="https://example.com")
|
||||
assert auth.available_providers() == []
|
||||
|
||||
|
||||
# ── OAuthProvider Constants ───────────────────────────────────
|
||||
|
||||
|
||||
class TestOAuthProviderConstants:
|
||||
"""Test OAuthProvider class constants."""
|
||||
|
||||
def test_github_constant(self):
|
||||
"""OAuthProvider.GITHUB should be 'github'."""
|
||||
assert OAuthProvider.GITHUB == "github"
|
||||
|
||||
def test_google_constant(self):
|
||||
"""OAuthProvider.GOOGLE should be 'google'."""
|
||||
assert OAuthProvider.GOOGLE == "google"
|
||||
366
tests/test_user_endpoints.py
Normal file
366
tests/test_user_endpoints.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""Tests for per-user MCP endpoint handler (core/user_endpoints.py)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.user_endpoints import (
|
||||
USER_RATE_LIMIT_PER_MIN,
|
||||
_rate_limits,
|
||||
user_mcp_handler,
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_request(
|
||||
user_id: str = "user-uuid-001",
|
||||
alias: str = "myblog",
|
||||
method_name: str = "initialize",
|
||||
params: dict | None = None,
|
||||
api_key: str = "mhu_validkey1234567890abcdefghijklmnopqrst",
|
||||
req_id: int = 1,
|
||||
) -> Request:
|
||||
"""Build a mock Starlette Request for the user MCP endpoint."""
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": method_name,
|
||||
"params": params or {},
|
||||
}
|
||||
body_bytes = json.dumps(body).encode()
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": f"/u/{user_id}/{alias}/mcp",
|
||||
"path_params": {"user_id": user_id, "alias": alias},
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
if api_key:
|
||||
scope["headers"].append((b"authorization", f"Bearer {api_key}".encode()))
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": body_bytes}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
def _make_request_no_auth(
|
||||
user_id: str = "user-uuid-001",
|
||||
alias: str = "myblog",
|
||||
) -> Request:
|
||||
"""Build a mock Request without an Authorization header."""
|
||||
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": f"/u/{user_id}/{alias}/mcp",
|
||||
"path_params": {"user_id": user_id, "alias": alias},
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": body}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_rate_limits():
|
||||
"""Clear the global rate limit tracking between tests."""
|
||||
_rate_limits.clear()
|
||||
yield
|
||||
_rate_limits.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_tool_cache():
|
||||
"""Clear the tool schema cache between tests."""
|
||||
import core.user_endpoints as mod
|
||||
|
||||
mod._tool_schema_cache.clear()
|
||||
yield
|
||||
mod._tool_schema_cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_key_mgr():
|
||||
"""Patch get_user_key_manager to return a mock."""
|
||||
mgr = AsyncMock()
|
||||
mgr.validate_key = AsyncMock(
|
||||
return_value={
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"scopes": "read write",
|
||||
}
|
||||
)
|
||||
with patch("core.user_keys.get_user_key_manager", return_value=mgr):
|
||||
yield mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Patch get_database to return a mock."""
|
||||
db = AsyncMock()
|
||||
db.get_site_by_alias = AsyncMock(
|
||||
return_value={
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "active",
|
||||
"status_msg": "OK",
|
||||
}
|
||||
)
|
||||
with patch("core.database.get_database", return_value=db):
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_encryption():
|
||||
"""Patch get_credential_encryption to return a mock."""
|
||||
enc = MagicMock()
|
||||
enc.decrypt_credentials = MagicMock(
|
||||
return_value={
|
||||
"username": "admin",
|
||||
"app_password": "xxxx xxxx xxxx xxxx",
|
||||
}
|
||||
)
|
||||
with patch("core.encryption.get_credential_encryption", return_value=enc):
|
||||
yield enc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_registry():
|
||||
"""Patch get_tool_registry to return a registry with a sample tool."""
|
||||
tool_def = MagicMock()
|
||||
tool_def.name = "wordpress_list_posts"
|
||||
tool_def.description = "List WordPress posts"
|
||||
tool_def.input_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site": {"type": "string", "description": "Site identifier"},
|
||||
"status": {"type": "string", "description": "Post status"},
|
||||
},
|
||||
"required": ["site"],
|
||||
}
|
||||
|
||||
registry = MagicMock()
|
||||
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||
|
||||
with patch("core.tool_registry.get_tool_registry", return_value=registry):
|
||||
yield registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plugin_registry():
|
||||
"""Patch plugins.plugin_registry to return a mock."""
|
||||
mock_reg = MagicMock()
|
||||
mock_reg.is_registered = MagicMock(return_value=True)
|
||||
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.list_posts = AsyncMock(
|
||||
return_value=[
|
||||
{"id": 1, "title": "Hello World"},
|
||||
]
|
||||
)
|
||||
mock_reg.create_instance = MagicMock(return_value=mock_instance)
|
||||
|
||||
with patch("plugins.plugin_registry", mock_reg, create=True):
|
||||
yield mock_reg
|
||||
|
||||
|
||||
# ── Authentication Tests ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
"""Test authentication checks in user_mcp_handler."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_missing_auth_header(self, mock_key_mgr, mock_db):
|
||||
"""Request without Authorization header should return 401."""
|
||||
request = _make_request_no_auth()
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 401
|
||||
body = json.loads(response.body)
|
||||
assert "error" in body
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_invalid_api_key(self, mock_key_mgr, mock_db):
|
||||
"""Invalid API key should return 401."""
|
||||
mock_key_mgr.validate_key.return_value = None
|
||||
request = _make_request(api_key="mhu_invalidkeyvalue")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 401
|
||||
body = json.loads(response.body)
|
||||
assert "Invalid API key" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_user_id_mismatch(self, mock_key_mgr, mock_db):
|
||||
"""API key user_id not matching URL user_id should return 403."""
|
||||
mock_key_mgr.validate_key.return_value = {
|
||||
"key_id": "key-uuid-001",
|
||||
"user_id": "different-user-id",
|
||||
"scopes": "read write",
|
||||
}
|
||||
request = _make_request(user_id="user-uuid-001")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 403
|
||||
body = json.loads(response.body)
|
||||
assert "does not match" in body["error"]["message"]
|
||||
|
||||
|
||||
# ── Site Lookup Tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSiteLookup:
|
||||
"""Test site lookup behavior."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_site_not_found(self, mock_key_mgr, mock_db):
|
||||
"""Non-existent alias should return 404."""
|
||||
mock_db.get_site_by_alias.return_value = None
|
||||
request = _make_request(alias="nonexistent")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 404
|
||||
body = json.loads(response.body)
|
||||
assert "not found" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_disabled_site(self, mock_key_mgr, mock_db):
|
||||
"""Disabled site should return 403."""
|
||||
mock_db.get_site_by_alias.return_value = {
|
||||
"id": "site-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"plugin_type": "wordpress",
|
||||
"alias": "myblog",
|
||||
"url": "https://myblog.example.com",
|
||||
"credentials": b"encrypted-blob",
|
||||
"status": "disabled",
|
||||
"status_msg": "Disabled by admin",
|
||||
}
|
||||
request = _make_request()
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 403
|
||||
body = json.loads(response.body)
|
||||
assert "disabled" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
# ── MCP Protocol Methods ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestMCPMethods:
|
||||
"""Test MCP JSON-RPC method handling."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_initialize_method(self, mock_key_mgr, mock_db):
|
||||
"""initialize should return protocolVersion and capabilities."""
|
||||
request = _make_request(method_name="initialize")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
result = body["result"]
|
||||
assert "protocolVersion" in result
|
||||
assert "capabilities" in result
|
||||
assert "tools" in result["capabilities"]
|
||||
assert "serverInfo" in result
|
||||
assert "myblog" in result["serverInfo"]["name"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_notifications_initialized(self, mock_key_mgr, mock_db):
|
||||
"""notifications/initialized should return 204 with no body."""
|
||||
request = _make_request(method_name="notifications/initialized")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 204
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_tools_list(self, mock_key_mgr, mock_db, mock_tool_registry):
|
||||
"""tools/list should return tools with site param removed."""
|
||||
request = _make_request(method_name="tools/list")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
tools = body["result"]["tools"]
|
||||
assert len(tools) == 1
|
||||
assert tools[0]["name"] == "wordpress_list_posts"
|
||||
# 'site' should be removed from properties and required
|
||||
schema = tools[0]["inputSchema"]
|
||||
assert "site" not in schema.get("properties", {})
|
||||
if "required" in schema:
|
||||
assert "site" not in schema["required"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_tools_call_invalid_tool(self, mock_key_mgr, mock_db):
|
||||
"""Calling a tool with wrong plugin prefix should return error."""
|
||||
request = _make_request(
|
||||
method_name="tools/call",
|
||||
params={"name": "gitea_list_repos", "arguments": {}},
|
||||
)
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert "error" in body
|
||||
assert "not available" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_tools_call_success(
|
||||
self,
|
||||
mock_key_mgr,
|
||||
mock_db,
|
||||
mock_encryption,
|
||||
mock_tool_registry,
|
||||
mock_plugin_registry,
|
||||
):
|
||||
"""Calling a valid tool should return content result."""
|
||||
request = _make_request(
|
||||
method_name="tools/call",
|
||||
params={"name": "wordpress_list_posts", "arguments": {"status": "publish"}},
|
||||
)
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert "result" in body
|
||||
assert "content" in body["result"]
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_unsupported_method(self, mock_key_mgr, mock_db):
|
||||
"""Unknown MCP method should return -32601 error."""
|
||||
request = _make_request(method_name="resources/list")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body)
|
||||
assert "error" in body
|
||||
assert body["error"]["code"] == -32601
|
||||
assert "not supported" in body["error"]["message"]
|
||||
|
||||
|
||||
# ── Rate Limiting ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Test per-user rate limiting."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_rate_limit_exceeded(self, mock_key_mgr, mock_db):
|
||||
"""Exceeding per-minute rate limit should return 429."""
|
||||
# Fill the rate limit bucket
|
||||
now = time.time()
|
||||
_rate_limits["user-uuid-001"] = [now - i for i in range(USER_RATE_LIMIT_PER_MIN)]
|
||||
|
||||
request = _make_request(method_name="initialize")
|
||||
response = await user_mcp_handler(request)
|
||||
assert response.status_code == 429
|
||||
body = json.loads(response.body)
|
||||
assert "Rate limit" in body["error"]["message"]
|
||||
294
tests/test_user_keys.py
Normal file
294
tests/test_user_keys.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Tests for User API Key management (core/user_keys.py)."""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.user_keys import (
|
||||
KEY_PREFIX_TAG,
|
||||
UserKeyManager,
|
||||
get_user_key_manager,
|
||||
initialize_user_key_manager,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton():
|
||||
"""Reset the global UserKeyManager singleton between tests."""
|
||||
import core.user_keys as mod
|
||||
|
||||
original = mod._manager
|
||||
mod._manager = None
|
||||
yield
|
||||
mod._manager = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create and patch a mock database instance."""
|
||||
db = AsyncMock()
|
||||
# Default return values for DB methods
|
||||
db.create_api_key = AsyncMock(
|
||||
return_value={
|
||||
"id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": "$2b$12$fakehashvalue",
|
||||
"key_prefix": "abcdefgh",
|
||||
"name": "Claude Desktop",
|
||||
"scopes": "read write",
|
||||
"created_at": "2026-02-19T12:00:00Z",
|
||||
"expires_at": None,
|
||||
}
|
||||
)
|
||||
db.get_api_key_by_prefix = AsyncMock(return_value=None)
|
||||
db.get_api_keys_by_user = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"id": "key-uuid-001",
|
||||
"name": "Claude Desktop",
|
||||
"scopes": "read write",
|
||||
"created_at": "2026-02-19T12:00:00Z",
|
||||
"expires_at": None,
|
||||
"last_used_at": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
db.delete_api_key = AsyncMock(return_value=True)
|
||||
db.update_api_key_usage = AsyncMock()
|
||||
with patch("core.database.get_database", return_value=db):
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_mgr():
|
||||
"""Create a fresh UserKeyManager instance."""
|
||||
return UserKeyManager()
|
||||
|
||||
|
||||
# ── Key Creation ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyCreation:
|
||||
"""Test API key creation."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_key_format(self, key_mgr, mock_db):
|
||||
"""Created key should start with 'mhu_' and have substantial length."""
|
||||
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
||||
raw_key = result["key"]
|
||||
assert raw_key.startswith(KEY_PREFIX_TAG)
|
||||
# mhu_ (4) + urlsafe_b64 of 32 bytes (43 chars) = 47 chars total
|
||||
assert len(raw_key) == 47
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_key_stores_bcrypt_hash(self, key_mgr, mock_db):
|
||||
"""The key_hash passed to DB should be a bcrypt hash ($2b$ prefix)."""
|
||||
await key_mgr.create_key("user-uuid-001", "Test Key")
|
||||
call_kwargs = mock_db.create_api_key.call_args
|
||||
key_hash = call_kwargs.kwargs.get("key_hash") or call_kwargs[1].get("key_hash")
|
||||
assert key_hash.startswith("$2b$")
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_key_returns_metadata(self, key_mgr, mock_db):
|
||||
"""Create key should return key_id, name, scopes, and timestamps."""
|
||||
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
||||
assert "key_id" in result
|
||||
assert result["name"] == "Claude Desktop"
|
||||
assert result["scopes"] == "read write"
|
||||
assert "created_at" in result
|
||||
|
||||
|
||||
# ── Key Validation ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyValidation:
|
||||
"""Test API key validation logic."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_success(self, key_mgr, mock_db):
|
||||
"""Creating then validating a key should return valid info."""
|
||||
import bcrypt
|
||||
|
||||
# Create a key and capture the raw key
|
||||
result = await key_mgr.create_key("user-uuid-001", "Test Key")
|
||||
raw_key = result["key"]
|
||||
|
||||
# Set up DB to return a matching row with correct bcrypt hash
|
||||
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||
mock_db.get_api_key_by_prefix.return_value = {
|
||||
"id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": key_hash,
|
||||
"scopes": "read write",
|
||||
"expires_at": None,
|
||||
}
|
||||
|
||||
info = await key_mgr.validate_key(raw_key)
|
||||
assert info is not None
|
||||
assert info["user_id"] == "user-uuid-001"
|
||||
assert info["key_id"] == "key-uuid-001"
|
||||
assert info["scopes"] == "read write"
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_wrong_key(self, key_mgr, mock_db):
|
||||
"""A key that doesn't match the bcrypt hash should return None."""
|
||||
import bcrypt
|
||||
|
||||
correct_key = "mhu_correctkeyvalue1234567890abcdefghijklmno"
|
||||
key_hash = bcrypt.hashpw(correct_key.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
mock_db.get_api_key_by_prefix.return_value = {
|
||||
"id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": key_hash,
|
||||
"scopes": "read write",
|
||||
"expires_at": None,
|
||||
}
|
||||
|
||||
wrong_key = "mhu_wrongkeyvalue1234567890abcdefghijklmnopq"
|
||||
info = await key_mgr.validate_key(wrong_key)
|
||||
assert info is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_no_prefix(self, key_mgr, mock_db):
|
||||
"""A key without the 'mhu_' prefix should immediately return None."""
|
||||
info = await key_mgr.validate_key("bad_prefix_key")
|
||||
assert info is None
|
||||
# DB should not even be queried
|
||||
mock_db.get_api_key_by_prefix.assert_not_called()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_expired(self, key_mgr, mock_db):
|
||||
"""A key with expires_at in the past should return None."""
|
||||
import bcrypt
|
||||
|
||||
raw_key = "mhu_expiredkeyvalue1234567890abcdefghijklmno"
|
||||
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
expired_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
mock_db.get_api_key_by_prefix.return_value = {
|
||||
"id": "key-uuid-002",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": key_hash,
|
||||
"scopes": "read",
|
||||
"expires_at": expired_at,
|
||||
}
|
||||
|
||||
info = await key_mgr.validate_key(raw_key)
|
||||
assert info is None
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_cache(self, key_mgr, mock_db):
|
||||
"""Second validation of the same key should use cache (no DB prefix lookup)."""
|
||||
import bcrypt
|
||||
|
||||
result = await key_mgr.create_key("user-uuid-001", "Cache Test")
|
||||
raw_key = result["key"]
|
||||
|
||||
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||
mock_db.get_api_key_by_prefix.return_value = {
|
||||
"id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": key_hash,
|
||||
"scopes": "read write",
|
||||
"expires_at": None,
|
||||
}
|
||||
|
||||
# First validation — hits DB
|
||||
info1 = await key_mgr.validate_key(raw_key)
|
||||
assert info1 is not None
|
||||
assert mock_db.get_api_key_by_prefix.call_count == 1
|
||||
|
||||
# Second validation — should use cache
|
||||
info2 = await key_mgr.validate_key(raw_key)
|
||||
assert info2 is not None
|
||||
# get_api_key_by_prefix should NOT be called again
|
||||
assert mock_db.get_api_key_by_prefix.call_count == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_validate_key_updates_usage(self, key_mgr, mock_db):
|
||||
"""Successful validation should call update_api_key_usage."""
|
||||
import bcrypt
|
||||
|
||||
result = await key_mgr.create_key("user-uuid-001", "Usage Test")
|
||||
raw_key = result["key"]
|
||||
|
||||
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||
mock_db.get_api_key_by_prefix.return_value = {
|
||||
"id": "key-uuid-001",
|
||||
"user_id": "user-uuid-001",
|
||||
"key_hash": key_hash,
|
||||
"scopes": "read write",
|
||||
"expires_at": None,
|
||||
}
|
||||
|
||||
await key_mgr.validate_key(raw_key)
|
||||
mock_db.update_api_key_usage.assert_called_with("key-uuid-001")
|
||||
|
||||
|
||||
# ── Key Listing ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyListing:
|
||||
"""Test key listing functionality."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_list_keys_no_hash(self, key_mgr, mock_db):
|
||||
"""Listed keys should not contain key_hash field."""
|
||||
keys = await key_mgr.list_keys("user-uuid-001")
|
||||
assert len(keys) == 1
|
||||
assert "key_hash" not in keys[0]
|
||||
assert keys[0]["name"] == "Claude Desktop"
|
||||
|
||||
|
||||
# ── Key Deletion ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyDeletion:
|
||||
"""Test key deletion and cache invalidation."""
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_key_clears_cache(self, key_mgr, mock_db):
|
||||
"""Deleting a key should remove it from the validation cache."""
|
||||
# Populate cache manually
|
||||
key_mgr._cache["mhu_test_cached_key"] = (
|
||||
"key-uuid-001",
|
||||
"user-uuid-001",
|
||||
"read write",
|
||||
time.time(),
|
||||
)
|
||||
assert "mhu_test_cached_key" in key_mgr._cache
|
||||
|
||||
deleted = await key_mgr.delete_key("key-uuid-001", "user-uuid-001")
|
||||
assert deleted is True
|
||||
assert "mhu_test_cached_key" not in key_mgr._cache
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_delete_key_not_found(self, key_mgr, mock_db):
|
||||
"""Deleting a non-existent key should return False."""
|
||||
mock_db.delete_api_key.return_value = False
|
||||
deleted = await key_mgr.delete_key("nonexistent-key", "user-uuid-001")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
# ── Singleton Pattern ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
"""Test module-level singleton pattern."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_singleton_pattern(self):
|
||||
"""initialize_user_key_manager should set the singleton retrievable by get."""
|
||||
mgr = initialize_user_key_manager()
|
||||
assert get_user_key_manager() is mgr
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_without_init_raises(self):
|
||||
"""get_user_key_manager before init should raise RuntimeError."""
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
get_user_key_manager()
|
||||
@@ -8,10 +8,16 @@ import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from plugins.base import BasePlugin, PluginRegistry
|
||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError, WordPressClient
|
||||
from plugins.wordpress.client import (
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ConnectionError,
|
||||
WordPressClient,
|
||||
)
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
|
||||
# --- WordPressClient Tests ---
|
||||
@@ -282,6 +288,282 @@ class TestWordPressClientRequest:
|
||||
)
|
||||
|
||||
|
||||
# --- Connection Error Tests ---
|
||||
|
||||
|
||||
class TestWordPressClientConnectionErrors:
|
||||
"""Test network error differentiation and retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
return WordPressClient(
|
||||
site_url="https://example.com",
|
||||
username="admin",
|
||||
app_password="xxxx",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_error_raises_connection_error(self, client):
|
||||
"""DNS failure should raise ConnectionError with helpful message."""
|
||||
dns_error = aiohttp.ClientConnectorDNSError(
|
||||
connection_key=MagicMock(), os_error=OSError("Name resolution failed")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(side_effect=dns_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with pytest.raises(ConnectionError, match="DNS resolution failed"):
|
||||
await client.request("GET", "posts")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssl_error_raises_connection_error(self, client):
|
||||
"""SSL certificate error should raise ConnectionError with helpful message."""
|
||||
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||
connection_key=MagicMock(), certificate_error=Exception("cert expired")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(side_effect=ssl_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with pytest.raises(ConnectionError, match="SSL certificate error"):
|
||||
await client.request("GET", "posts")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_refused_raises_connection_error(self, client):
|
||||
"""Connection refused should raise ConnectionError with helpful message."""
|
||||
conn_error = aiohttp.ClientConnectorError(
|
||||
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(side_effect=conn_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with pytest.raises(ConnectionError, match="Cannot connect"):
|
||||
await client.request("GET", "posts")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_retries_then_raises(self, client):
|
||||
"""Timeout should retry then raise ConnectionError."""
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(side_effect=TimeoutError())
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
with pytest.raises(ConnectionError, match="timed out"):
|
||||
await client.request("GET", "posts")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_url_raises_connection_error(self, client):
|
||||
"""Invalid URL should raise ConnectionError."""
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(side_effect=aiohttp.InvalidURL("bad url"))
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with pytest.raises(ConnectionError, match="Invalid URL"):
|
||||
await client.request("GET", "posts")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_not_retried(self, client):
|
||||
"""Auth errors should NOT be retried."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 401
|
||||
mock_response.text = AsyncMock(
|
||||
return_value=json.dumps({"code": "invalid_auth", "message": "Bad"})
|
||||
)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_response),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with pytest.raises(AuthenticationError):
|
||||
await client.request("GET", "posts")
|
||||
# Should be called only once (no retry)
|
||||
assert mock_session.request.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_502_retried_then_raises(self, client):
|
||||
"""502 errors should be retried before raising."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 502
|
||||
mock_response.text = AsyncMock(return_value="Bad Gateway")
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.request = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_response),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
with pytest.raises(Exception, match="BAD_GATEWAY"):
|
||||
await client.request("GET", "posts")
|
||||
# Should be called 3 times (1 + 2 retries)
|
||||
assert mock_session.request.call_count == 3
|
||||
|
||||
|
||||
class TestWordPressClientHealthCheck:
|
||||
"""Test site health check with error differentiation."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
return WordPressClient(
|
||||
site_url="https://example.com",
|
||||
username="admin",
|
||||
app_password="xxxx",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_healthy_site(self, client):
|
||||
"""Healthy site should return proper status."""
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(
|
||||
return_value={
|
||||
"name": "My Blog",
|
||||
"description": "A blog",
|
||||
"url": "https://example.com",
|
||||
"routes": {"a": 1},
|
||||
}
|
||||
)
|
||||
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_response),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is True
|
||||
assert result["name"] == "My Blog"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_api_disabled_detected(self, client):
|
||||
"""403/404 on /wp-json should detect REST API disabled."""
|
||||
for status in (403, 404):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = status
|
||||
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_response),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is False
|
||||
assert result["accessible"] is True
|
||||
assert result["error_type"] == "rest_api_disabled"
|
||||
assert "REST API" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_failure_in_health_check(self, client):
|
||||
"""DNS failure in health check should be detected."""
|
||||
dns_error = aiohttp.ClientConnectorDNSError(
|
||||
connection_key=MagicMock(), os_error=OSError("DNS failed")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(side_effect=dns_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is False
|
||||
assert result["error_type"] == "dns_failure"
|
||||
assert "DNS" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_in_health_check(self, client):
|
||||
"""Timeout in health check should be detected."""
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(side_effect=TimeoutError())
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is False
|
||||
assert result["error_type"] == "timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssl_error_in_health_check(self, client):
|
||||
"""SSL error in health check should be detected."""
|
||||
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||
connection_key=MagicMock(), certificate_error=Exception("expired")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(side_effect=ssl_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is False
|
||||
assert result["error_type"] == "ssl_error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_refused_in_health_check(self, client):
|
||||
"""Connection refused in health check should be detected."""
|
||||
conn_error = aiohttp.ClientConnectorError(
|
||||
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||
)
|
||||
with patch("aiohttp.ClientSession") as mock_cls:
|
||||
mock_session = AsyncMock()
|
||||
mock_session.get = MagicMock(side_effect=conn_error)
|
||||
mock_cls.return_value = AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_session),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
result = await client.check_site_health()
|
||||
assert result["healthy"] is False
|
||||
assert result["error_type"] == "connection_refused"
|
||||
|
||||
|
||||
# --- WordPressPlugin Tests ---
|
||||
|
||||
|
||||
|
||||
BIN
wordpress-plugin/openpanel-self-hosted.zip
Normal file
BIN
wordpress-plugin/openpanel-self-hosted.zip
Normal file
Binary file not shown.
106
wordpress-plugin/openpanel-self-hosted/README.md
Normal file
106
wordpress-plugin/openpanel-self-hosted/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# OpenPanel Self-Hosted - WordPress Plugin
|
||||
|
||||
**Version:** 1.1.1
|
||||
**Requires:** WordPress 5.8+, PHP 7.4+
|
||||
**License:** GPLv2 or later
|
||||
|
||||
## Description
|
||||
|
||||
**OpenPanel Self-Hosted** is a fork of the [official OpenPanel WordPress plugin](https://wordpress.org/plugins/openpanel/) with full **Self-Hosted instance support**. [OpenPanel](https://openpanel.dev) is an open-source web and product analytics platform — a privacy-friendly alternative to Google Analytics.
|
||||
|
||||
This plugin seamlessly integrates OpenPanel (Cloud or Self-Hosted) with your WordPress site while maximizing reliability and avoiding ad-blocker interference.
|
||||
|
||||
Designed to work with [MCP Hub](https://github.com/airano-ir/mcphub) for AI-powered analytics management.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Self-Hosted Support**: Works with both OpenPanel Cloud and your own Self-Hosted instance
|
||||
- **Ad-Blocker Resistant**: Serves analytics scripts and API calls from your own domain
|
||||
- **Real-Time Analytics**: Instant insights without processing delays
|
||||
- **Privacy-Friendly**: Cookie-less tracking — no cookie banners needed
|
||||
- **Performance Optimized**: Local script caching and efficient proxying
|
||||
- **Product Analytics**: Funnel analysis, retention tracking, conversion metrics
|
||||
- **Web Analytics**: Visitors, referrals, top pages, devices, sessions, bounce rates
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Inlines** `op1.js` directly into your pages (cached locally for 1 week)
|
||||
2. **Bootstraps** the OpenPanel SDK with your Client ID automatically
|
||||
3. **Proxies** all SDK requests through WordPress REST API (`/wp-json/openpanel/`)
|
||||
4. **Preserves** all request methods, headers, query parameters, and body data
|
||||
|
||||
Serving scripts and data from your own domain origin avoids third-party blocking and improves tracking reliability.
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: Upload via WordPress Admin
|
||||
|
||||
1. Download `openpanel-self-hosted.zip` from [Releases](https://github.com/airano-ir/mcphub)
|
||||
2. Go to WordPress Admin > Plugins > Add New > Upload Plugin
|
||||
3. Upload the ZIP and click "Install Now"
|
||||
4. Activate the plugin
|
||||
|
||||
### Method 2: Manual Upload
|
||||
|
||||
1. Upload the `openpanel-self-hosted` folder to `/wp-content/plugins/`
|
||||
2. Activate via WordPress Admin > Plugins
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cloud Mode (openpanel.dev)
|
||||
|
||||
1. Sign up at [OpenPanel.dev](https://openpanel.dev) and create a project
|
||||
2. Go to **Settings > OpenPanel** in WordPress admin
|
||||
3. Select **Cloud (openpanel.dev)** as hosting mode
|
||||
4. Paste your **Client ID** (starts with `op_client_`)
|
||||
5. Enable desired auto-tracking features
|
||||
|
||||
### Self-Hosted Mode
|
||||
|
||||
1. Go to **Settings > OpenPanel** in WordPress admin
|
||||
2. Select **Self-Hosted** as hosting mode
|
||||
3. Enter your **API URL** (e.g., `https://api.openpanel.yourdomain.com`)
|
||||
4. Enter your **Dashboard URL** (e.g., `https://openpanel.yourdomain.com`)
|
||||
5. Paste your **Client ID** from your self-hosted instance
|
||||
6. Enable desired auto-tracking features
|
||||
|
||||
| Setting | Example Value |
|
||||
|---------|---------------|
|
||||
| API URL | `https://api.openpanel.yourdomain.com` |
|
||||
| Dashboard URL | `https://openpanel.yourdomain.com` |
|
||||
| Client ID | Your project's client ID from OpenPanel |
|
||||
|
||||
## Privacy
|
||||
|
||||
- **No Cookie Banners Required**: Cookie-less tracking technology
|
||||
- **GDPR Friendly**: Compliant without requiring user consent for basic analytics
|
||||
- **Data Ownership**: Full control over your analytics data
|
||||
- **No PII Collection**: Tracks behavior patterns without personal information
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.1.1
|
||||
- Fixed op1.js loading for self-hosted instances (was always loading from CDN)
|
||||
- Both inline cache and external fallback now use the correct self-hosted URL
|
||||
|
||||
### 1.1.0
|
||||
- Full self-hosted OpenPanel instance support
|
||||
- Hosting mode selector: Cloud vs Self-Hosted
|
||||
- Configurable API URL and Dashboard URL
|
||||
- Dynamic proxy validation for custom domains
|
||||
|
||||
### 1.0.0
|
||||
- Initial release with OpenPanel Cloud integration
|
||||
- Automatic script inlining with local caching
|
||||
- REST API proxy for ad-blocker resistant tracking
|
||||
- Auto-tracking: page views, outgoing links, page attributes
|
||||
|
||||
## Support
|
||||
|
||||
- **Plugin issues:** [GitHub Issues](https://github.com/airano-ir/mcphub/issues)
|
||||
- **OpenPanel platform:** [OpenPanel.dev](https://openpanel.dev)
|
||||
- **MCP Hub:** [MCP Hub Documentation](https://github.com/airano-ir/mcphub)
|
||||
|
||||
## License
|
||||
|
||||
GPLv2 or later - See [LICENSE](https://www.gnu.org/licenses/gpl-2.0.html) for details
|
||||
@@ -1,14 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: OpenPanel
|
||||
* Description: Activate OpenPanel to start tracking your website. Supports both Cloud and Self-Hosted instances.
|
||||
* Plugin Name: OpenPanel Self-Hosted
|
||||
* Plugin URI: https://github.com/airano-ir/mcphub
|
||||
* Description: OpenPanel analytics with full Self-Hosted instance support. Ad-blocker resistant proxy, privacy-friendly cookie-less tracking. Fork of the official OpenPanel plugin with self-hosted enhancements.
|
||||
* Version: 1.1.1
|
||||
* Author: OpenPanel / Airano
|
||||
* Author: OpenPanel / MCP Hub
|
||||
* Author URI: https://github.com/airano-ir
|
||||
* License: GPLv2 or later
|
||||
* Requires at least: 5.8
|
||||
* Requires PHP: 7.4
|
||||
* Tested up to: 6.8
|
||||
* Text Domain: openpanel
|
||||
* Tested up to: 6.9
|
||||
* Text Domain: openpanel-self-hosted
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) { exit; }
|
||||
@@ -52,55 +54,55 @@ final class OP_WP_Proxy {
|
||||
]);
|
||||
|
||||
// Section: Hosting Mode
|
||||
add_settings_section('op_hosting', __('Hosting Mode', 'openpanel'), function() {
|
||||
echo '<p>' . esc_html__('Choose between OpenPanel Cloud or your own Self-Hosted instance.', 'openpanel') . '</p>';
|
||||
add_settings_section('op_hosting', __('Hosting Mode', 'openpanel-self-hosted'), function() {
|
||||
echo '<p>' . esc_html__('Choose between OpenPanel Cloud or your own Self-Hosted instance.', 'openpanel-self-hosted') . '</p>';
|
||||
}, self::OPTION_KEY);
|
||||
|
||||
add_settings_field('hosting_mode', __('Mode', 'openpanel'), function() {
|
||||
add_settings_field('hosting_mode', __('Mode', 'openpanel-self-hosted'), function() {
|
||||
$opts = get_option(self::OPTION_KEY);
|
||||
$mode = isset($opts['hosting_mode']) ? $opts['hosting_mode'] : 'cloud';
|
||||
?>
|
||||
<label>
|
||||
<input type="radio" name="<?php echo esc_attr(self::OPTION_KEY); ?>[hosting_mode]" value="cloud" <?php checked($mode, 'cloud'); ?> class="op-hosting-mode">
|
||||
<?php esc_html_e('Cloud (openpanel.dev)', 'openpanel'); ?>
|
||||
<?php esc_html_e('Cloud (openpanel.dev)', 'openpanel-self-hosted'); ?>
|
||||
</label><br>
|
||||
<label>
|
||||
<input type="radio" name="<?php echo esc_attr(self::OPTION_KEY); ?>[hosting_mode]" value="selfhosted" <?php checked($mode, 'selfhosted'); ?> class="op-hosting-mode">
|
||||
<?php esc_html_e('Self-Hosted', 'openpanel'); ?>
|
||||
<?php esc_html_e('Self-Hosted', 'openpanel-self-hosted'); ?>
|
||||
</label>
|
||||
<p class="description"><?php esc_html_e('Select Self-Hosted if you run your own OpenPanel instance.', 'openpanel'); ?></p>
|
||||
<p class="description"><?php esc_html_e('Select Self-Hosted if you run your own OpenPanel instance.', 'openpanel-self-hosted'); ?></p>
|
||||
<?php
|
||||
}, self::OPTION_KEY, 'op_hosting');
|
||||
|
||||
// Section: Self-Hosted URLs
|
||||
add_settings_section('op_selfhosted', __('Self-Hosted Settings', 'openpanel'), function() {
|
||||
echo '<p>' . esc_html__('Configure your Self-Hosted OpenPanel URLs. Only required if using Self-Hosted mode.', 'openpanel') . '</p>';
|
||||
add_settings_section('op_selfhosted', __('Self-Hosted Settings', 'openpanel-self-hosted'), function() {
|
||||
echo '<p>' . esc_html__('Configure your Self-Hosted OpenPanel URLs. Only required if using Self-Hosted mode.', 'openpanel-self-hosted') . '</p>';
|
||||
}, self::OPTION_KEY);
|
||||
|
||||
add_settings_field('api_url', __('API URL', 'openpanel'), function() {
|
||||
add_settings_field('api_url', __('API URL', 'openpanel-self-hosted'), function() {
|
||||
$opts = get_option(self::OPTION_KEY);
|
||||
printf('<input type="url" name="%s[api_url]" value="%s" class="regular-text op-selfhosted-field" placeholder="https://api.openpanel.yourdomain.com"/>',
|
||||
esc_attr(self::OPTION_KEY),
|
||||
isset($opts['api_url']) ? esc_attr($opts['api_url']) : ''
|
||||
);
|
||||
echo '<p class="description">' . esc_html__('Your OpenPanel API endpoint (e.g., https://api.openpanel.yourdomain.com)', 'openpanel') . '</p>';
|
||||
echo '<p class="description">' . esc_html__('Your OpenPanel API endpoint (e.g., https://api.openpanel.yourdomain.com)', 'openpanel-self-hosted') . '</p>';
|
||||
}, self::OPTION_KEY, 'op_selfhosted');
|
||||
|
||||
add_settings_field('dashboard_url', __('Dashboard URL', 'openpanel'), function() {
|
||||
add_settings_field('dashboard_url', __('Dashboard URL', 'openpanel-self-hosted'), function() {
|
||||
$opts = get_option(self::OPTION_KEY);
|
||||
printf('<input type="url" name="%s[dashboard_url]" value="%s" class="regular-text op-selfhosted-field" placeholder="https://openpanel.yourdomain.com"/>',
|
||||
esc_attr(self::OPTION_KEY),
|
||||
isset($opts['dashboard_url']) ? esc_attr($opts['dashboard_url']) : ''
|
||||
);
|
||||
echo '<p class="description">' . esc_html__('Your OpenPanel Dashboard URL — used to load op1.js from your server instead of the CDN (e.g., https://openpanel.yourdomain.com)', 'openpanel') . '</p>';
|
||||
echo '<p class="description">' . esc_html__('Your OpenPanel Dashboard URL — used to load op1.js from your server instead of the CDN (e.g., https://openpanel.yourdomain.com)', 'openpanel-self-hosted') . '</p>';
|
||||
}, self::OPTION_KEY, 'op_selfhosted');
|
||||
|
||||
// Section: Main Settings
|
||||
add_settings_section('op_main', __('OpenPanel Settings', 'openpanel'), function() {
|
||||
echo '<p>' . esc_html__('Set your OpenPanel Client ID. The SDK and requests are served from your domain to avoid ad blockers.', 'openpanel') . '</p>';
|
||||
add_settings_section('op_main', __('OpenPanel Settings', 'openpanel-self-hosted'), function() {
|
||||
echo '<p>' . esc_html__('Set your OpenPanel Client ID. The SDK and requests are served from your domain to avoid ad blockers.', 'openpanel-self-hosted') . '</p>';
|
||||
}, self::OPTION_KEY);
|
||||
|
||||
add_settings_field('client_id', __('Client ID', 'openpanel'), function() {
|
||||
add_settings_field('client_id', __('Client ID', 'openpanel-self-hosted'), function() {
|
||||
$opts = get_option(self::OPTION_KEY);
|
||||
printf('<input type="text" name="%s[client_id]" value="%s" class="regular-text" placeholder="op_client_..."/>',
|
||||
esc_attr(self::OPTION_KEY),
|
||||
@@ -108,14 +110,14 @@ final class OP_WP_Proxy {
|
||||
);
|
||||
}, self::OPTION_KEY, 'op_main');
|
||||
|
||||
add_settings_field('toggles', __('Auto-tracking (optional)', 'openpanel'), function() {
|
||||
add_settings_field('toggles', __('Auto-tracking (optional)', 'openpanel-self-hosted'), function() {
|
||||
$o = get_option(self::OPTION_KEY);
|
||||
// Default track_screen to true if not set
|
||||
$track_screen = isset($o['track_screen']) ? !empty($o['track_screen']) : true;
|
||||
?>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_screen]" <?php checked($track_screen); ?>> <?php esc_html_e('Track page views automatically', 'openpanel'); ?></label><br>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_outgoing]" <?php checked(!empty($o['track_outgoing'])); ?>> <?php esc_html_e('Track clicks on outgoing links', 'openpanel'); ?></label><br>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_attributes]" <?php checked(!empty($o['track_attributes'])); ?>> <?php esc_html_e('Track additional page attributes', 'openpanel'); ?></label>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_screen]" <?php checked($track_screen); ?>> <?php esc_html_e('Track page views automatically', 'openpanel-self-hosted'); ?></label><br>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_outgoing]" <?php checked(!empty($o['track_outgoing'])); ?>> <?php esc_html_e('Track clicks on outgoing links', 'openpanel-self-hosted'); ?></label><br>
|
||||
<label><input type="checkbox" name="<?php echo esc_attr(self::OPTION_KEY); ?>[track_attributes]" <?php checked(!empty($o['track_attributes'])); ?>> <?php esc_html_e('Track additional page attributes', 'openpanel-self-hosted'); ?></label>
|
||||
<?php
|
||||
}, self::OPTION_KEY, 'op_main');
|
||||
}
|
||||
@@ -136,7 +138,7 @@ final class OP_WP_Proxy {
|
||||
delete_transient(self::TRANSIENT_JS);
|
||||
add_action('admin_notices', function() {
|
||||
echo '<div class="notice notice-success is-dismissible"><p>' .
|
||||
esc_html__('OpenPanel cache cleared successfully. The latest op1.js will be fetched on the next page load.', 'openpanel') .
|
||||
esc_html__('OpenPanel cache cleared successfully. The latest op1.js will be fetched on the next page load.', 'openpanel-self-hosted') .
|
||||
'</p></div>';
|
||||
});
|
||||
}
|
||||
@@ -153,18 +155,18 @@ final class OP_WP_Proxy {
|
||||
<?php
|
||||
settings_fields(self::OPTION_KEY);
|
||||
do_settings_sections(self::OPTION_KEY);
|
||||
submit_button(__('Save Settings', 'openpanel'));
|
||||
submit_button(__('Save Settings', 'openpanel-self-hosted'));
|
||||
?>
|
||||
</form>
|
||||
|
||||
<hr style="margin: 2rem 0;">
|
||||
|
||||
<h2><?php esc_html_e('Cache Management', 'openpanel'); ?></h2>
|
||||
<p><?php esc_html_e('Clear the cached op1.js file to force fetch the latest version from OpenPanel.', 'openpanel'); ?></p>
|
||||
<h2><?php esc_html_e('Cache Management', 'openpanel-self-hosted'); ?></h2>
|
||||
<p><?php esc_html_e('Clear the cached op1.js file to force fetch the latest version from OpenPanel.', 'openpanel-self-hosted'); ?></p>
|
||||
|
||||
<form method="post" action="">
|
||||
<?php wp_nonce_field('op_clear_cache_nonce'); ?>
|
||||
<input type="submit" name="op_clear_cache" class="button button-secondary" value="<?php esc_attr_e('Clear Cache & Force Refresh', 'openpanel'); ?>">
|
||||
<input type="submit" name="op_clear_cache" class="button button-secondary" value="<?php esc_attr_e('Clear Cache & Force Refresh', 'openpanel-self-hosted'); ?>">
|
||||
</form>
|
||||
|
||||
<?php
|
||||
@@ -178,42 +180,42 @@ final class OP_WP_Proxy {
|
||||
if ($time_remaining > 0) {
|
||||
echo '<p style="margin-top:1rem;color:#666;">' .
|
||||
/* translators: %s: human readable time difference */
|
||||
sprintf(esc_html__('Cache expires in %s', 'openpanel'), esc_html(human_time_diff(time(), $cached_time))) .
|
||||
sprintf(esc_html__('Cache expires in %s', 'openpanel-self-hosted'), esc_html(human_time_diff(time(), $cached_time))) .
|
||||
'</p>';
|
||||
} else {
|
||||
echo '<p style="margin-top:1rem;color:#666;">' .
|
||||
esc_html__('Cache has expired and will refresh on next page load.', 'openpanel') .
|
||||
esc_html__('Cache has expired and will refresh on next page load.', 'openpanel-self-hosted') .
|
||||
'</p>';
|
||||
}
|
||||
} else {
|
||||
echo '<p style="margin-top:1rem;color:#666;">' .
|
||||
esc_html__('No cached version found. op1.js will be fetched on next page load.', 'openpanel') .
|
||||
esc_html__('No cached version found. op1.js will be fetched on next page load.', 'openpanel-self-hosted') .
|
||||
'</p>';
|
||||
}
|
||||
?>
|
||||
|
||||
<p style="margin-top:1rem;color:#666;">
|
||||
<?php esc_html_e('The plugin fetches and inlines op1.js (cached for 1 week). If fetching fails, it falls back to the CDN script.', 'openpanel'); ?>
|
||||
<?php esc_html_e('The plugin fetches and inlines op1.js (cached for 1 week). If fetching fails, it falls back to the CDN script.', 'openpanel-self-hosted'); ?>
|
||||
</p>
|
||||
|
||||
<hr style="margin: 2rem 0;">
|
||||
|
||||
<h2><?php esc_html_e('Current Configuration', 'openpanel'); ?></h2>
|
||||
<h2><?php esc_html_e('Current Configuration', 'openpanel-self-hosted'); ?></h2>
|
||||
<table class="widefat" style="max-width: 600px;">
|
||||
<tr>
|
||||
<td><strong><?php esc_html_e('Mode', 'openpanel'); ?></strong></td>
|
||||
<td><strong><?php esc_html_e('Mode', 'openpanel-self-hosted'); ?></strong></td>
|
||||
<td><?php echo esc_html($mode === 'selfhosted' ? 'Self-Hosted' : 'Cloud'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php esc_html_e('API URL', 'openpanel'); ?></strong></td>
|
||||
<td><strong><?php esc_html_e('API URL', 'openpanel-self-hosted'); ?></strong></td>
|
||||
<td><code><?php echo esc_html($this->get_api_base()); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php esc_html_e('JS URL', 'openpanel'); ?></strong></td>
|
||||
<td><strong><?php esc_html_e('JS URL', 'openpanel-self-hosted'); ?></strong></td>
|
||||
<td><code><?php echo esc_html($this->get_js_url()); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php esc_html_e('Proxy Endpoint', 'openpanel'); ?></strong></td>
|
||||
<td><strong><?php esc_html_e('Proxy Endpoint', 'openpanel-self-hosted'); ?></strong></td>
|
||||
<td><code><?php echo esc_html(rest_url(self::REST_NS . '/')); ?></code></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -479,7 +481,7 @@ final class OP_WP_Proxy {
|
||||
// Content-Type is a special header NOT prefixed with HTTP_ in PHP
|
||||
// This is critical for OpenPanel API which requires application/json
|
||||
if (!empty($_SERVER['CONTENT_TYPE'])) {
|
||||
$headers['Content-Type'] = sanitize_text_field($_SERVER['CONTENT_TYPE']);
|
||||
$headers['Content-Type'] = sanitize_text_field(wp_unslash($_SERVER['CONTENT_TYPE']));
|
||||
}
|
||||
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
@@ -1,18 +1,20 @@
|
||||
=== OpenPanel ===
|
||||
=== OpenPanel Self-Hosted ===
|
||||
Contributors: openpanel, airano
|
||||
Tags: analytics, web analytics, privacy-friendly, tracking, proxy, self-hosted
|
||||
Tags: analytics, privacy-friendly, tracking, self-hosted, proxy
|
||||
Requires at least: 5.8
|
||||
Tested up to: 6.8
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.1.1
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
OpenPanel WordPress plugin - Privacy-friendly analytics with ad-blocker resistance. Supports both OpenPanel Cloud and Self-Hosted instances. Inline tracking scripts and proxy API calls through your domain.
|
||||
Privacy-friendly analytics with ad-blocker resistance. Supports OpenPanel Cloud and Self-Hosted instances.
|
||||
|
||||
== Description ==
|
||||
|
||||
**OpenPanel** is an open-source web and product analytics platform that serves as a privacy-friendly alternative to traditional analytics solutions. This WordPress plugin seamlessly integrates [OpenPanel](https://openpanel.dev) with your WordPress site while maximizing reliability and avoiding ad-blocker interference.
|
||||
**OpenPanel Self-Hosted** is a fork of the [official OpenPanel WordPress plugin](https://wordpress.org/plugins/openpanel/) with full **Self-Hosted instance support**. [OpenPanel](https://openpanel.dev) is an open-source web and product analytics platform — a privacy-friendly alternative to Google Analytics. This plugin seamlessly integrates OpenPanel (Cloud or Self-Hosted) with your WordPress site while maximizing reliability and avoiding ad-blocker interference.
|
||||
|
||||
Designed to work with [MCP Hub](https://github.com/airano-ir/mcphub) for AI-powered analytics management.
|
||||
|
||||
= Key Features =
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
|
||||
**Version:** 1.3.0
|
||||
**Requires:** WordPress 5.0+, PHP 7.4+
|
||||
**License:** MIT
|
||||
**License:** GPLv2 or later
|
||||
|
||||
## Description
|
||||
|
||||
@@ -10,94 +10,82 @@ SEO API Bridge is a comprehensive WordPress plugin that exposes Rank Math SEO an
|
||||
|
||||
## What's New in 1.3.0
|
||||
|
||||
✨ **Full REST API Endpoints** - GET and POST operations for all post types
|
||||
✨ **Product SEO Support** - Complete WooCommerce product SEO management
|
||||
✨ **Simplified Integration** - Direct API calls instead of meta field manipulation
|
||||
✨ **Better Error Handling** - Proper validation and error responses
|
||||
- **Full REST API Endpoints** - GET and POST operations for all post types
|
||||
- **Product SEO Support** - Complete WooCommerce product SEO management
|
||||
- **Simplified Integration** - Direct API calls instead of meta field manipulation
|
||||
- **Better Error Handling** - Proper validation and error responses
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Rank Math SEO Support** - Full access to all Rank Math meta fields
|
||||
- ✅ **Yoast SEO Support** - Full access to all Yoast SEO meta fields
|
||||
- ✅ **WooCommerce Compatible** - Works with product post types
|
||||
- ✅ **Secure** - Requires proper authentication and edit_posts capability
|
||||
- ✅ **Auto-Detection** - Automatically detects which SEO plugin is active
|
||||
- ✅ **Health Check Endpoint** - NEW! Dedicated API endpoint for plugin detection
|
||||
- ✅ **Zero Configuration** - Works out of the box after activation
|
||||
- **Rank Math SEO Support** - Full access to all Rank Math meta fields
|
||||
- **Yoast SEO Support** - Full access to all Yoast SEO meta fields
|
||||
- **WooCommerce Compatible** - Works with product post types
|
||||
- **Secure** - Requires proper authentication and edit_posts capability
|
||||
- **Auto-Detection** - Automatically detects which SEO plugin is active
|
||||
- **Health Check Endpoint** - Dedicated API endpoint for plugin detection
|
||||
- **Zero Configuration** - Works out of the box after activation
|
||||
|
||||
## Supported SEO Fields
|
||||
|
||||
### Rank Math SEO
|
||||
|
||||
#### Core Fields
|
||||
- `rank_math_focus_keyword` - Focus keyword
|
||||
- `rank_math_seo_title` - Meta title
|
||||
- `rank_math_description` - Meta description
|
||||
- `rank_math_additional_keywords` - Additional keywords
|
||||
|
||||
#### Advanced Fields
|
||||
- `rank_math_canonical_url` - Canonical URL
|
||||
- `rank_math_robots` - Robots meta directives
|
||||
- `rank_math_breadcrumb_title` - Breadcrumb title
|
||||
|
||||
#### Open Graph (Facebook)
|
||||
- `rank_math_facebook_title` - OG title
|
||||
- `rank_math_facebook_description` - OG description
|
||||
- `rank_math_facebook_image` - OG image URL
|
||||
- `rank_math_facebook_image_id` - OG image ID
|
||||
|
||||
#### Twitter Card
|
||||
- `rank_math_twitter_title` - Twitter title
|
||||
- `rank_math_twitter_description` - Twitter description
|
||||
- `rank_math_twitter_image` - Twitter image URL
|
||||
- `rank_math_twitter_image_id` - Twitter image ID
|
||||
- `rank_math_twitter_card_type` - Card type (summary, summary_large_image)
|
||||
| Category | Field | Description |
|
||||
|----------|-------|-------------|
|
||||
| Core | `rank_math_focus_keyword` | Focus keyword |
|
||||
| Core | `rank_math_title` | Meta title |
|
||||
| Core | `rank_math_description` | Meta description |
|
||||
| Core | `rank_math_additional_keywords` | Additional keywords |
|
||||
| Advanced | `rank_math_canonical_url` | Canonical URL |
|
||||
| Advanced | `rank_math_robots` | Robots meta directives |
|
||||
| Advanced | `rank_math_breadcrumb_title` | Breadcrumb title |
|
||||
| Open Graph | `rank_math_facebook_title` | OG title |
|
||||
| Open Graph | `rank_math_facebook_description` | OG description |
|
||||
| Open Graph | `rank_math_facebook_image` | OG image URL |
|
||||
| Open Graph | `rank_math_facebook_image_id` | OG image ID |
|
||||
| Twitter | `rank_math_twitter_title` | Twitter title |
|
||||
| Twitter | `rank_math_twitter_description` | Twitter description |
|
||||
| Twitter | `rank_math_twitter_image` | Twitter image URL |
|
||||
| Twitter | `rank_math_twitter_image_id` | Twitter image ID |
|
||||
| Twitter | `rank_math_twitter_card_type` | Card type (summary, summary_large_image) |
|
||||
|
||||
### Yoast SEO
|
||||
|
||||
#### Core Fields
|
||||
- `_yoast_wpseo_focuskw` - Focus keyword
|
||||
- `_yoast_wpseo_title` - Meta title
|
||||
- `_yoast_wpseo_metadesc` - Meta description
|
||||
|
||||
#### Advanced Fields
|
||||
- `_yoast_wpseo_canonical` - Canonical URL
|
||||
- `_yoast_wpseo_meta-robots-noindex` - Noindex setting
|
||||
- `_yoast_wpseo_meta-robots-nofollow` - Nofollow setting
|
||||
- `_yoast_wpseo_bctitle` - Breadcrumb title
|
||||
|
||||
#### Open Graph
|
||||
- `_yoast_wpseo_opengraph-title` - OG title
|
||||
- `_yoast_wpseo_opengraph-description` - OG description
|
||||
- `_yoast_wpseo_opengraph-image` - OG image URL
|
||||
- `_yoast_wpseo_opengraph-image-id` - OG image ID
|
||||
|
||||
#### Twitter Card
|
||||
- `_yoast_wpseo_twitter-title` - Twitter title
|
||||
- `_yoast_wpseo_twitter-description` - Twitter description
|
||||
- `_yoast_wpseo_twitter-image` - Twitter image URL
|
||||
- `_yoast_wpseo_twitter-image-id` - Twitter image ID
|
||||
| Category | Field | Description |
|
||||
|----------|-------|-------------|
|
||||
| Core | `_yoast_wpseo_focuskw` | Focus keyword |
|
||||
| Core | `_yoast_wpseo_title` | Meta title |
|
||||
| Core | `_yoast_wpseo_metadesc` | Meta description |
|
||||
| Advanced | `_yoast_wpseo_canonical` | Canonical URL |
|
||||
| Advanced | `_yoast_wpseo_meta-robots-noindex` | Noindex setting |
|
||||
| Advanced | `_yoast_wpseo_meta-robots-nofollow` | Nofollow setting |
|
||||
| Advanced | `_yoast_wpseo_bctitle` | Breadcrumb title |
|
||||
| Open Graph | `_yoast_wpseo_opengraph-title` | OG title |
|
||||
| Open Graph | `_yoast_wpseo_opengraph-description` | OG description |
|
||||
| Open Graph | `_yoast_wpseo_opengraph-image` | OG image URL |
|
||||
| Open Graph | `_yoast_wpseo_opengraph-image-id` | OG image ID |
|
||||
| Twitter | `_yoast_wpseo_twitter-title` | Twitter title |
|
||||
| Twitter | `_yoast_wpseo_twitter-description` | Twitter description |
|
||||
| Twitter | `_yoast_wpseo_twitter-image` | Twitter image URL |
|
||||
| Twitter | `_yoast_wpseo_twitter-image-id` | Twitter image ID |
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: Manual Upload via WordPress Admin
|
||||
### Method 1: Upload via WordPress Admin
|
||||
|
||||
1. Download the plugin folder
|
||||
2. Create a ZIP file: `seo-api-bridge.zip`
|
||||
3. Go to WordPress Admin → Plugins → Add New → Upload Plugin
|
||||
4. Upload the ZIP file and click "Install Now"
|
||||
5. Click "Activate Plugin"
|
||||
1. Download `seo-api-bridge.zip` from [Releases](https://github.com/airano-ir/mcphub)
|
||||
2. Go to WordPress Admin > Plugins > Add New > Upload Plugin
|
||||
3. Upload the ZIP file and click "Install Now"
|
||||
4. Click "Activate Plugin"
|
||||
|
||||
### Method 2: FTP/SSH Upload
|
||||
|
||||
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/`
|
||||
2. Go to WordPress Admin → Plugins
|
||||
2. Go to WordPress Admin > Plugins
|
||||
3. Find "SEO API Bridge" and click "Activate"
|
||||
|
||||
### Method 3: WP-CLI (Recommended for Coolify)
|
||||
### Method 3: WP-CLI
|
||||
|
||||
```bash
|
||||
# Via SSH to your WordPress container
|
||||
cd /var/www/html/wp-content/plugins/
|
||||
# Copy the plugin folder here
|
||||
wp plugin activate seo-api-bridge
|
||||
@@ -105,55 +93,92 @@ wp plugin activate seo-api-bridge
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
All endpoints require **WordPress Application Password** authentication.
|
||||
|
||||
### Status Endpoint
|
||||
|
||||
**GET** `/wp-json/seo-bridge/v1/status`
|
||||
|
||||
Check plugin status and detected SEO plugins.
|
||||
**GET** `/wp-json/seo-api-bridge/v1/status`
|
||||
|
||||
```bash
|
||||
curl https://yoursite.com/wp-json/seo-bridge/v1/status
|
||||
curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/status" \
|
||||
-u "username:application_password"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"plugin": "SEO API Bridge",
|
||||
"version": "1.3.0",
|
||||
"active": true,
|
||||
"seo_plugins": {
|
||||
"rank_math": { "active": true, "version": "1.0.257" },
|
||||
"yoast": { "active": false, "version": null }
|
||||
},
|
||||
"supported_post_types": ["post", "page", "product"],
|
||||
"message": "SEO API Bridge is active and working with Rank Math SEO."
|
||||
}
|
||||
```
|
||||
|
||||
### Post SEO Endpoints
|
||||
|
||||
**GET** `/wp-json/seo-bridge/v1/posts/{id}/seo`
|
||||
**GET** `/wp-json/seo-api-bridge/v1/posts/{id}/seo` — Get SEO metadata for a post
|
||||
|
||||
Get SEO metadata for a post.
|
||||
|
||||
**POST** `/wp-json/seo-bridge/v1/posts/{id}/seo`
|
||||
|
||||
Update SEO metadata for a post.
|
||||
**POST** `/wp-json/seo-api-bridge/v1/posts/{id}/seo` — Update SEO metadata for a post
|
||||
|
||||
```bash
|
||||
curl -X POST https://yoursite.com/wp-json/seo-bridge/v1/posts/123/seo \
|
||||
# Get post SEO
|
||||
curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/posts/123/seo" \
|
||||
-u "username:application_password"
|
||||
|
||||
# Update post SEO
|
||||
curl -X POST "https://yoursite.com/wp-json/seo-api-bridge/v1/posts/123/seo" \
|
||||
-u "username:application_password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"focus_keyword": "wordpress", "seo_title": "My Title"}'
|
||||
```
|
||||
|
||||
### Page SEO Endpoints
|
||||
|
||||
**GET** `/wp-json/seo-bridge/v1/pages/{id}/seo`
|
||||
**GET** `/wp-json/seo-api-bridge/v1/pages/{id}/seo`
|
||||
|
||||
**POST** `/wp-json/seo-bridge/v1/pages/{id}/seo`
|
||||
**POST** `/wp-json/seo-api-bridge/v1/pages/{id}/seo`
|
||||
|
||||
### Product SEO Endpoints (WooCommerce)
|
||||
|
||||
**GET** `/wp-json/seo-bridge/v1/products/{id}/seo`
|
||||
**GET** `/wp-json/seo-api-bridge/v1/products/{id}/seo`
|
||||
|
||||
Get SEO metadata for a WooCommerce product.
|
||||
|
||||
**POST** `/wp-json/seo-bridge/v1/products/{id}/seo`
|
||||
|
||||
Update SEO metadata for a WooCommerce product.
|
||||
**POST** `/wp-json/seo-api-bridge/v1/products/{id}/seo`
|
||||
|
||||
```bash
|
||||
curl -X POST https://yoursite.com/wp-json/seo-bridge/v1/products/1217/seo \
|
||||
curl -X POST "https://yoursite.com/wp-json/seo-api-bridge/v1/products/1217/seo" \
|
||||
-u "username:application_password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"focus_keyword": "product keyword", "seo_title": "Product Title"}'
|
||||
```
|
||||
|
||||
## Usage with MCP Servers
|
||||
### Alternative: Standard WordPress REST API
|
||||
|
||||
The plugin also registers all SEO meta fields on standard WordPress REST API endpoints. You can read/write SEO data directly via:
|
||||
|
||||
- `GET/POST /wp-json/wp/v2/posts/{id}` — SEO fields in the `meta` object
|
||||
- `GET/POST /wp-json/wp/v2/pages/{id}`
|
||||
- `GET/POST /wp-json/wp/v2/products/{id}` (WooCommerce)
|
||||
|
||||
```bash
|
||||
# Read SEO fields via standard endpoint
|
||||
curl -X GET "https://yoursite.com/wp-json/wp/v2/posts/123" \
|
||||
-u "username:application_password"
|
||||
|
||||
# Update SEO via standard endpoint
|
||||
curl -X POST "https://yoursite.com/wp-json/wp/v2/posts/123" \
|
||||
-u "username:application_password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"meta": {"rank_math_focus_keyword": "wordpress optimization"}}'
|
||||
```
|
||||
|
||||
## Usage with MCP Hub
|
||||
|
||||
This plugin is designed to work with [MCP Hub](https://github.com/airano-ir/mcphub).
|
||||
|
||||
```javascript
|
||||
// Get product SEO
|
||||
@@ -172,244 +197,98 @@ await mcp.wordpress_update_product_seo({
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits of this approach:**
|
||||
1. ✅ **WordPress Best Practice** - Uses recommended core REST API functionality
|
||||
2. ✅ **Zero Maintenance** - Leverages WordPress built-in features
|
||||
3. ✅ **Universal Compatibility** - Works with all WordPress REST API clients
|
||||
4. ✅ **Inherits Security** - Uses WordPress authentication and permissions
|
||||
5. ✅ **Simpler Code** - No custom endpoints to maintain
|
||||
|
||||
**Standard Endpoints:**
|
||||
- Posts/Pages: `/wp-json/wp/v2/posts/{id}` or `/wp-json/wp/v2/pages/{id}`
|
||||
- Products: `/wp-json/wp/v2/products/{id}` (WooCommerce custom post type)
|
||||
|
||||
**SEO data is in the `meta` object:**
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"title": {"rendered": "My Post"},
|
||||
"meta": {
|
||||
"rank_math_focus_keyword": "wordpress seo",
|
||||
"rank_math_seo_title": "Complete SEO Guide",
|
||||
"rank_math_description": "Learn WordPress SEO best practices..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reading SEO Data
|
||||
|
||||
**Get Post with SEO Fields:**
|
||||
```bash
|
||||
GET /wp-json/wp/v2/posts/{id}
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"title": {"rendered": "My Post"},
|
||||
"meta": {
|
||||
"rank_math_focus_keyword": "wordpress seo",
|
||||
"rank_math_seo_title": "Complete WordPress SEO Guide",
|
||||
"rank_math_description": "Learn how to optimize your WordPress site...",
|
||||
"rank_math_facebook_title": "SEO Guide for Facebook",
|
||||
"rank_math_twitter_card_type": "summary_large_image"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Writing SEO Data
|
||||
|
||||
**Update Post SEO Fields:**
|
||||
```bash
|
||||
POST /wp-json/wp/v2/posts/{id}
|
||||
Authorization: Basic [base64(username:application_password)]
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"meta": {
|
||||
"rank_math_focus_keyword": "wordpress optimization",
|
||||
"rank_math_seo_title": "WordPress Optimization Tips",
|
||||
"rank_math_description": "Discover the best practices for WordPress optimization"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WooCommerce Products
|
||||
|
||||
**Get Product with SEO:**
|
||||
```bash
|
||||
GET /wp-json/wp/v2/products/{id}
|
||||
```
|
||||
|
||||
**Update Product SEO:**
|
||||
```bash
|
||||
POST /wp-json/wp/v2/products/{id}
|
||||
{
|
||||
"meta": {
|
||||
"rank_math_focus_keyword": "premium widget",
|
||||
"rank_math_description": "Buy the best premium widget online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage with MCP Server
|
||||
|
||||
This plugin is designed to work with the [Coolify Projects MCP Server](https://github.com/your-repo/mcphub).
|
||||
|
||||
### Example MCP Tool Usage
|
||||
|
||||
```python
|
||||
# Get post SEO data
|
||||
result = await mcp.call_tool(
|
||||
"wordpress_site1_get_post_seo",
|
||||
{"post_id": 123}
|
||||
"wordpress_get_post_seo",
|
||||
{"site": "yoursite", "post_id": 123}
|
||||
)
|
||||
|
||||
# Update post SEO
|
||||
result = await mcp.call_tool(
|
||||
"wordpress_site1_update_post_seo",
|
||||
"wordpress_update_post_seo",
|
||||
{
|
||||
"site": "yoursite",
|
||||
"post_id": 123,
|
||||
"focus_keyword": "wordpress seo",
|
||||
"seo_title": "Complete SEO Guide",
|
||||
"meta_description": "Learn WordPress SEO..."
|
||||
}
|
||||
)
|
||||
|
||||
# Update WooCommerce product SEO
|
||||
result = await mcp.call_tool(
|
||||
"wordpress_site1_update_product_seo",
|
||||
{
|
||||
"product_id": 456,
|
||||
"focus_keyword": "premium widget",
|
||||
"meta_description": "Buy the best widget"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Check if Plugin is Working
|
||||
1. **Admin Notice:** After activation, visit the Plugins page to see a status notice indicating which SEO plugin was detected.
|
||||
|
||||
1. **Admin Notice:** After activation, you should see a success notice indicating which SEO plugin was detected
|
||||
|
||||
2. **Health Check Endpoint (v1.1.0+):**
|
||||
2. **Status Endpoint:**
|
||||
```bash
|
||||
# Check plugin status directly
|
||||
curl -X GET "https://your-site.com/wp-json/seo-api-bridge/v1/status" \
|
||||
-u "username:application_password"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"plugin": "SEO API Bridge",
|
||||
"version": "1.1.0",
|
||||
"active": true,
|
||||
"seo_plugins": {
|
||||
"rank_math": {
|
||||
"active": true,
|
||||
"version": "1.0.257"
|
||||
},
|
||||
"yoast": {
|
||||
"active": false,
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
"supported_post_types": ["post", "page", "product"],
|
||||
"message": "SEO API Bridge is active and working with Rank Math SEO."
|
||||
}
|
||||
```
|
||||
|
||||
3. **REST API Test:**
|
||||
```bash
|
||||
# Get any post and check if meta fields are present
|
||||
curl -X GET "https://your-site.com/wp-json/wp/v2/posts/1" \
|
||||
-u "username:application_password"
|
||||
```
|
||||
|
||||
4. **MCP Health Check:** The MCP server will automatically detect the plugin using the new endpoint
|
||||
4. **MCP Health Check:** The MCP server automatically detects the plugin using the status endpoint.
|
||||
|
||||
## Security
|
||||
|
||||
- ✅ All meta fields require authentication via Application Passwords
|
||||
- ✅ Write access requires `edit_posts` capability
|
||||
- ✅ Read access follows WordPress post visibility rules
|
||||
- ✅ No sensitive data exposed without proper permissions
|
||||
- All meta fields require authentication via Application Passwords
|
||||
- Write access requires `edit_posts` capability
|
||||
- Read access follows WordPress post visibility rules
|
||||
- No sensitive data exposed without proper permissions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP Server Cannot Detect Plugin
|
||||
|
||||
**Issue:** MCP reports "SEO API Bridge plugin not detected" even though plugin is active
|
||||
|
||||
**Solution for v1.1.0+:**
|
||||
1. **Upgrade to v1.1.0** - This version adds a dedicated health check endpoint
|
||||
2. **Restart MCP server** - New detection logic will be used
|
||||
3. **Test endpoint directly:**
|
||||
```bash
|
||||
curl "https://your-site.com/wp-json/seo-api-bridge/v1/status" -u "user:pass"
|
||||
```
|
||||
|
||||
**Solution for v1.0.0 (legacy):**
|
||||
1. Create at least one post or product with SEO metadata set
|
||||
2. The old detection requires checking actual content meta fields
|
||||
3. Upgrade to v1.1.0 to avoid this requirement
|
||||
1. Upgrade to v1.1.0+ (has dedicated health check endpoint)
|
||||
2. Restart MCP server
|
||||
3. Test endpoint: `curl "https://your-site.com/wp-json/seo-api-bridge/v1/status" -u "user:pass"`
|
||||
|
||||
### SEO Plugin Not Detected
|
||||
|
||||
**Issue:** Admin notice says "Neither Rank Math SEO nor Yoast SEO is detected"
|
||||
|
||||
**Solution:**
|
||||
1. Verify Rank Math or Yoast SEO is installed and activated
|
||||
2. Try deactivating and reactivating SEO API Bridge
|
||||
3. Check PHP error logs for any warnings
|
||||
2. Deactivate and reactivate SEO API Bridge
|
||||
3. Check PHP error logs for warnings
|
||||
|
||||
### Meta Fields Not Appearing in REST API
|
||||
|
||||
**Issue:** `/wp-json/wp/v2/posts/{id}` doesn't show `meta` object
|
||||
|
||||
**Solution:**
|
||||
1. Ensure you're authenticated (use Application Password)
|
||||
2. Check that you have `edit_posts` permission
|
||||
2. Check user has `edit_posts` permission
|
||||
3. Verify the post type is supported (post, page, product)
|
||||
|
||||
### Fields Are Read-Only
|
||||
|
||||
**Issue:** Can read SEO fields but cannot update them
|
||||
|
||||
**Solution:**
|
||||
1. Verify authentication credentials
|
||||
2. Check user has `edit_posts` capability
|
||||
3. Ensure you're using POST/PUT request, not GET
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ WordPress 5.0+
|
||||
- ✅ PHP 7.4+
|
||||
- ✅ Rank Math SEO 1.0+
|
||||
- ✅ Yoast SEO 14.0+
|
||||
- ✅ WooCommerce 5.0+ (for product support)
|
||||
- ✅ Classic Editor & Gutenberg
|
||||
- ✅ Multisite compatible
|
||||
|
||||
## Support
|
||||
|
||||
For issues related to:
|
||||
- **This plugin:** [GitHub Issues](https://github.com/your-repo/mcphub/issues)
|
||||
- **MCP Server:** [MCP Server Documentation](https://github.com/your-repo/mcphub)
|
||||
- **Rank Math SEO:** [Rank Math Support](https://rankmath.com/support/)
|
||||
- **Yoast SEO:** [Yoast Support](https://yoast.com/help/)
|
||||
- WordPress 5.0+
|
||||
- PHP 7.4+
|
||||
- Rank Math SEO 1.0+
|
||||
- Yoast SEO 14.0+
|
||||
- WooCommerce 5.0+ (for product support)
|
||||
- Classic Editor & Gutenberg
|
||||
- Multisite compatible
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.3.0 (2025-02-18)
|
||||
- Added REST API endpoints for posts, pages, and products (GET/POST operations)
|
||||
- Added authentication requirement for all endpoints
|
||||
- Fixed `rank_math_title` meta key registration
|
||||
- Fixed output escaping for WordPress.org compliance
|
||||
|
||||
### 1.2.0 (2025-02-15)
|
||||
- Enhanced WooCommerce product support
|
||||
- Improved MariaDB compatibility for meta field queries
|
||||
|
||||
### 1.1.0 (2025-01-10)
|
||||
- 🎉 **NEW:** Health check REST API endpoint `/seo-api-bridge/v1/status`
|
||||
- 🎉 **NEW:** Direct plugin detection without requiring posts/products
|
||||
- 🎉 **IMPROVEMENT:** Better compatibility with sites that have no content
|
||||
- 🎉 **IMPROVEMENT:** Returns SEO plugin versions in status endpoint
|
||||
- 🎉 **FIX:** MCP server can now detect plugin even with zero posts
|
||||
- Added health check REST API endpoint `/seo-api-bridge/v1/status`
|
||||
- Direct plugin detection without requiring posts/products
|
||||
- Better compatibility with sites that have no content
|
||||
- Returns SEO plugin versions in status endpoint
|
||||
|
||||
### 1.0.0 (2025-01-10)
|
||||
- Initial release
|
||||
@@ -417,12 +296,14 @@ For issues related to:
|
||||
- Yoast SEO support (15 meta fields)
|
||||
- WooCommerce product support
|
||||
- Auto-detection of active SEO plugins
|
||||
- Admin notices for status
|
||||
|
||||
## Support
|
||||
|
||||
- **Plugin issues:** [GitHub Issues](https://github.com/airano-ir/mcphub/issues)
|
||||
- **MCP Server:** [MCP Hub Documentation](https://github.com/airano-ir/mcphub)
|
||||
- **Rank Math SEO:** [Rank Math Support](https://rankmath.com/support/)
|
||||
- **Yoast SEO:** [Yoast Support](https://yoast.com/help/)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
## Credits
|
||||
|
||||
Developed for use with Coolify Projects MCP Server to enable AI-powered SEO content management.
|
||||
GPLv2 or later - See [LICENSE](https://www.gnu.org/licenses/gpl-2.0.html) for details
|
||||
|
||||
82
wordpress-plugin/seo-api-bridge/readme.txt
Normal file
82
wordpress-plugin/seo-api-bridge/readme.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
=== SEO API Bridge ===
|
||||
Contributors: airano
|
||||
Tags: seo, rest-api, rank-math, yoast, mcp
|
||||
Requires at least: 5.0
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.3.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents.
|
||||
|
||||
== Description ==
|
||||
|
||||
SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
|
||||
|
||||
**Features:**
|
||||
|
||||
* **Rank Math SEO Support** — Full access to all Rank Math meta fields
|
||||
* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
|
||||
* **WooCommerce Compatible** — Works with product post types
|
||||
* **Secure** — Requires WordPress Application Password and edit_posts capability
|
||||
* **Auto-Detection** — Automatically detects which SEO plugin is active
|
||||
* **Status Endpoint** — Dedicated API endpoint for plugin and SEO detection
|
||||
* **Zero Configuration** — Works out of the box after activation
|
||||
|
||||
**REST API Endpoints:**
|
||||
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/posts/{id}/seo` — Post SEO data
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/pages/{id}/seo` — Page SEO data
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||
* `GET /wp-json/seo-api-bridge/v1/status` — Plugin status and SEO detection
|
||||
|
||||
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/`
|
||||
2. Activate the plugin through the 'Plugins' menu in WordPress
|
||||
3. Ensure either Rank Math SEO or Yoast SEO is active
|
||||
4. The REST API endpoints are now available
|
||||
|
||||
== Frequently Asked Questions ==
|
||||
|
||||
= Which SEO plugins are supported? =
|
||||
|
||||
Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
|
||||
|
||||
= Does it work with WooCommerce? =
|
||||
|
||||
Yes. Product SEO endpoints are available when WooCommerce is active.
|
||||
|
||||
= How is authentication handled? =
|
||||
|
||||
All endpoints require WordPress Application Password authentication and the `edit_posts` capability. POST requests require the same capability.
|
||||
|
||||
= Can I use this without MCP Hub? =
|
||||
|
||||
Yes. The REST API endpoints work with any application that can make HTTP requests with proper WordPress authentication.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.3.0 =
|
||||
* Added REST API endpoints for posts, pages, and products (GET/POST operations)
|
||||
* Added authentication requirement for all endpoints
|
||||
* Fixed rank_math_title meta key registration
|
||||
|
||||
= 1.2.0 =
|
||||
* Enhanced WooCommerce product support
|
||||
* Improved MariaDB compatibility
|
||||
|
||||
= 1.1.0 =
|
||||
* Added status endpoint for plugin detection
|
||||
* Improved error handling
|
||||
|
||||
= 1.0.0 =
|
||||
* Initial release with Rank Math and Yoast SEO support
|
||||
|
||||
== Upgrade Notice ==
|
||||
|
||||
= 1.3.0 =
|
||||
GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: SEO API Bridge
|
||||
* Plugin URI: https://github.com/your-repo/seo-api-bridge
|
||||
* Plugin URI: https://github.com/airano-ir/mcphub
|
||||
* Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations.
|
||||
* Version: 1.3.0
|
||||
* Author: MCP Coolify Projects
|
||||
* Author URI: https://github.com/your-repo
|
||||
* License: MIT
|
||||
* Author: MCP Hub
|
||||
* Author URI: https://github.com/airano-ir
|
||||
* License: GPL-2.0-or-later
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Text Domain: seo-api-bridge
|
||||
@@ -70,7 +70,9 @@ class SEO_API_Bridge {
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_post_seo'],
|
||||
'permission_callback' => '__return_true',
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('edit_posts');
|
||||
},
|
||||
'args' => [
|
||||
'id' => [
|
||||
'validate_callback' => function($param) {
|
||||
@@ -100,7 +102,9 @@ class SEO_API_Bridge {
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_page_seo'],
|
||||
'permission_callback' => '__return_true',
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('edit_posts');
|
||||
},
|
||||
'args' => [
|
||||
'id' => [
|
||||
'validate_callback' => function($param) {
|
||||
@@ -130,7 +134,9 @@ class SEO_API_Bridge {
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_product_seo'],
|
||||
'permission_callback' => '__return_true',
|
||||
'permission_callback' => function() {
|
||||
return current_user_can('edit_posts');
|
||||
},
|
||||
'args' => [
|
||||
'id' => [
|
||||
'validate_callback' => function($param) {
|
||||
@@ -246,7 +252,7 @@ class SEO_API_Bridge {
|
||||
'description' => 'Rank Math focus keyword',
|
||||
'single' => true,
|
||||
],
|
||||
'rank_math_seo_title' => [
|
||||
'rank_math_title' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Rank Math SEO title (meta title)',
|
||||
'single' => true,
|
||||
@@ -668,13 +674,19 @@ class SEO_API_Bridge {
|
||||
* Display admin notices
|
||||
*/
|
||||
public function admin_notices() {
|
||||
// Only show notices on the Plugins page to avoid clutter on every admin page
|
||||
$screen = get_current_screen();
|
||||
if ( ! $screen || $screen->id !== 'plugins' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rank_math_active = $this->is_rank_math_active();
|
||||
$yoast_active = $this->is_yoast_active();
|
||||
$woocommerce_active = $this->is_woocommerce_active();
|
||||
|
||||
if (!$rank_math_active && !$yoast_active) {
|
||||
echo '<div class="notice notice-warning is-dismissible">';
|
||||
echo '<p><strong>SEO API Bridge:</strong> Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.</p>';
|
||||
echo '<p><strong>SEO API Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'seo-api-bridge' ) . '</p>';
|
||||
echo '</div>';
|
||||
} else {
|
||||
$active_plugins = [];
|
||||
@@ -684,11 +696,11 @@ class SEO_API_Bridge {
|
||||
$supported_types = implode(', ', $this->supported_post_types);
|
||||
|
||||
echo '<div class="notice notice-success is-dismissible">';
|
||||
echo '<p><strong>SEO API Bridge v' . self::VERSION . ':</strong> Successfully registered meta fields for ' . implode(' and ', $active_plugins) . '.</p>';
|
||||
echo '<p><strong>Supported post types:</strong> ' . $supported_types . '</p>';
|
||||
echo '<p><strong>SEO API Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
|
||||
echo '<p><strong>' . esc_html__( 'Supported post types:', 'seo-api-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
||||
|
||||
if ($woocommerce_active) {
|
||||
echo '<p><strong>WooCommerce:</strong> Detected and supported. Product SEO fields are available via REST API.</p>';
|
||||
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'seo-api-bridge' ) . '</p>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user