Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec6c006235 | |||
| 331d756851 | |||
| 349913ca2d | |||
| 43fd2201a0 | |||
| f203ca88de | |||
| 788439e377 | |||
| d8a0805412 | |||
| 3fc02b5734 | |||
| d7e3946e11 | |||
| 151ba8e6e6 | |||
| bf893a743a | |||
| 2c9d4ed3d2 | |||
| 4f0c2f9bb0 | |||
| d79c2fd94a | |||
| 1615daf1a7 | |||
| c47ec7fd28 | |||
| 3815f5a6d5 | |||
| 81417b552f | |||
| 75cac1cb4d | |||
| 12b06ce66f | |||
| 9905a28eb0 | |||
| 68c84b3ece | |||
| 9b70b259bb | |||
|
|
f6dbbeaab0 | ||
|
|
c0d55f9061 | ||
|
|
a806671e2d | ||
|
|
4a5381d765 | ||
|
|
fdb050454e | ||
|
|
ea51c10e03 | ||
|
|
479d0c7252 | ||
|
|
d3bcb31053 | ||
|
|
1736779d69 | ||
|
|
983d93c2a7 | ||
|
|
80377a1a22 | ||
|
|
684c889c27 | ||
|
|
55d30ba876 | ||
|
|
eceba04578 | ||
|
|
1fcc539093 | ||
|
|
9e6f06d933 | ||
|
|
42ea75bcb3 | ||
|
|
647a650629 | ||
|
|
c57fd666c9 | ||
|
|
7a16a260f1 | ||
|
|
f29dc299b2 | ||
|
|
5d97b31c12 |
65
.env.example
65
.env.example
@@ -2,24 +2,27 @@
|
|||||||
# MCP Hub - Environment Configuration
|
# MCP Hub - Environment Configuration
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
#
|
#
|
||||||
# Version: 3.0.0
|
# Version: 3.4.0
|
||||||
# Last Updated: 2026-02-17
|
# Last Updated: 2026-04-02
|
||||||
#
|
#
|
||||||
# This file contains all environment variables needed to run the MCP server.
|
# This file contains environment variables needed to run the MCP server.
|
||||||
# Copy this file to .env and fill in your actual values.
|
# Copy this file to .env and fill in your actual values.
|
||||||
#
|
#
|
||||||
|
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
|
||||||
|
# The plugin sections below are kept as credential reference only.
|
||||||
|
#
|
||||||
# SECURITY NOTE: Never commit .env file to version control!
|
# SECURITY NOTE: Never commit .env file to version control!
|
||||||
#
|
#
|
||||||
# Multi-Endpoint Architecture (v3.0.0):
|
# Multi-Endpoint Architecture (v3.0.0):
|
||||||
# - /mcp → Admin (all 587 tools, Master API Key required)
|
# - /mcp → Admin (all 565 tools, Master API Key required)
|
||||||
# - /system/mcp → System (17 tools, Master Key)
|
# - /system/mcp → System (17 tools, Master Key)
|
||||||
# - /wordpress/mcp → WordPress Core (65 tools)
|
# - /wordpress/mcp → WordPress Core (65 tools)
|
||||||
# - /woocommerce/mcp → WooCommerce (28 tools)
|
# - /woocommerce/mcp → WooCommerce (28 tools)
|
||||||
# - /wordpress-advanced/mcp → WordPress Advanced (22 tools)
|
# - /wordpress-specialist/mcp → WordPress Specialist (51 tools)
|
||||||
# - /gitea/mcp → Gitea (56 tools)
|
# - /gitea/mcp → Gitea (56 tools)
|
||||||
# - /n8n/mcp → n8n Automation (56 tools)
|
# - /n8n/mcp → n8n Automation (56 tools)
|
||||||
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
||||||
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
|
# - /openpanel/mcp → OpenPanel Analytics (42 tools)
|
||||||
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
||||||
# - /directus/mcp → Directus CMS (100 tools)
|
# - /directus/mcp → Directus CMS (100 tools)
|
||||||
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
||||||
@@ -215,39 +218,31 @@ WORDPRESS_SITE1_CONTAINER=coolify-wp-site1 # For WP-CLI access (optional)
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# WORDPRESS ADVANCED PLUGIN
|
# WORDPRESS SPECIALIST PLUGIN
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
#
|
#
|
||||||
# WordPress Advanced provides 22 advanced management tools:
|
# WordPress Specialist provides 51 management tools across read / editor /
|
||||||
# - Database operations (7 tools): export, import, search, query, repair
|
# settings / install / admin tiers. Companion-backed (Airano MCP Bridge
|
||||||
# - Bulk operations (8 tools): batch updates/deletes for posts, products, media
|
# v2.18.0+ — install wordpress-plugin/airano-mcp-bridge.zip on the WP
|
||||||
# - System operations (7 tools): system info, cache, cron, error logs
|
# site). No Docker socket needed; replaced the deprecated wordpress_advanced
|
||||||
|
# plugin (sunset 2026-05-04 in F.19.3.2-.3).
|
||||||
#
|
#
|
||||||
# REQUIRED: Docker container name for WP-CLI access
|
# Tool surface:
|
||||||
# NOTE: WordPress Advanced has the SAME configuration pattern as WordPress
|
# - Read management (9 tools): plugins / themes / users / options / cron
|
||||||
# but requires 'container' configuration for WP-CLI access.
|
# / maintenance status / system_info / phpinfo / disk_usage
|
||||||
|
# - Page editing (11 tools): Gutenberg blocks + Elementor + Classic
|
||||||
|
# - Theme dev (7 tools): install / activate / delete / file CRUD
|
||||||
|
# - Plugin write (6 tools): install / activate / deactivate / update / delete
|
||||||
|
# - Site config (6 tools): identity / reading / permalinks
|
||||||
|
# - Site layout (7 tools): menus / widgets / customizer
|
||||||
|
# - Database inspection (3 tools): db_size / db_tables / db_search
|
||||||
|
# (no SQL exposure; db_search wraps WP_Query)
|
||||||
|
# - Bulk fan-out (2 tools): bulk_post_update / bulk_term_update (50-item
|
||||||
|
# cap, concurrency=10)
|
||||||
#
|
#
|
||||||
# Security Benefits:
|
# Sites are managed via the web dashboard (DB-based), not env vars.
|
||||||
# - Separate API keys for advanced vs basic operations
|
# This section is documentation only — there are no env-var stanzas to
|
||||||
# - Better tool visibility (basic users don't see advanced tools)
|
# fill in for wordpress_specialist sites.
|
||||||
# - Granular access control
|
|
||||||
#
|
|
||||||
|
|
||||||
# Site 1 Advanced Configuration
|
|
||||||
WORDPRESS_ADVANCED_SITE1_URL=https://example1.com
|
|
||||||
WORDPRESS_ADVANCED_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=your_app_password_here
|
|
||||||
WORDPRESS_ADVANCED_SITE1_ALIAS=myblog
|
|
||||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp-site1 # REQUIRED for advanced features
|
|
||||||
|
|
||||||
# Site 2 Advanced Configuration
|
|
||||||
#WORDPRESS_ADVANCED_SITE2_URL=https://example2.com
|
|
||||||
#WORDPRESS_ADVANCED_SITE2_USERNAME=admin
|
|
||||||
#WORDPRESS_ADVANCED_SITE2_APP_PASSWORD=your_app_password_here
|
|
||||||
#WORDPRESS_ADVANCED_SITE2_ALIAS=mystore
|
|
||||||
#WORDPRESS_ADVANCED_SITE2_CONTAINER=coolify-wp-site2
|
|
||||||
|
|
||||||
# Add more sites as needed (wordpress_advanced_site3, wordpress_advanced_site4, etc.)
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# LOGGING & MONITORING
|
# LOGGING & MONITORING
|
||||||
|
|||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -14,6 +14,14 @@ build/
|
|||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
|
||||||
|
# Track G — preserve the SPA dist directory placeholder (so package_data can
|
||||||
|
# pick it up) but ignore the actual build output, which is regenerated by
|
||||||
|
# `cd web && npm run build` or the Dockerfile's frontend stage.
|
||||||
|
!core/templates/static/dist/
|
||||||
|
!core/templates/static/dist/.gitkeep
|
||||||
|
core/templates/static/dist/*
|
||||||
|
!core/templates/static/dist/.gitkeep
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
@@ -28,6 +36,8 @@ ENV/
|
|||||||
.coverage.*
|
.coverage.*
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
|
pytest-out.txt
|
||||||
|
scripts/community-build/BUILD_REPORT.md
|
||||||
.nox/
|
.nox/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
@@ -159,3 +169,26 @@ pytest-cache-files-*/
|
|||||||
# Project specific
|
# Project specific
|
||||||
# ====================================
|
# ====================================
|
||||||
# Add project-specific ignores here
|
# Add project-specific ignores here
|
||||||
|
.worktrees/
|
||||||
|
worktrees/
|
||||||
|
/.agents
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# planning-with-files skill artifacts (session-local)
|
||||||
|
task_plan.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
|
||||||
|
# wp.org SVN working copies (separate VCS, not for git):
|
||||||
|
# - airano-mcp-bridge-wporg-svn/ → new slug `airano-mcp-bridge` (current)
|
||||||
|
# - airano-mcp-seo-bridge-svn/ → old slug `airano-mcp-seo-bridge` (deprecated, migration release lives here)
|
||||||
|
# - airano-mcp-bridge-svn/ → legacy stub from earlier exploration
|
||||||
|
wordpress-plugin/airano-mcp-bridge-svn/
|
||||||
|
wordpress-plugin/airano-mcp-bridge-wporg-svn/
|
||||||
|
wordpress-plugin/airano-mcp-seo-bridge-svn/
|
||||||
|
|
||||||
|
# Screenshot harness auth state — never commit (contains session cookie)
|
||||||
|
screenshots/.auth-state.json
|
||||||
|
screenshots/_*.json
|
||||||
|
# wp.org SVN working copy for the airano-mcp-bridge plugin (separate VCS, not for git)
|
||||||
|
wordpress-plugin/airano-mcp-bridge-svn/
|
||||||
|
|||||||
323
CHANGELOG.md
323
CHANGELOG.md
@@ -5,6 +5,329 @@ All notable changes to MCP Hub will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.12.0] — 2026-04-25
|
||||||
|
|
||||||
|
### Media pipeline, AI image generation, capability probe, companion v2.9.0 (Tracks F.5a + F.7e + F.18)
|
||||||
|
|
||||||
|
The biggest release since v3.11.0 — three months of incremental work batched into one community drop. Core themes:
|
||||||
|
|
||||||
|
- **WordPress media pipeline**: end-to-end image flow with optimization, AI generation, and chunked uploads.
|
||||||
|
- **Capability discovery**: every site now publishes what it can actually do, and the dashboard reflects it.
|
||||||
|
- **Companion plugin overhaul**: `airano-mcp-bridge` (renamed from `airano-mcp-seo-bridge`) gained eight new endpoints and a wp.org Plugin Check pass.
|
||||||
|
- **Reliability polish**: idempotent retries, fast-fail on unreachable sites, install hints in every error.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **AI image generation** (`F.5a.4` / `F.5a.9`): pluggable provider system (OpenAI, Stability, Replicate, Google Nano Banana, OpenRouter image models) → `wordpress_generate_and_upload_image`, attached as featured media or to WC products in one call.
|
||||||
|
- **Pillow-based image optimization pipeline** (`F.5a.2`): client-side resize / format conversion / quality knobs before upload.
|
||||||
|
- **Chunked media upload** (`F.5a.5`): large files uploaded in resumable chunks; `upload_media_chunked_status` lets you resume after a disconnect (`F.5a.8.4`).
|
||||||
|
- **Bulk media tools** (`F.5a.8.3`): `bulk_delete_media` and `bulk_reassign_media`.
|
||||||
|
- **Idempotency-Key dedup** for AI media upload retries — same key returns the previous result instead of double-uploading.
|
||||||
|
- **Companion v2.9.0** (`airano-mcp-bridge`):
|
||||||
|
- `/upload-and-attach` — single-call combined endpoint (`F.5a.8.5`).
|
||||||
|
- `/regenerate-thumbnails` (`F.5a.8.2`).
|
||||||
|
- `/capabilities` + `wordpress_probe_capabilities` (`F.18.1`).
|
||||||
|
- `/bulk-meta` + `bulk_update_meta` (`F.18.2`).
|
||||||
|
- `/export` + `export_content` (`F.18.3`).
|
||||||
|
- `/cache-purge` + `cache_purge` (`F.18.4`).
|
||||||
|
- `/transient-flush` + `transient_flush` (`F.18.5`).
|
||||||
|
- `/site-health` + `site_health` (`F.18.6`).
|
||||||
|
- `/audit-hook` + MCPHub webhook receiver (`F.18.7`).
|
||||||
|
- **Provider Keys dashboard** (`F.18.8`): per-site AI provider key management UI with no-reload UX; AI tool visibility is now gated on per-site provider key presence.
|
||||||
|
- **Credential capability probe** (`F.7e`): WordPress / WooCommerce / Gitea adapters report what tier of operations the saved credential can actually perform; capability badge UI + HTMX partial re-check on the site manage page.
|
||||||
|
- **Tier requirements engine**: `TIER_REQUIREMENTS` + `evaluate_tier_fit(granted ∪ roles)` so alias matching unions both sources.
|
||||||
|
- **Companion install hint** in every companion-unreachable error (`F.7e`).
|
||||||
|
- **Gitea ergonomics** (`F.17`): batch file ops, tree, search, compare, releases, fork shortcuts.
|
||||||
|
- **Opportunistic bcrypt upgrade** for legacy SHA-256 admin keys (`F.8`).
|
||||||
|
- **n8n refactor**: structured errors, capability probe, missing tools backfilled.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **`get_post`** returns `slug`, `featured_media`, `featured_media_url` by default.
|
||||||
|
- **`get_media` / `list_media`** expose `post_parent`.
|
||||||
|
- **WordPress client** fast-fails on unreachable sites with an `install_hint`.
|
||||||
|
- **Rank Math** integration uses canonical `rank_math_title` instead of the older `rank_math_seo_title`.
|
||||||
|
- **Tool prerequisites** surfaced in the dashboard; tool list shows the expected provider/credential per tier.
|
||||||
|
- **WooCommerce probe** infers capabilities from REST key scope.
|
||||||
|
- **Default model** + dark background polish across dashboard surfaces.
|
||||||
|
- **Companion route map** is single-source (`capabilities` and `site_health` share their definitions).
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **AI provider gating**: per-site visibility now requires a present provider key; surfaces correctly hide tools when keys are absent.
|
||||||
|
- **Capability badge re-check** is an HTMX partial swap (no full reload).
|
||||||
|
- **WC media tools** accept WordPress App Password and global attribute id.
|
||||||
|
- **`set_featured_image` for WC products**, AI image attach to WC products, provider enum narrowing, WC badge wording.
|
||||||
|
- **Companion `airano-mcp-bridge` security**: scheme allowlist on the audit-hook endpoint URL; wp.org Plugin Check warnings (i18n + `WP_Filesystem`) cleared.
|
||||||
|
|
||||||
|
#### Deployment
|
||||||
|
- **PyPI mirror chain** + removed dead hardcoded proxy.
|
||||||
|
- **Optional `BUILD_HTTP_PROXY`** for restrictive build networks.
|
||||||
|
- Alpine `apk` swapped to Yandex mirror; **Debian-slim Plan-B** Dockerfile added.
|
||||||
|
- `mirror.gcr.io` variant for Docker Hub TLS-timeout fallback.
|
||||||
|
- Gitea mirror documented as fallback deploy source.
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- README: dropped fixed "633 tools" claims in favour of per-plugin approximations + a note that the count grows with each release.
|
||||||
|
- CLAUDE.md: total tool count is no longer asserted as a fixed number.
|
||||||
|
|
||||||
|
#### Companion plugin
|
||||||
|
- **Renamed**: `airano-mcp-seo-bridge` → `airano-mcp-bridge` (the plugin now does much more than SEO).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.11.0] — 2026-04-14
|
||||||
|
|
||||||
|
### Plugin-specific access levels, regression tests, UX polish (Track F.7d)
|
||||||
|
|
||||||
|
Polish pass on top of v3.10.0 tool-access hardening. Access levels and credential guidance are now tailored per plugin; UX signals have been tightened.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Plugin-aware scope presets** (`core/tool_access.py::get_scope_presets_for_plugin`): WordPress / WordPress Advanced / WooCommerce / Gitea / Coolify (5 tiers) / OpenPanel — each with tailored EN+FA labels and one-line hints. Custom preset always available.
|
||||||
|
- **Admin Tools card** on per-service dashboard page (4-col grid when `admin` tools > 0).
|
||||||
|
- **Credential requirement notice** on Tool Access: describes exactly what the saved Credential must grant, per plugin/tier (WP Application Password, WooCommerce REST key scope, Gitea scoped tokens, OpenPanel client mode, Coolify API token permissions).
|
||||||
|
- **Auto-scoped quick key** on the Connect section (site_id injected → key capped to this site's tier).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- Scope buttons rendered dynamically from `scope_presets_json` (was: hardcoded 4 tiers).
|
||||||
|
- WordPress / WP Advanced / WooCommerce: dropped redundant `write` tier (admin-scope tool count = 0) — now **Read Only + Full Access + Custom**.
|
||||||
|
- Coolify tool list visually filtered by category for all 5 tiers in JS (mirrors `SCOPE_TO_CATEGORIES`).
|
||||||
|
- OpenPanel Client ID hint clarifies the correct dashboard path (`{org}/{project-id}/settings/clients`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.10.0] — 2026-04-12
|
||||||
|
|
||||||
|
### Tool access hardening + per-site API keys + Tailwind build (Track F.7c)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Per-site API keys** (DB migration v8 adds `site_id` column): UI site selector in create modal; endpoint enforces site-scoped key access.
|
||||||
|
- **Configuration Snippet section** on keys page (transport / WP / bearer notes).
|
||||||
|
- **Pre-built Tailwind CSS** via `scripts/build-css.sh` (replaces CDN).
|
||||||
|
- **Unified site manage page**: collapsible Connection / Tool Access / Connect sections.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **CSRF bug**: removed conflicting `getCsrf()` that tried to read an `httponly` cookie; now relies on the global meta-tag CSRF interceptor in `head_assets.html`.
|
||||||
|
- **Tool scope UI**: tools outside the selected scope are visually dimmed; summary shows in-scope count.
|
||||||
|
- Sidebar logo hidden when collapsed.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **828 tests passing** (was: 766).
|
||||||
|
- `/dashboard/connect` and `/dashboard/edit` redirect to unified site page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.9.0] — 2026-04-09
|
||||||
|
|
||||||
|
### Tool access UI + unified keys page (Track F.7b)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- `sites/edit`: Tool Access card with scope dropdown + per-tool toggle grid.
|
||||||
|
- `sites/view`: new `/dashboard/sites/{id}` page with MCP URL + config snippets.
|
||||||
|
- `/dashboard/keys`: unified page (user view with scope selector, admin view).
|
||||||
|
- Sidebar: "Connect" replaced with "API Keys" → `/dashboard/keys`.
|
||||||
|
- `sites/list`: added "Connect" link per site row.
|
||||||
|
- 12 new tests (keys unified + tool access UI smoke).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Site-scoped tool access** (schema v7): dropped `user_tool_toggles` in favour of `site_tool_toggles(site_id)`; added `sites.tool_scope` column (default `admin`). Resolves the limitation that a user with multiple sites of the same plugin (e.g. Coolify production + staging) needed independent tool filters.
|
||||||
|
- `ToolAccessManager` now takes `site_id`; `get_visible_tools` intersects key-scope categories with the site's `tool_scope` preset, then applies `site_tool_toggles` overrides.
|
||||||
|
- Dashboard API routes moved under `/api/sites/{site_id}/...`.
|
||||||
|
- `/dashboard/connect` → `/dashboard/keys` (301); `/dashboard/api-keys` → `/dashboard/keys` (301).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.0] — 2026-04-07
|
||||||
|
|
||||||
|
### Coolify plugin Phase 2+3 — projects, databases, services (Track F.17)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Coolify projects handler** (8 tools): CRUD projects + environments.
|
||||||
|
- **Coolify databases handler** (16 tools): CRUD, lifecycle, 6 DB types, backups.
|
||||||
|
- **Coolify services handler** (13 tools): CRUD, lifecycle, env vars.
|
||||||
|
- **Scope-based tool visibility + per-user toggles** (Track F.7 core): every `ToolDefinition` now carries `category` + `sensitivity` fields. Extends the scope model with `deploy` and `read:sensitive`. `tools/list` filters by the API key's scopes via `SCOPE_TO_CATEGORIES` in `core/tool_access.py`. Coolify handlers are annotated; other plugins keep the default `read` category (backward compatible).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Coolify plugin total: 67 tools** (was 30). Platform total: **633 tools**.
|
||||||
|
- **766 tests passing** (48 new).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.0] — 2026-04-04
|
||||||
|
|
||||||
|
### Coolify MCP plugin — Phase 1 MVP (Track F.17)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Coolify plugin** (30 tools, admin-only):
|
||||||
|
- **Applications** (17): CRUD, lifecycle (start/stop/restart), logs, env vars.
|
||||||
|
- **Deployments** (5): list, get, cancel, deploy by tag/UUID, app history.
|
||||||
|
- **Servers** (8): CRUD, resources, domains, validation.
|
||||||
|
- Coolify credential fields added to `site_api` (URL + API token), with health check.
|
||||||
|
- Registered in `server.py` startup so Coolify tools show up in `/coolify/mcp`.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- Platform plugin count: **10** (added Coolify). Tool count: **597**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.0] — 2026-04-02
|
||||||
|
|
||||||
|
### Gitea Plugin — Public Release (Track F.16)
|
||||||
|
|
||||||
|
Gitea plugin fully reviewed, tested, and published for public use. Two missing tools added, comprehensive test suite created.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **`update_webhook` tool**: Update existing webhook configuration, events, and active status (admin scope)
|
||||||
|
- **`delete_label` tool**: Delete labels from repositories (write scope)
|
||||||
|
- **`tests/test_gitea_plugin.py`**: Comprehensive test suite — 85+ tests covering client init, headers, tool specs, all 5 handler groups, health check, and plugin delegation
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **Gitea plugin now public**: Added `gitea` to `DEFAULT_PUBLIC_PLUGINS` — available to all OAuth users
|
||||||
|
- **Tool count**: 565 → 567 (added update_webhook + delete_label)
|
||||||
|
- **env.example**: Updated default `ENABLED_PLUGINS` to include `gitea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.5.0] — 2026-04-02
|
||||||
|
|
||||||
|
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
|
||||||
|
|
||||||
|
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
|
||||||
|
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
|
||||||
|
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
|
||||||
|
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
|
||||||
|
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
|
||||||
|
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.4.0] — 2026-03-31
|
||||||
|
|
||||||
|
### OpenPanel Plugin — Public Release (Track F.10)
|
||||||
|
|
||||||
|
OpenPanel product analytics plugin fully reviewed, tested, and published for public use. Works with both self-hosted and cloud (openpanel.dev) instances.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **OpenPanel Plugin** (42 tools): Complete rewrite using public REST APIs — no tRPC/session dependency
|
||||||
|
- **Track API** (11 tools): track_event, page_view, screen_view, identify_user, set_user_properties, increment/decrement_property, create_group, assign_group, track_revenue, track_batch
|
||||||
|
- **Export API** (10 tools): export_events, export_events_csv, export_chart_data, get_event_count, get_unique_users, get_page_views, get_top_pages, get_top_referrers, get_geo_data, get_device_data
|
||||||
|
- **Insights API** (2 tools): get_overview_report, get_realtime_stats
|
||||||
|
- **Profile API** (3 tools): get_profile_events, get_profile_sessions, export_profile_data
|
||||||
|
- **Manage API** (10 tools): list/get/create/update/delete projects and clients
|
||||||
|
- **System** (6 tools): health_check, get_instance_info, get_usage_stats, get_storage_stats, test_connection, get_rate_limit_status
|
||||||
|
- **Service Page descriptions**: Per-plugin description and notes section on service detail pages
|
||||||
|
- **Dynamic URL hints**: Add Site form shows per-plugin URL guidance (e.g., OpenPanel API URL vs dashboard URL)
|
||||||
|
- **WordPress plugin download**: OpenPanel WordPress plugin bundled at `/static/plugins/openpanel-self-hosted.zip`
|
||||||
|
- **62 unit tests** for OpenPanel plugin (`tests/test_openpanel_plugin.py`)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- `ENABLED_PLUGINS` default: `wordpress,woocommerce,supabase,openpanel` (was: `wordpress,woocommerce,supabase`)
|
||||||
|
- OpenPanel health check uses `GET /healthcheck` (was: non-existent `/api/v1/oauth/token`)
|
||||||
|
- OpenPanel credential fields: added Project ID and Organization ID (optional)
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- OpenPanel `alias_user` tool (explicitly unsupported by OpenPanel API)
|
||||||
|
- OpenPanel dashboard handler (18 tools) — no public API available, was placeholder stubs
|
||||||
|
- OpenPanel funnel handler (8 tools) — no public API available, was placeholder stubs
|
||||||
|
- tRPC client dependency — all tools now use public REST APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.3.0] — 2026-03-31
|
||||||
|
|
||||||
|
### Platform Hardening & Admin Unification (Track F.1–F.8)
|
||||||
|
|
||||||
|
Major quality release: plugin visibility control, UI/UX polish, unified admin panel, database-backed settings, and security hardening. No breaking API changes.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **Plugin Visibility Control** (F.1): `ENABLED_PLUGINS` env var controls which plugins public users see. Default: `wordpress,woocommerce,supabase`. Admin sees all. New module: `core/plugin_visibility.py`
|
||||||
|
- **MCP Service Pages** (F.3): Dedicated `/dashboard/services/{type}` pages showing plugin capabilities, tool list, and setup requirements. Services list page with grid of plugin cards
|
||||||
|
- **Admin by Email** (F.4a): `ADMIN_EMAILS` env var for designating OAuth users as admin (supports multiple emails). OAuth admin users see full admin sidebar
|
||||||
|
- **Master Key Scope Control** (F.4b): `DISABLE_MASTER_KEY_LOGIN` to block dashboard login via master key. `MASTER_KEY_SCOPE` (`all`/`admin`) to restrict master key to admin endpoints only
|
||||||
|
- **Panel Unification** (F.4c): OAuth admin sees both "My Tools" and "Administration" sidebar sections. Master key admin auto-creates user record on first login for unified site management
|
||||||
|
- **Settings from UI** (F.4c.3): Database-backed settings with DB > ENV > Default priority. Admin can edit `ENABLED_PLUGINS`, `MAX_SITES_PER_USER`, rate limits, registration toggle from dashboard. New module: `core/settings.py`
|
||||||
|
- **Dashboard Stats** (F.3): Admin home page shows Total Users and User Sites stat cards
|
||||||
|
- **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- **Connect Page** (F.2): WordPress/SEO amber info box shown only for WordPress/WooCommerce sites (was shown for all plugin types)
|
||||||
|
- **Sidebar Version** (F.2): Fixed "v" displaying when version string is empty
|
||||||
|
- **Auth Page Language** (F.2): Auth page defaults to English regardless of Accept-Language header
|
||||||
|
- **Donation Link** (F.2): Moved from home page to sidebar for consistent visibility
|
||||||
|
- **Test Connection** (F.3): Fixed 504/HTML error handling; status badge auto-updates without page refresh; added `last_tested_at` timestamp (DB schema v4)
|
||||||
|
- **Admin Auth** (F.4): Fixed `_require_admin_session()` to recognize OAuth admin sessions; hide "Admin Login with API Key" button when `DISABLE_MASTER_KEY_LOGIN=true`
|
||||||
|
- **Starlette API** (F.2): Updated `TemplateResponse` to new API (request as first arg)
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- **exec() Removal** (F.8): Replaced `exec()` in `core/tool_generator.py` with closure-based tool generation
|
||||||
|
- **Shell Injection Fix** (F.8): Replaced `create_subprocess_shell` with `create_subprocess_exec` in WP-CLI handler
|
||||||
|
- **bcrypt Migration** (F.8): Admin API key hashing upgraded from unsalted SHA-256 to bcrypt
|
||||||
|
|
||||||
|
#### Tests
|
||||||
|
- New test suites: `test_plugin_visibility.py`, `test_admin_system.py`, `test_f3_admin_stats.py`, `test_f3_last_tested.py`, `test_f3_service_pages.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.2.0] — 2026-02-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bug A**: OAuth consent redirect loop — after GitHub/Google login, page now returns to consent screen instead of `/dashboard`
|
||||||
|
- **Bug B**: `/u/{user_id}/{alias}/mcp` endpoint now accepts OAuth JWT tokens (issued after social login) in addition to `mhu_` API keys
|
||||||
|
- **Bug C**: OAuth users can now create, list, and delete their own OAuth clients via the new `/dashboard/connect/oauth-clients` page
|
||||||
|
- **D-1**: Dashboard sidebar border uses Tailwind class instead of inline style; version shown in footer
|
||||||
|
- **D-2**: Removed broken `setInterval` in dashboard that targeted non-existent `#stats-container`
|
||||||
|
- **D-3**: 404 page uses purple primary colors and respects system dark mode preference
|
||||||
|
- **D-4**: Fixed invisible buttons (`bg-gray-200` + `text-white` in light mode) across api-keys, health, projects pages
|
||||||
|
- **D-5**: Pinned Alpine.js to 3.14.8; removed duplicate CSRF meta tag from `head_assets.html`
|
||||||
|
- **D-6**: Fixed invisible language toggle button in settings (dark mode)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `OAuthClient.owner_user_id` field for per-user OAuth client isolation
|
||||||
|
- `dashboard_connect_page` now includes Claude.ai connection guide with endpoint URL and link to OAuth Clients
|
||||||
|
- New route: `GET /dashboard/connect/oauth-clients` — user's OAuth clients list
|
||||||
|
- New API: `POST /api/dashboard/user-oauth-clients/create` — create OAuth client for logged-in user
|
||||||
|
- New API: `DELETE /api/dashboard/user-oauth-clients/{client_id}` — delete own OAuth client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
## [2.9.0] - 2026-02-14
|
||||||
|
|||||||
108
CLAUDE.md
108
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**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.
|
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 8 active plugin types (WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Total tool count varies with which plugins are enabled and grows as new plugins are added; per-plugin counts are surfaced in the dashboard. The exposed count for a given user/endpoint stays constant regardless of how many sites of the same type are configured.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
@@ -55,13 +55,42 @@ docker build -t mcphub .
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Frontend (web/) — Track G
|
||||||
|
|
||||||
|
The redesigned dashboard SPA lives in `web/` (Vite + React 18 + TypeScript).
|
||||||
|
Build artifacts land in `core/templates/static/dist/` and are served by
|
||||||
|
Starlette under `/static/dist/` and routed at `/dashboard-v2/*`. Legacy Jinja
|
||||||
|
dashboard at `/dashboard/*` is unchanged during the coexistence window.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install JS deps (one-time)
|
||||||
|
cd web && npm install
|
||||||
|
|
||||||
|
# Dev server (proxies /api, /auth, /dashboard, /static to localhost:8000)
|
||||||
|
cd web && npm run dev
|
||||||
|
|
||||||
|
# Type-check and run unit tests
|
||||||
|
cd web && npm run typecheck
|
||||||
|
cd web && npm test
|
||||||
|
|
||||||
|
# Production build (writes hashed bundles into core/templates/static/dist)
|
||||||
|
cd web && npm run build
|
||||||
|
|
||||||
|
# SPA-support backend tests
|
||||||
|
pytest -m frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Logo: `core/templates/static/logo.svg` is the single source of truth and is
|
||||||
|
copied verbatim into `web/public/` during build. The React `Logo` component
|
||||||
|
inlines the same path data and themes its fills via CSS vars.
|
||||||
|
|
||||||
## Code Quality Configuration
|
## Code Quality Configuration
|
||||||
|
|
||||||
All configured in `pyproject.toml`:
|
All configured in `pyproject.toml`:
|
||||||
- **Black**: line-length=100, target py311
|
- **Black**: line-length=100, target py311
|
||||||
- **Ruff**: strict rules (E, W, F, I, N, D, UP, B, C4, SIM, TCH, PTH), Google-style docstrings
|
- **Ruff**: strict rules (E, W, F, I, N, D, UP, B, C4, SIM, TCH, PTH), Google-style docstrings
|
||||||
- **mypy**: Python 3.11, strict equality, check_untyped_defs=true
|
- **mypy**: Python 3.11, strict equality, check_untyped_defs=true
|
||||||
- **pytest**: asyncio_mode="auto", testpaths=["tests"], markers: slow, integration, unit, security
|
- **pytest**: asyncio_mode="auto", testpaths=["tests"], markers: slow, integration, unit, security, frontend
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -69,9 +98,8 @@ All configured in `pyproject.toml`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── server.py # Primary entry point
|
├── server.py # Primary entry point
|
||||||
├── server_multi.py # Alternative multi-endpoint server
|
|
||||||
├── core/ # Layer 1: Core system modules
|
├── core/ # Layer 1: Core system modules
|
||||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
├── plugins/ # Layer 2: Plugin system (10 plugins)
|
||||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
├── tests/ # Organized test suite
|
├── tests/ # Organized test suite
|
||||||
├── scripts/ # Setup & deployment scripts
|
├── scripts/ # Setup & deployment scripts
|
||||||
@@ -86,15 +114,13 @@ All configured in `pyproject.toml`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting
|
Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting
|
||||||
Layer 2: Plugin System (plugins/) — 9 plugin types, each with handlers + schemas
|
Layer 2: Plugin System (plugins/) — 10 plugin types, each with handlers + schemas
|
||||||
Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard
|
Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Entry Points
|
### Entry Points
|
||||||
|
|
||||||
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
||||||
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
|
|
||||||
|
|
||||||
### Multi-Endpoint Architecture
|
### Multi-Endpoint Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -119,30 +145,23 @@ plugins/{name}/
|
|||||||
└── schemas/ # Pydantic models for validation
|
└── schemas/ # Pydantic models for validation
|
||||||
```
|
```
|
||||||
|
|
||||||
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus
|
**Registered plugins**: wordpress, woocommerce, wordpress_specialist, gitea, n8n, supabase, openpanel, coolify
|
||||||
|
|
||||||
|
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.
|
||||||
|
|
||||||
### Tool Generation
|
### Tool Generation
|
||||||
|
|
||||||
Tools are dynamically generated at startup:
|
Tools are dynamically generated at startup from plugin specifications:
|
||||||
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
|
1. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||||
2. `ToolGenerator` creates unified tools with a `site` parameter injected
|
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||||
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
|
||||||
|
|
||||||
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
||||||
|
|
||||||
### Site Configuration via Environment Variables
|
### Site Configuration
|
||||||
|
|
||||||
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
|
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
|
||||||
```bash
|
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
|
|
||||||
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
|
|
||||||
```
|
|
||||||
|
|
||||||
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
|
|
||||||
|
|
||||||
### Key Core Modules
|
### Key Core Modules
|
||||||
|
|
||||||
@@ -150,7 +169,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
|||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `core/auth.py` | Master API key validation, request authentication |
|
| `core/auth.py` | Master API key validation, request authentication |
|
||||||
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
||||||
| `core/site_manager.py` | Type-safe site config discovery from env vars |
|
| `core/site_manager.py` | Type-safe site config registration and lookup |
|
||||||
| `core/tool_registry.py` | Central tool definitions and lookup |
|
| `core/tool_registry.py` | Central tool definitions and lookup |
|
||||||
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
||||||
| `core/health.py` | Health monitoring, metrics, alerts |
|
| `core/health.py` | Health monitoring, metrics, alerts |
|
||||||
@@ -159,14 +178,24 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
|||||||
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
|
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
|
||||||
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
|
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
|
||||||
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
|
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
|
||||||
|
| `core/plugin_visibility.py` | Plugin enable/disable for public vs admin users |
|
||||||
|
| `core/user_auth.py` | OAuth Social Login (GitHub + Google) |
|
||||||
|
| `core/user_endpoints.py` | Per-user MCP endpoints (`/u/{user_id}/{alias}/mcp`) |
|
||||||
|
| `core/site_api.py` | User site CRUD, connection testing, credential encryption |
|
||||||
|
| `core/user_keys.py` | User API key management (bcrypt, `mhu_` prefix) |
|
||||||
|
| `core/database.py` | SQLite backend (aiosqlite, WAL mode, migrations) |
|
||||||
|
| `core/encryption.py` | AES-256-GCM credential encryption |
|
||||||
|
|
||||||
|
### User System (Track E)
|
||||||
|
|
||||||
|
OAuth Social Login (GitHub + Google) via `core/user_auth.py`. Users register, add sites, get personal MCP endpoints at `/u/{user_id}/{alias}/mcp`. Per-user API keys with `mhu_` prefix (bcrypt-hashed). Credentials encrypted with AES-256-GCM in SQLite.
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`). 8 pages: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`).
|
||||||
|
|
||||||
### Legacy Modules (Deprecated)
|
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
||||||
|
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
|
||||||
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
|
|
||||||
|
|
||||||
## Commit Style
|
## Commit Style
|
||||||
|
|
||||||
@@ -175,12 +204,26 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
|
|||||||
```
|
```
|
||||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
## Live Instances
|
||||||
|
|
||||||
|
- **Platform**: `mcp.example.com` — Live MCP Hub with OAuth login
|
||||||
|
- **Blog**: `blog.example.com` — WordPress test site + project blog
|
||||||
|
- **Deployment**: Coolify / Docker Compose, port 8000
|
||||||
|
|
||||||
|
## Current Development (Track F)
|
||||||
|
|
||||||
|
v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan.
|
||||||
|
|
||||||
|
**Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel, Gitea (configurable via `ENABLED_PLUGINS` env var)
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
||||||
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
|
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-bridge) — these are PHP, not Python
|
||||||
- `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
|
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||||
|
- 8 active plugins: WordPress, WooCommerce, WordPress Specialist, Supabase, OpenPanel, Gitea, n8n, Coolify. Appwrite/Directus code is retained in plugins/ but not registered (need review before re-enabling).
|
||||||
|
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
|
||||||
|
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
|
||||||
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
|
- 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)
|
- `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)
|
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
||||||
@@ -192,3 +235,6 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
|||||||
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
|
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
|
||||||
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
|
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
|
||||||
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
|
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
|
||||||
|
- OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL`
|
||||||
|
- User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500`
|
||||||
|
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea` (default)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ Then register in `plugins/__init__.py` and add tests.
|
|||||||
core/ # Core system (auth, site manager, tool registry, dashboard)
|
core/ # Core system (auth, site manager, tool registry, dashboard)
|
||||||
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
||||||
core/templates/ # Jinja2 templates (dashboard + OAuth)
|
core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
tests/ # Test suite (290 tests)
|
tests/ # Test suite (481 tests)
|
||||||
scripts/ # Setup & deployment scripts
|
scripts/ # Setup & deployment scripts
|
||||||
docs/ # Documentation
|
docs/ # Documentation
|
||||||
```
|
```
|
||||||
|
|||||||
111
DOCKER_README.md
111
DOCKER_README.md
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||||
|
|
||||||
**Version 3.0.1** — 596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
8 plugins, hundreds of tools. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||||
|
|
||||||
|
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -11,14 +13,10 @@
|
|||||||
```bash
|
```bash
|
||||||
# Recommended (auto-generates temp key if omitted — check container logs)
|
# Recommended (auto-generates temp key if omitted — check container logs)
|
||||||
MASTER_API_KEY=your-secure-key-here
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After starting, open the **web dashboard** to add your sites — no env vars needed for site configuration.
|
||||||
|
|
||||||
### 2. Run the container
|
### 2. Run the container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -39,6 +37,8 @@ curl http://localhost:8000/health
|
|||||||
# http://localhost:8000/dashboard
|
# http://localhost:8000/dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Log in with your `MASTER_API_KEY`, then add sites via **My Sites → Add Service**.
|
||||||
|
|
||||||
### 4. Connect your AI client
|
### 4. Connect your AI client
|
||||||
|
|
||||||
**Claude Desktop** (`claude_desktop_config.json`):
|
**Claude Desktop** (`claude_desktop_config.json`):
|
||||||
@@ -57,7 +57,7 @@ curl http://localhost:8000/health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**VS Code** (`.vscode/mcp.json`):
|
**VS Code / Claude Code** (`.vscode/mcp.json` or `.mcp.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -73,7 +73,7 @@ curl http://localhost:8000/health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Endpoints](#endpoints) below.
|
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to keep the tool list focused and save tokens. See [Endpoints](#endpoints) below.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -83,19 +83,18 @@ MCP Hub uses **Bearer token** authentication:
|
|||||||
Authorization: Bearer YOUR_API_KEY
|
Authorization: Bearer YOUR_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
> `X-API-Key` header and query parameter auth are **not** supported.
|
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
Use the most specific endpoint for your use case:
|
Use the most specific endpoint for your use case:
|
||||||
|
|
||||||
| Endpoint | Tools | `site` param? | Best for |
|
| Endpoint | Use case |
|
||||||
|----------|------:|:-------------:|----------|
|
|----------|----------|
|
||||||
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
| `/u/{user_id}/{alias}/mcp` | Hosted/OAuth users — single pre-scoped service |
|
||||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
| `/project/{alias}/mcp` | Single-site workflow (recommended) |
|
||||||
| `/mcp` | 596 | Yes | Admin & discovery only |
|
| `/{plugin}/mcp` | Multi-site management for one service type |
|
||||||
|
| `/mcp` | Admin & discovery — all enabled tools |
|
||||||
|
|
||||||
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-specialist/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/coolify/mcp`, `/system/mcp`
|
||||||
|
|
||||||
## Using Docker Compose
|
## Using Docker Compose
|
||||||
|
|
||||||
@@ -119,53 +118,71 @@ volumes:
|
|||||||
mcphub-logs:
|
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
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **WP-CLI tools** (cache flush, database export, plugin management via CLI) require Docker socket access. Uncomment the socket volume and set the `container` field when adding the WordPress site in the dashboard.
|
||||||
|
|
||||||
## After Starting
|
## After Starting
|
||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `http://localhost:8000/health` | Health check & status |
|
| `http://localhost:8000/health` | Health check & status |
|
||||||
| `http://localhost:8000/dashboard` | Web dashboard (manage API keys, view sites, health) |
|
| `http://localhost:8000/dashboard` | Web dashboard — manage sites, API keys, OAuth clients, health |
|
||||||
| `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) |
|
| `http://localhost:8000/mcp` | MCP endpoint (connect your AI client here) |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Required | Description |
|
### Required
|
||||||
|----------|----------|-------------|
|
|
||||||
| `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.
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `MASTER_API_KEY` | API key for admin access. If omitted, a temporary key is auto-generated and printed to logs. |
|
||||||
|
|
||||||
> **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.
|
### Optional — OAuth & Social Login
|
||||||
|
|
||||||
Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md).
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `OAUTH_JWT_SECRET_KEY` | JWT secret for ChatGPT Remote MCP auto-registration. Not needed for Claude/Cursor/VS Code. |
|
||||||
|
| `OAUTH_BASE_URL` | Public URL of your server. Required for OAuth flows. |
|
||||||
|
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | Enable GitHub social login for multi-user mode. |
|
||||||
|
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Enable Google social login for multi-user mode. |
|
||||||
|
| `PUBLIC_URL` | Public URL for OAuth callbacks (e.g., `https://mcp.example.com`). |
|
||||||
|
|
||||||
## Supported Plugins
|
### Optional — Multi-user & Encryption
|
||||||
|
|
||||||
| Plugin | Tools | Env Prefix |
|
| Variable | Default | Description |
|
||||||
|--------|-------|------------|
|
|----------|---------|-------------|
|
||||||
| WordPress | 67 | `WORDPRESS_` |
|
| `ENCRYPTION_KEY` | — | AES-256-GCM key for encrypting user site credentials. Generate: `python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"` |
|
||||||
| WooCommerce | 28 | `WOOCOMMERCE_` |
|
| `MAX_SITES_PER_USER` | `10` | Maximum sites per user account (also configurable via the dashboard Settings page). |
|
||||||
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` |
|
| `USER_RATE_LIMIT_PER_MIN` | `30` | Per-user rate limit (requests/minute). Configurable via dashboard. |
|
||||||
| Gitea | 56 | `GITEA_` |
|
| `USER_RATE_LIMIT_PER_HR` | `500` | Per-user rate limit (requests/hour). Configurable via dashboard. |
|
||||||
| n8n | 56 | `N8N_` |
|
| `ENABLED_PLUGINS` | `wordpress,woocommerce,supabase,openpanel,gitea` | Comma-separated list of plugins visible to public/OAuth users. Admin always sees all plugins. |
|
||||||
| Supabase | 70 | `SUPABASE_` |
|
|
||||||
| OpenPanel | 73 | `OPENPANEL_` |
|
### Optional — Logging
|
||||||
| Appwrite | 100 | `APPWRITE_` |
|
|
||||||
| Directus | 100 | `DIRECTUS_` |
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||||
|
|
||||||
|
> **Site configuration** (URLs, credentials) is managed via the web dashboard — not environment variables. Log in and go to **My Sites → Add Service**.
|
||||||
|
|
||||||
|
## 8 Plugins, Hundreds of Tools
|
||||||
|
|
||||||
|
| Plugin | Approx. Tools | What you can do |
|
||||||
|
|--------|------:|-----------------|
|
||||||
|
| WordPress | ~70 | Posts, pages, media (incl. AI image gen), users, menus, taxonomies, SEO |
|
||||||
|
| WooCommerce | ~30 | Products, orders, customers, coupons, reports, shipping |
|
||||||
|
| WordPress Specialist | ~50 | Plugins, themes, options, cron, page editing, DB inspection, bulk fan-out |
|
||||||
|
| Gitea | ~65 | Repos, issues, PRs, releases, webhooks, orgs, batch file ops |
|
||||||
|
| n8n | ~55 | Workflows, executions, credentials, variables, audit |
|
||||||
|
| Supabase | ~70 | Database, auth, storage, edge functions, realtime |
|
||||||
|
| OpenPanel | ~40 | Events, export, insights, profiles, projects |
|
||||||
|
| Coolify | ~65 | Apps, deployments, servers, projects, databases, services |
|
||||||
|
| System | ~25 | Health monitoring, API keys, OAuth management, audit logs |
|
||||||
|
|
||||||
|
> **WordPress Specialist** requires [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) (v2.18.0+) installed on your WordPress site.
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -5,6 +5,16 @@
|
|||||||
# Production-ready with security best practices
|
# Production-ready with security best practices
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
|
# Stage 0: Frontend build (Track G — Vite + React SPA → core/templates/static/dist)
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||||
|
COPY web/ ./
|
||||||
|
COPY core/templates/static/logo.svg ./public/logo.svg
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
# Stage 1: Build stage
|
# Stage 1: Build stage
|
||||||
FROM python:3.12-alpine AS builder
|
FROM python:3.12-alpine AS builder
|
||||||
|
|
||||||
@@ -27,7 +37,8 @@ RUN pip install --no-cache-dir --user -r requirements.txt
|
|||||||
FROM python:3.12-alpine AS production
|
FROM python:3.12-alpine AS production
|
||||||
|
|
||||||
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
|
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
|
||||||
RUN apk add --no-cache wget curl docker-cli
|
# libmagic is required by python-magic (F.5a media upload MIME sniffing)
|
||||||
|
RUN apk add --no-cache wget curl docker-cli libmagic
|
||||||
|
|
||||||
# Create non-root user for security and grant Docker socket access
|
# Create non-root user for security and grant Docker socket access
|
||||||
# Docker group (GID 999) allows access to /var/run/docker.sock
|
# Docker group (GID 999) allows access to /var/run/docker.sock
|
||||||
@@ -45,6 +56,11 @@ COPY --from=builder /root/.local /home/appuser/.local
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
|
# Overlay the SPA build output (from stage 0) into the templates static dir.
|
||||||
|
# Vite already targets core/templates/static/dist when run locally; in Docker
|
||||||
|
# we build inside the frontend stage and copy the result here.
|
||||||
|
COPY --from=frontend --chown=appuser:appgroup /core/templates/static/dist /app/core/templates/static/dist
|
||||||
|
|
||||||
# Create data directories for API keys and logs with correct ownership
|
# Create data directories for API keys and logs with correct ownership
|
||||||
# This must be done before switching to non-root user
|
# This must be done before switching to non-root user
|
||||||
RUN mkdir -p /app/data /app/logs && \
|
RUN mkdir -p /app/data /app/logs && \
|
||||||
|
|||||||
113
Dockerfile.slim
Normal file
113
Dockerfile.slim
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ===================================
|
||||||
|
# MCP Hub — Dockerfile (Debian slim, restrictive-network variant)
|
||||||
|
# ===================================
|
||||||
|
# Plan B fallback when both registry-1.docker.io AND
|
||||||
|
# dl-cdn.alpinelinux.org are unreachable from the build host.
|
||||||
|
#
|
||||||
|
# Switches the base from python:3.12-alpine (musl + apk) to
|
||||||
|
# python:3.12-slim-bookworm (Debian + apt). Debian's package mirrors
|
||||||
|
# are CDN-fronted (Cloudflare/Fastly) and are usually reachable from
|
||||||
|
# networks that block Alpine's CDN. Image is pulled from mirror.gcr.io
|
||||||
|
# to also bypass Docker Hub TLS issues.
|
||||||
|
#
|
||||||
|
# Tradeoffs vs Dockerfile.mirror (Alpine):
|
||||||
|
# - Image size: ~120 MB (slim) vs ~60 MB (alpine) — acceptable
|
||||||
|
# - Compatibility: ALL Python wheels work (no musl rebuild needed)
|
||||||
|
# - Security baseline: equivalent (slim is minimal Debian, no shell extras)
|
||||||
|
#
|
||||||
|
# Switch back to Dockerfile.mirror once dl-cdn.alpinelinux.org reachable
|
||||||
|
# OR back to Dockerfile once registry-1.docker.io is reachable.
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
# Stage 1: Build stage
|
||||||
|
FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS builder
|
||||||
|
|
||||||
|
# Optional HTTP proxy for restricted networks. ARG values are NOT baked
|
||||||
|
# into the final image, so the runtime container never carries the proxy.
|
||||||
|
ARG BUILD_HTTP_PROXY=""
|
||||||
|
ARG BUILD_HTTPS_PROXY=""
|
||||||
|
ARG BUILD_NO_PROXY=""
|
||||||
|
|
||||||
|
# Install build dependencies via apt (proxy honoured if BUILD_HTTP_PROXY set)
|
||||||
|
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
|
||||||
|
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
|
||||||
|
NO_PROXY="${BUILD_NO_PROXY}" \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create build directory
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies (proxy honoured)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
|
||||||
|
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
|
||||||
|
NO_PROXY="${BUILD_NO_PROXY}" \
|
||||||
|
&& pip install --no-cache-dir --user -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 2: Production stage
|
||||||
|
FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS production
|
||||||
|
|
||||||
|
# Re-declare proxy ARGs in this stage (ARGs don't cross stage boundaries).
|
||||||
|
ARG BUILD_HTTP_PROXY=""
|
||||||
|
ARG BUILD_HTTPS_PROXY=""
|
||||||
|
ARG BUILD_NO_PROXY=""
|
||||||
|
|
||||||
|
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
|
||||||
|
# libmagic1 is required by python-magic (F.5a media upload MIME sniffing)
|
||||||
|
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
|
||||||
|
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
|
||||||
|
NO_PROXY="${BUILD_NO_PROXY}" \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
wget \
|
||||||
|
curl \
|
||||||
|
docker.io \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for security and grant Docker socket access
|
||||||
|
# Docker group (GID 999) allows access to /var/run/docker.sock
|
||||||
|
RUN groupadd -g 1001 appgroup && \
|
||||||
|
useradd -u 1001 -g appgroup -s /bin/sh -m appuser && \
|
||||||
|
(groupadd -g 999 docker 2>/dev/null || true) && \
|
||||||
|
(usermod -aG docker appuser 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Python packages from builder
|
||||||
|
COPY --from=builder /root/.local /home/appuser/.local
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
|
# Create data directories for API keys and logs with correct ownership
|
||||||
|
# This must be done before switching to non-root user
|
||||||
|
RUN mkdir -p /app/data /app/logs && \
|
||||||
|
chown -R appuser:appgroup /app/data /app/logs && \
|
||||||
|
chmod 755 /app/data /app/logs
|
||||||
|
|
||||||
|
# Make server.py executable
|
||||||
|
RUN chmod +x server.py
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Add local packages to PATH
|
||||||
|
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# CRITICAL: EXPOSE port for Coolify
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# CRITICAL: Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# CRITICAL: Listen on 0.0.0.0 (not localhost!)
|
||||||
|
# Run server with streamable-http transport on port 8000
|
||||||
|
CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]
|
||||||
189
README.md
189
README.md
@@ -1,3 +1,5 @@
|
|||||||
|
<!-- mcp-name: io.github.airano-ir/mcphub -->
|
||||||
|
|
||||||
# MCP Hub
|
# MCP Hub
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -6,13 +8,12 @@
|
|||||||
|
|
||||||
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
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)
|
[](https://github.com/airano-ir/mcphub/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://pypi.org/project/mcphub-server/)
|
[](https://pypi.org/project/mcphub-server/)
|
||||||
[](https://hub.docker.com/r/airano/mcphub)
|
[](https://hub.docker.com/r/airano/mcphub)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
|
||||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +24,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
|||||||
|
|
||||||
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
|
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
|
||||||
|
|
||||||
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
|
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
|
||||||
|
|
||||||
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
|
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
|
||||||
>
|
>
|
||||||
@@ -39,7 +40,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
| AI agent integration | No | No | No | **Native (MCP)** |
|
| AI agent integration | No | No | No | **Native (MCP)** |
|
||||||
| Full WordPress API | Dashboard | Dashboard | Content only | **67 tools** |
|
| Full WordPress API | Dashboard | Dashboard | Content only | **67 tools** |
|
||||||
| WooCommerce management | No | Limited | No | **28 tools** |
|
| WooCommerce management | No | Limited | No | **28 tools** |
|
||||||
| Git/CI management | No | No | No | **56 tools (Gitea)** |
|
| Git/CI management | No | No | No | **65 tools (Gitea)** |
|
||||||
| Automation workflows | No | No | No | **56 tools (n8n)** |
|
| Automation workflows | No | No | No | **56 tools (n8n)** |
|
||||||
| Self-hosted | No | Yes | N/A | **Yes** |
|
| Self-hosted | No | Yes | N/A | **Yes** |
|
||||||
| Open source | No | Core only | Varies | **Fully open** |
|
| Open source | No | Core only | Varies | **Fully open** |
|
||||||
@@ -47,21 +48,26 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 596 Tools Across 9 Plugins
|
## 8 Public Plugins, Hundreds of Tools
|
||||||
|
|
||||||
| Plugin | Tools | What You Can Do |
|
The exact tool count grows as new plugins ship and existing ones gain endpoints.
|
||||||
|--------|-------|-----------------|
|
What you actually expose is controlled by your `ENABLED_PLUGINS` setting and per-key
|
||||||
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
|
scope — pick a plugin-specific endpoint to keep the surface area small.
|
||||||
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
|
|
||||||
| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
|
| Plugin | Approx. Tools | What You Can Do |
|
||||||
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
|--------|---------------:|-----------------|
|
||||||
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
| **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) |
|
||||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
| **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping |
|
||||||
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
|
| **WordPress Specialist** | ~50 | Plugins, themes, users, options, cron, page editing, site config + layout, db inspection, bulk fan-out (companion-backed; no Docker socket) |
|
||||||
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
| **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare |
|
||||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
| **n8n** | ~55 | Workflows, executions, credentials, variables, audit |
|
||||||
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
| **Supabase** | ~70 | Database, auth, storage, edge functions, realtime |
|
||||||
| **Total** | **596** | Constant count — scales to unlimited sites |
|
| **OpenPanel** | ~40 | Events, export, insights, profiles, projects, system |
|
||||||
|
| **Coolify** | ~65 | Applications, deployments, servers, projects, databases, services |
|
||||||
|
| **System** | ~25 | Health monitoring, API keys, OAuth management, audit |
|
||||||
|
|
||||||
|
> Per-site duplication does **not** inflate the tool count — adding a second
|
||||||
|
> WordPress site reuses the same WordPress tools with a different `site` argument.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,14 +79,14 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
git clone https://github.com/airano-ir/mcphub.git
|
git clone https://github.com/airano-ir/mcphub.git
|
||||||
cd mcphub
|
cd mcphub
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env — set MASTER_API_KEY and add your site credentials
|
# Edit .env — set MASTER_API_KEY, then add sites via the web dashboard
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Docker Hub (No Clone)
|
### Option 2: Docker Hub (No Clone)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a .env file with your credentials (see "Configure Your Sites" below)
|
# Create a .env file with MASTER_API_KEY (see "Configure Your Sites" below)
|
||||||
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ git clone https://github.com/airano-ir/mcphub.git
|
|||||||
cd mcphub
|
cd mcphub
|
||||||
pip install -e .
|
pip install -e .
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env — set MASTER_API_KEY
|
||||||
python server.py --transport streamable-http --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,32 +112,32 @@ curl http://localhost:8000/health
|
|||||||
|
|
||||||
Open the **web dashboard** in your browser: **http://localhost:8000/dashboard**
|
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.
|
You should see the login page. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
|
||||||
|
|
||||||
|
### Try It Now (No Setup Required)
|
||||||
|
|
||||||
|
**Don't want to self-host?** Use the hosted instance at **[mcp.example.com](https://mcp.example.com)**:
|
||||||
|
|
||||||
|
1. Log in with **GitHub** or **Google**
|
||||||
|
2. Add your sites via the dashboard (My Sites → Add Service)
|
||||||
|
3. Go to **Connect** page — generate config for your AI client
|
||||||
|
4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code
|
||||||
|
|
||||||
|
Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mcp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Configure Your Sites
|
### Configure Your Sites
|
||||||
|
|
||||||
Add site credentials to `.env`:
|
Sites are managed via the **web dashboard** — no environment variables needed.
|
||||||
|
|
||||||
|
1. Set `MASTER_API_KEY` in your `.env` file
|
||||||
|
2. Start the server and open the dashboard
|
||||||
|
3. Add sites with their credentials (URL, username, password/token)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Master API Key (recommended — auto-generates temp key if omitted)
|
# .env — only system configuration needed
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
# WordPress Site
|
|
||||||
WORDPRESS_SITE1_URL=https://myblog.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
WORDPRESS_SITE1_ALIAS=myblog
|
|
||||||
|
|
||||||
# WooCommerce Store
|
|
||||||
WOOCOMMERCE_STORE1_URL=https://mystore.com
|
|
||||||
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
|
||||||
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
|
||||||
WOOCOMMERCE_STORE1_ALIAS=mystore
|
|
||||||
|
|
||||||
# Gitea Instance
|
|
||||||
GITEA_REPO1_URL=https://git.example.com
|
|
||||||
GITEA_REPO1_TOKEN=your_gitea_token
|
|
||||||
GITEA_REPO1_ALIAS=mygitea
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -143,45 +149,23 @@ GITEA_REPO1_ALIAS=mygitea
|
|||||||
|----------|----------|---------|-------------|
|
|----------|----------|---------|-------------|
|
||||||
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||||
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
|
||||||
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
|
| `OAUTH_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_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.
|
> **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
|
||||||
|
|
||||||
**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}`
|
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
|
||||||
|
|
||||||
| Plugin | Required Keys | Optional Keys |
|
| Plugin | Required Credentials | Notes |
|
||||||
|--------|--------------|---------------|
|
|--------|---------------------|-------|
|
||||||
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
|
||||||
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
|
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
|
||||||
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
|
| WordPress Specialist | URL, Username, App Password | Requires [Airano MCP Bridge v2.18.0+](wordpress-plugin/airano-mcp-bridge.zip) on the WP site; user must have `manage_options` |
|
||||||
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
|
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
|
||||||
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
|
| n8n | URL, API Key | Settings → API → Create API Key |
|
||||||
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
|
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
|
||||||
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
|
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
|
||||||
| `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>
|
</details>
|
||||||
|
|
||||||
@@ -283,27 +267,30 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
/mcp → Admin endpoint (all 596 tools)
|
/mcp → Admin endpoint (every enabled tool)
|
||||||
/system/mcp → System tools only (24 tools)
|
/system/mcp → System tools only
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
/wordpress/mcp → WordPress tools
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
/woocommerce/mcp → WooCommerce tools
|
||||||
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
|
/wordpress-specialist/mcp → WordPress Specialist tools (companion-backed)
|
||||||
/gitea/mcp → Gitea tools (56 tools)
|
/gitea/mcp → Gitea tools
|
||||||
/n8n/mcp → n8n tools (56 tools)
|
/n8n/mcp → n8n tools
|
||||||
/supabase/mcp → Supabase tools (70 tools)
|
/supabase/mcp → Supabase tools
|
||||||
/openpanel/mcp → OpenPanel tools (73 tools)
|
/openpanel/mcp → OpenPanel tools
|
||||||
/appwrite/mcp → Appwrite tools (100 tools)
|
/coolify/mcp → Coolify tools
|
||||||
/directus/mcp → Directus tools (100 tools)
|
|
||||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||||
|
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
|
**Recommendation**: Use plugin-specific endpoints instead of the all-tools `/mcp`
|
||||||
|
admin endpoint to keep your AI client's tool window small (and your token bill
|
||||||
|
lower).
|
||||||
|
|
||||||
| Endpoint | Use Case | Tools |
|
| Endpoint | Use Case |
|
||||||
|----------|----------|------:|
|
|----------|----------|
|
||||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) — single service |
|
||||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
| `/project/{alias}/mcp` | Single-site workflow (recommended) |
|
||||||
| `/mcp` | Admin & discovery only | 596 |
|
| `/{plugin}/mcp` | Multi-site management for one service |
|
||||||
|
| `/mcp` | Admin & discovery only — every enabled tool |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -313,7 +300,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
||||||
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
|
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
|
||||||
|
|
||||||
> **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.
|
> **Compatibility Note**: MCP Hub requires FastMCP 3.x (`>=3.0.0,<4.0.0`). The legacy multi-endpoint server and ProjectManager have been removed in v3.5.0.
|
||||||
|
|
||||||
### WordPress Plugin Requirements
|
### WordPress Plugin Requirements
|
||||||
|
|
||||||
@@ -321,22 +308,20 @@ Some MCP Hub tools require companion WordPress plugins:
|
|||||||
|
|
||||||
| Tools | Requirement |
|
| 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 |
|
| SEO + capability/audit tools (`wordpress_get_post_seo`, capability probe, audit hook, etc.) | [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) ([GitHub](wordpress-plugin/airano-mcp-bridge/)) + Rank Math or Yoast SEO |
|
||||||
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var |
|
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
|
||||||
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
|
| WordPress Specialist (~50 tools: plugins / themes / users / options / cron / page editing / site config + layout / db inspection / bulk fan-out) | [Airano MCP Bridge v2.18.0+](wordpress-plugin/airano-mcp-bridge.zip) (no Docker socket needed) |
|
||||||
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
|
| 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) |
|
| WooCommerce tools | WooCommerce plugin installed on your WordPress site |
|
||||||
|
|
||||||
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
|
**Docker socket** is needed for the legacy WP-CLI tools (15 helpers under `wordpress_wp_*`). Everything in `wordpress_specialist` works without it. Add to your docker-compose:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /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.
|
Set the `container` field when adding a WordPress site in the dashboard. Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -361,7 +346,7 @@ Without Docker socket, WP-CLI tools return "not available" but all REST API tool
|
|||||||
# Install with dev dependencies
|
# Install with dev dependencies
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
# Run tests (290 tests)
|
# Run tests
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
# Format and lint
|
# Format and lint
|
||||||
|
|||||||
@@ -37,20 +37,13 @@ from core.health import (
|
|||||||
initialize_health_monitor,
|
initialize_health_monitor,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Project and Site Management
|
|
||||||
from core.project_manager import ProjectManager, get_project_manager
|
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
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
|
from core.tool_generator import ToolGenerator
|
||||||
|
|
||||||
# Tool Management (Option B architecture)
|
# Tool Management (Option B architecture)
|
||||||
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
||||||
from core.unified_tools import UnifiedToolGenerator
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Authentication
|
# Authentication
|
||||||
@@ -58,17 +51,10 @@ __all__ = [
|
|||||||
"get_auth_manager",
|
"get_auth_manager",
|
||||||
"APIKeyManager",
|
"APIKeyManager",
|
||||||
"get_api_key_manager",
|
"get_api_key_manager",
|
||||||
# Project/Site Management
|
# Site Management
|
||||||
"ProjectManager",
|
|
||||||
"get_project_manager",
|
|
||||||
"SiteManager",
|
"SiteManager",
|
||||||
"SiteConfig",
|
"SiteConfig",
|
||||||
"get_site_manager",
|
"get_site_manager",
|
||||||
# Legacy (deprecated)
|
|
||||||
"SiteRegistry",
|
|
||||||
"SiteInfo",
|
|
||||||
"get_site_registry",
|
|
||||||
"UnifiedToolGenerator",
|
|
||||||
# Tool Management
|
# Tool Management
|
||||||
"ToolRegistry",
|
"ToolRegistry",
|
||||||
"ToolDefinition",
|
"ToolDefinition",
|
||||||
|
|||||||
27
core/admin_utils.py
Normal file
27
core/admin_utils.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Admin utility functions for role determination.
|
||||||
|
|
||||||
|
Centralizes admin email checking so it can be used in OAuth callback,
|
||||||
|
dashboard auth, and future admin/user panel unification (F.5+).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_email(email: str | None) -> bool:
|
||||||
|
"""Check if an email is in the ADMIN_EMAILS env var list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email matches an admin email (case-insensitive).
|
||||||
|
"""
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
admin_emails_raw = os.environ.get("ADMIN_EMAILS", "")
|
||||||
|
if not admin_emails_raw.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
admin_emails = {e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()}
|
||||||
|
return email.strip().lower() in admin_emails
|
||||||
@@ -6,6 +6,7 @@ and audit trail.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,8 @@ from dataclasses import asdict, dataclass
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@ class APIKey:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
key_id: str
|
key_id: str
|
||||||
key_hash: str
|
key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy)
|
||||||
project_id: str
|
project_id: str
|
||||||
scope: Scope
|
scope: Scope
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -180,8 +183,63 @@ class APIKeyManager:
|
|||||||
logger.error(f"Failed to save keys: {e}")
|
logger.error(f"Failed to save keys: {e}")
|
||||||
|
|
||||||
def _hash_key(self, api_key: str) -> str:
|
def _hash_key(self, api_key: str) -> str:
|
||||||
"""Hash API key for storage."""
|
"""Hash API key for storage using bcrypt."""
|
||||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_bcrypt_hash(key_hash: str) -> bool:
|
||||||
|
"""Return True if the stored hash is in bcrypt format ($2a/$2b/$2y)."""
|
||||||
|
return key_hash.startswith("$2")
|
||||||
|
|
||||||
|
def _verify_key(self, api_key: str, key_hash: str) -> bool:
|
||||||
|
"""Verify API key against stored hash.
|
||||||
|
|
||||||
|
Supports both the current bcrypt format and a legacy SHA-256
|
||||||
|
fallback so pre-bcrypt keys keep working. F.8 security-
|
||||||
|
hardening note: SHA-256 is a fast hash with no per-hash salt —
|
||||||
|
a stolen keys.json file would enable offline brute-force on
|
||||||
|
any legacy entry. We therefore:
|
||||||
|
|
||||||
|
1. accept legacy hashes here (so customers aren't locked out), and
|
||||||
|
2. opportunistically upgrade every legacy hash to bcrypt the
|
||||||
|
first time it's successfully verified (see
|
||||||
|
:meth:`_upgrade_legacy_hash`).
|
||||||
|
|
||||||
|
Brand-new keys created via :meth:`create_key` are always
|
||||||
|
bcrypt-hashed — legacy SHA-256 only appears for rows that
|
||||||
|
existed before the F.4 / F.8 hardening passes.
|
||||||
|
"""
|
||||||
|
if self._is_bcrypt_hash(key_hash):
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
|
||||||
|
except ValueError:
|
||||||
|
# Truncated / corrupt bcrypt hash — treat as mismatch
|
||||||
|
# rather than raising.
|
||||||
|
return False
|
||||||
|
# Legacy SHA-256 fallback (constant-time compare to avoid
|
||||||
|
# timing oracles on the legacy-hash path).
|
||||||
|
expected = hashlib.sha256(api_key.encode()).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, key_hash)
|
||||||
|
|
||||||
|
def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool:
|
||||||
|
"""Re-hash a legacy SHA-256 entry with bcrypt and persist.
|
||||||
|
|
||||||
|
Called from the verify paths the moment a legacy hash is
|
||||||
|
successfully matched, so the next verify uses bcrypt. Returns
|
||||||
|
True when an upgrade happened, False otherwise. Errors during
|
||||||
|
persist are logged but swallowed — the key still validates,
|
||||||
|
we just try again next time.
|
||||||
|
"""
|
||||||
|
if self._is_bcrypt_hash(key.key_hash):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
key.key_hash = self._hash_key(api_key)
|
||||||
|
self._save_keys()
|
||||||
|
logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # pragma: no cover — defensive
|
||||||
|
logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
def create_key(
|
def create_key(
|
||||||
self,
|
self,
|
||||||
@@ -269,12 +327,13 @@ class APIKeyManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[str]: key_id if valid, None otherwise
|
Optional[str]: key_id if valid, None otherwise
|
||||||
"""
|
"""
|
||||||
key_hash = self._hash_key(api_key)
|
# Find key by verifying against stored hash (bcrypt or legacy SHA-256)
|
||||||
|
|
||||||
# Find key by hash
|
|
||||||
for key_id, key in self.keys.items():
|
for key_id, key in self.keys.items():
|
||||||
if key.key_hash != key_hash:
|
if not self._verify_key(api_key, key.key_hash):
|
||||||
continue
|
continue
|
||||||
|
# F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt
|
||||||
|
# the moment they validate successfully.
|
||||||
|
self._upgrade_legacy_hash(key, api_key)
|
||||||
|
|
||||||
# Check if valid (not revoked, not expired)
|
# Check if valid (not revoked, not expired)
|
||||||
if not key.is_valid():
|
if not key.is_valid():
|
||||||
@@ -341,10 +400,10 @@ class APIKeyManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[APIKey]: The APIKey object if found, None otherwise
|
Optional[APIKey]: The APIKey object if found, None otherwise
|
||||||
"""
|
"""
|
||||||
key_hash = self._hash_key(api_key)
|
|
||||||
|
|
||||||
for key_id, key in self.keys.items():
|
for key_id, key in self.keys.items():
|
||||||
if key.key_hash == key_hash:
|
if self._verify_key(api_key, key.key_hash):
|
||||||
|
# F.8: upgrade legacy SHA-256 hashes on first successful match.
|
||||||
|
self._upgrade_legacy_hash(key, api_key)
|
||||||
logger.debug(f"Found API key {key_id} by token")
|
logger.debug(f"Found API key {key_id} by token")
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ class AuthManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if valid
|
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:
|
if not is_valid:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.debug("Master key validation failed for provided token")
|
||||||
|
|
||||||
return is_valid
|
return is_valid
|
||||||
|
|
||||||
|
|||||||
485
core/capability_probe.py
Normal file
485
core/capability_probe.py
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
"""F.7e — per-site credential capability probe.
|
||||||
|
|
||||||
|
Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows
|
||||||
|
how to ask its upstream service what the saved credential can actually
|
||||||
|
do. This module wraps those calls with:
|
||||||
|
|
||||||
|
* Per-site in-memory TTL cache (default 10 min) so the probe doesn't
|
||||||
|
hammer the upstream service on every dashboard page view.
|
||||||
|
* A thin wrapper that decrypts the site's credentials, instantiates the
|
||||||
|
plugin, calls the probe, and normalises the result.
|
||||||
|
* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard
|
||||||
|
UI consumes (wired in ``core/dashboard/routes.py``).
|
||||||
|
|
||||||
|
The cache is process-local and not persisted. Workers start cold and
|
||||||
|
populate on demand; invalidation happens on cache expiry or explicit
|
||||||
|
site-credential update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcphub.capability_probe")
|
||||||
|
|
||||||
|
# Cache TTL in seconds (default 10 min). Upstream capability rarely
|
||||||
|
# changes — most drift happens when the operator rotates an
|
||||||
|
# app_password or changes a Gitea token's scopes, both of which are
|
||||||
|
# infrequent.
|
||||||
|
_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600"))
|
||||||
|
|
||||||
|
|
||||||
|
class _ProbeCache:
|
||||||
|
"""Trivial in-memory ``site_id -> (expires_at, payload)`` cache."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None:
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
def get(self, site_id: str) -> dict[str, Any] | None:
|
||||||
|
entry = self._entries.get(site_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
expires_at, payload = entry
|
||||||
|
if expires_at < time.time():
|
||||||
|
self._entries.pop(site_id, None)
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def set(self, site_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
self._entries[site_id] = (time.time() + self._ttl, payload)
|
||||||
|
|
||||||
|
def invalidate(self, site_id: str) -> bool:
|
||||||
|
return self._entries.pop(site_id, None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
_cache = _ProbeCache()
|
||||||
|
|
||||||
|
|
||||||
|
def get_probe_cache() -> _ProbeCache:
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F.7e — tier-fit evaluation: compare probe.granted with what the site's
|
||||||
|
# selected ``tool_scope`` actually needs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Capability names required for each (plugin_type, tier) pair. Tiers come
|
||||||
|
# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1
|
||||||
|
# onto these names (e.g. Gitea scopes use ``read:repository`` rather than
|
||||||
|
# ``read``) the fit evaluator's ``_aliased_granted`` helper below
|
||||||
|
# normalises both sides before comparing.
|
||||||
|
TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
|
||||||
|
"wordpress": {
|
||||||
|
"read": {"read"},
|
||||||
|
"write": {"edit_posts", "upload_files"},
|
||||||
|
# WP-admin-tier tools need the same cap as WP's admin area itself.
|
||||||
|
"admin": {"manage_options"},
|
||||||
|
},
|
||||||
|
"woocommerce": {
|
||||||
|
"read": {"read_products"},
|
||||||
|
"write": {"write_products"},
|
||||||
|
"admin": {"write_products"},
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"read": {"read:repository"},
|
||||||
|
"write": {"write:repository"},
|
||||||
|
"admin": {"admin:repo_hook"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aliases that plugins may return from their probe which should be
|
||||||
|
# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS.
|
||||||
|
# Keeps the adapter implementations honest about what the upstream
|
||||||
|
# service actually says while letting the evaluator compare sets.
|
||||||
|
_CAP_ALIASES: dict[str, set[str]] = {
|
||||||
|
"manage_options": {"administrator", "manage_options"},
|
||||||
|
"edit_posts": {"edit_posts", "editor", "administrator"},
|
||||||
|
"upload_files": {"upload_files", "editor", "administrator"},
|
||||||
|
"read": {"read", "subscriber", "contributor", "author", "editor", "administrator"},
|
||||||
|
"read_products": {"read_products", "read", "read_write"},
|
||||||
|
"write_products": {"write_products", "write", "read_write"},
|
||||||
|
"read_orders": {"read_orders", "read", "read_write"},
|
||||||
|
"write_orders": {"write_orders", "write", "read_write"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_matches(required: str, granted: set[str]) -> bool:
|
||||||
|
"""Return True if ``required`` is satisfied by any cap in ``granted``.
|
||||||
|
|
||||||
|
Uses ``_CAP_ALIASES`` to accept role names (WP) and permission
|
||||||
|
strings (WC) alongside the canonical capability names.
|
||||||
|
"""
|
||||||
|
if required in granted:
|
||||||
|
return True
|
||||||
|
aliases = _CAP_ALIASES.get(required, {required})
|
||||||
|
return bool(aliases & granted)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_tier_fit(
|
||||||
|
plugin_type: str,
|
||||||
|
tier: str | None,
|
||||||
|
probe_payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether ``probe.granted`` covers what ``tier`` requires.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: e.g. "wordpress", "woocommerce", "gitea".
|
||||||
|
tier: the site's selected ``tool_scope`` preset (read / write /
|
||||||
|
admin / custom / None).
|
||||||
|
probe_payload: the dict returned by ``probe_site_capabilities``
|
||||||
|
(must contain ``probe_available`` and ``granted``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with:
|
||||||
|
* ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable``
|
||||||
|
| ``unknown_tier``.
|
||||||
|
* ``required``: list[str] of caps the tier needs (empty when
|
||||||
|
the tier is ``custom`` / not in the table).
|
||||||
|
* ``missing``: list[str] of required caps not present in
|
||||||
|
``granted``.
|
||||||
|
* ``reason``: passthrough from the probe when probe is
|
||||||
|
unavailable, else ``None``.
|
||||||
|
|
||||||
|
``custom`` tier always returns ``status='ok'`` because by definition
|
||||||
|
the caller picked individual tools — we can't check a tier-level
|
||||||
|
contract.
|
||||||
|
"""
|
||||||
|
tier_norm = (tier or "").strip().lower()
|
||||||
|
|
||||||
|
if not probe_payload.get("probe_available", False):
|
||||||
|
return {
|
||||||
|
"status": "probe_unavailable",
|
||||||
|
"required": [],
|
||||||
|
"missing": [],
|
||||||
|
"reason": probe_payload.get("reason"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if tier_norm in {"", "custom"}:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"required": [],
|
||||||
|
"missing": [],
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm)
|
||||||
|
if requirements is None:
|
||||||
|
return {
|
||||||
|
"status": "unknown_tier",
|
||||||
|
"required": [],
|
||||||
|
"missing": [],
|
||||||
|
"reason": f"no_tier_table_for:{plugin_type}/{tier_norm}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# F.X.fix #5: the probe places WP role names under ``roles`` and
|
||||||
|
# individual capability strings under ``granted`` — historically we
|
||||||
|
# only compared against ``granted``, so the ``read`` tier always
|
||||||
|
# reported ``warning: Missing read`` even for admin users, because
|
||||||
|
# ``read`` is implied by every role but not emitted as a bare cap
|
||||||
|
# in the companion's capability map. Union the two sets so the
|
||||||
|
# alias resolver in ``_cap_matches`` can see roles too.
|
||||||
|
granted = set(probe_payload.get("granted") or [])
|
||||||
|
roles = set(probe_payload.get("roles") or [])
|
||||||
|
effective = granted | roles
|
||||||
|
missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "warning" if missing else "ok",
|
||||||
|
"required": sorted(requirements),
|
||||||
|
"missing": missing,
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_site_capabilities(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the capability probe payload for a user-owned site.
|
||||||
|
|
||||||
|
Uses a 10-minute per-site TTL cache unless ``force=True``.
|
||||||
|
Response shape:
|
||||||
|
|
||||||
|
{
|
||||||
|
"site_id": str,
|
||||||
|
"plugin_type": str,
|
||||||
|
"probe_available": bool,
|
||||||
|
"granted": list[str],
|
||||||
|
"source": str,
|
||||||
|
"reason": str | None, # only when probe_available=False
|
||||||
|
"cached": bool,
|
||||||
|
# + any plugin-specific extras (roles, plugin_version, ...)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
from plugins import registry as plugin_registry
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
return {
|
||||||
|
"site_id": site_id,
|
||||||
|
"plugin_type": None,
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": "site_not_found",
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
cached = _cache.get(site_id)
|
||||||
|
if cached is not None:
|
||||||
|
out = dict(cached)
|
||||||
|
out["cached"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
plugin_type = site["plugin_type"]
|
||||||
|
if not plugin_registry.is_registered(plugin_type):
|
||||||
|
return {
|
||||||
|
"site_id": site_id,
|
||||||
|
"plugin_type": plugin_type,
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": f"plugin_not_registered:{plugin_type}",
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc)
|
||||||
|
return {
|
||||||
|
"site_id": site_id,
|
||||||
|
"plugin_type": plugin_type,
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": "credentials_decrypt_failed",
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
config_dict: dict[str, Any] = {
|
||||||
|
"site_url": site["url"],
|
||||||
|
"url": site["url"],
|
||||||
|
"alias": site["alias"],
|
||||||
|
"user_id": user_id,
|
||||||
|
"site_id": site_id,
|
||||||
|
**credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
instance = plugin_registry.create_instance(
|
||||||
|
plugin_type,
|
||||||
|
project_id=f"probe_{site_id}",
|
||||||
|
config=config_dict,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc)
|
||||||
|
return {
|
||||||
|
"site_id": site_id,
|
||||||
|
"plugin_type": plugin_type,
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": f"plugin_instantiation_failed: {exc}",
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await instance.probe_credential_capabilities()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc)
|
||||||
|
result = {
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": f"probe_call_failed: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"site_id": site_id,
|
||||||
|
"plugin_type": plugin_type,
|
||||||
|
"probe_available": bool(result.get("probe_available", False)),
|
||||||
|
"granted": list(result.get("granted") or []),
|
||||||
|
"source": result.get("source") or "unavailable",
|
||||||
|
}
|
||||||
|
if not payload["probe_available"]:
|
||||||
|
payload["reason"] = result.get("reason")
|
||||||
|
# F.X.fix #3: propagate install_hint so the dashboard can render
|
||||||
|
# the "site unreachable / install companion" prompt without a
|
||||||
|
# second probe call.
|
||||||
|
# F.X.fix-pass3: also propagate routes + features so the
|
||||||
|
# tool-prerequisites resolver in core/tool_access can compute
|
||||||
|
# tool availability without a second probe call.
|
||||||
|
# F.X.fix-pass5: also propagate wp_credentials_present so the
|
||||||
|
# prerequisites resolver can auto-disable WC media tools when
|
||||||
|
# the site has no WP Application Password configured.
|
||||||
|
for extra in (
|
||||||
|
"roles",
|
||||||
|
"plugin_version",
|
||||||
|
"install_hint",
|
||||||
|
"routes",
|
||||||
|
"features",
|
||||||
|
"wp_credentials_present",
|
||||||
|
):
|
||||||
|
if extra in result:
|
||||||
|
payload[extra] = result[extra]
|
||||||
|
|
||||||
|
# F.X.fix-pass2 — surface the site's configured AI-provider set so
|
||||||
|
# the badge can show a distinct "no AI provider key" warning
|
||||||
|
# independent of tier fit. Even an Administrator WP credential
|
||||||
|
# can't run the AI image tool without a provider key. Cheap: one
|
||||||
|
# SQLite query over site_provider_keys.
|
||||||
|
try:
|
||||||
|
from core.site_api import list_site_providers_set
|
||||||
|
|
||||||
|
payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc)
|
||||||
|
payload["ai_providers_configured"] = []
|
||||||
|
|
||||||
|
# Cache everything, including the "probe unavailable" answer — a
|
||||||
|
# missing companion plugin is a stable fact until the operator
|
||||||
|
# installs it and re-tests the connection.
|
||||||
|
_cache.set(site_id, payload)
|
||||||
|
payload_out = dict(payload)
|
||||||
|
payload_out["cached"] = False
|
||||||
|
return payload_out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Starlette handler: GET /api/sites/{id}/capabilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def api_site_capabilities(request: Request) -> JSONResponse:
|
||||||
|
"""Return the capability probe for a user-owned site.
|
||||||
|
|
||||||
|
Auth: same OAuth user session guard as the other site endpoints.
|
||||||
|
Query params:
|
||||||
|
* ``force=1`` — bypass the 10-minute cache.
|
||||||
|
"""
|
||||||
|
from core.dashboard.routes import _require_user_session
|
||||||
|
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect or user_session is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
site_id = (request.path_params.get("id") or "").strip()
|
||||||
|
if not site_id:
|
||||||
|
return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400)
|
||||||
|
|
||||||
|
force = request.query_params.get("force") in {"1", "true", "True"}
|
||||||
|
|
||||||
|
payload = await probe_site_capabilities(
|
||||||
|
site_id=site_id, user_id=user_session["user_id"], force=force
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("reason") == "site_not_found":
|
||||||
|
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
|
||||||
|
|
||||||
|
# F.7e: also evaluate fit against the site's currently-selected
|
||||||
|
# tool_scope tier so the UI can render the badge without needing a
|
||||||
|
# second request. The caller-supplied ?tier=... query param lets the
|
||||||
|
# dashboard preview a tier before save.
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_session["user_id"])
|
||||||
|
site_tier = (site or {}).get("tool_scope")
|
||||||
|
# Allow the caller to override the tier (e.g. preview before save).
|
||||||
|
tier_override = request.query_params.get("tier")
|
||||||
|
tier = tier_override or site_tier
|
||||||
|
|
||||||
|
fit = evaluate_tier_fit(
|
||||||
|
plugin_type=payload.get("plugin_type") or "",
|
||||||
|
tier=tier,
|
||||||
|
probe_payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
**payload,
|
||||||
|
"tier": tier,
|
||||||
|
"fit": fit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_site_capabilities_badge(request: Request):
|
||||||
|
"""F.X.fix #9 — render the capability-badge template fragment.
|
||||||
|
|
||||||
|
Used by the HTMX Re-check button so the badge swaps in place
|
||||||
|
instead of forcing a full-page reload. Response is HTML (not JSON)
|
||||||
|
— the caller sets ``hx-swap="outerHTML"`` on the badge element.
|
||||||
|
"""
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from core.dashboard.routes import _require_user_session, templates
|
||||||
|
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect or user_session is None:
|
||||||
|
return HTMLResponse("<div>unauthorized</div>", status_code=401)
|
||||||
|
|
||||||
|
site_id = (request.path_params.get("id") or "").strip()
|
||||||
|
if not site_id:
|
||||||
|
return HTMLResponse("<div>invalid_request</div>", status_code=400)
|
||||||
|
|
||||||
|
force = request.query_params.get("force") in {"1", "true", "True"}
|
||||||
|
|
||||||
|
payload = await probe_site_capabilities(
|
||||||
|
site_id=site_id, user_id=user_session["user_id"], force=force
|
||||||
|
)
|
||||||
|
if payload.get("reason") == "site_not_found":
|
||||||
|
return HTMLResponse("<div>site_not_found</div>", status_code=404)
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_session["user_id"])
|
||||||
|
if site is None:
|
||||||
|
return HTMLResponse("<div>site_not_found</div>", status_code=404)
|
||||||
|
|
||||||
|
tier = request.query_params.get("tier") or site.get("tool_scope")
|
||||||
|
fit = evaluate_tier_fit(
|
||||||
|
plugin_type=payload.get("plugin_type") or "",
|
||||||
|
tier=tier,
|
||||||
|
probe_payload=payload,
|
||||||
|
)
|
||||||
|
capability_probe = {**payload, "tier": tier, "fit": fit}
|
||||||
|
|
||||||
|
# Pull up the companion download URL the same way the page does.
|
||||||
|
try:
|
||||||
|
from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL
|
||||||
|
|
||||||
|
companion_download_url: str | None = COMPANION_DOWNLOAD_URL
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
companion_download_url = None
|
||||||
|
|
||||||
|
lang = request.query_params.get("lang") or request.cookies.get("lang") or "en"
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"dashboard/sites/_capability_badge.html",
|
||||||
|
{
|
||||||
|
"capability_probe": capability_probe,
|
||||||
|
"site": site,
|
||||||
|
"lang": lang,
|
||||||
|
"companion_download_url": companion_download_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
325
core/companion_audit.py
Normal file
325
core/companion_audit.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
|
||||||
|
|
||||||
|
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
|
||||||
|
action events (post transitions, user events, plugin activations, etc.)
|
||||||
|
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
|
||||||
|
|
||||||
|
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
|
||||||
|
2. ``verify_companion_signature`` — constant-time HMAC verification.
|
||||||
|
3. ``handle_companion_audit_request`` — Starlette handler that validates
|
||||||
|
the signature, appends the event to the audit log, and returns 200.
|
||||||
|
|
||||||
|
The store is intentionally independent of the SQLite sites DB so the
|
||||||
|
webhook can land before a dashboard UI exists; the UI (future work) can
|
||||||
|
replace ``CompanionAuditSecretStore`` with DB-backed storage without
|
||||||
|
changing the wire format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcphub.companion_audit")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(raw: Any) -> float | None:
|
||||||
|
"""Coerce the envelope ``timestamp`` field to epoch seconds.
|
||||||
|
|
||||||
|
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
|
||||||
|
companion plugin's current format via PHP ``gmdate``) or a numeric
|
||||||
|
epoch value (for forward compatibility with other senders).
|
||||||
|
Returns None on any parse failure so callers can emit a uniform
|
||||||
|
401 without leaking which step failed.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, int | float):
|
||||||
|
return float(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Python 3.11+ fromisoformat handles "Z" suffix; on older
|
||||||
|
# versions we translate it to +00:00 explicitly.
|
||||||
|
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(iso).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Numeric-string fallback (e.g. "1712345678").
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
|
||||||
|
# (or malicious) companion can't queue up unbounded memory with a
|
||||||
|
# single request. Real audit events are well under 4 KB; 64 KB is a
|
||||||
|
# generous ceiling that still fits within Starlette's default limit.
|
||||||
|
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
|
||||||
|
|
||||||
|
# Replay-protection window (seconds). Events whose ``timestamp`` field
|
||||||
|
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
|
||||||
|
# Set to 0 or negative to disable (not recommended). Default 5 minutes
|
||||||
|
# balances clock-skew tolerance against replay risk.
|
||||||
|
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_url(url: str) -> str:
|
||||||
|
return url.rstrip("/").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class CompanionAuditSecretStore:
|
||||||
|
"""File-backed ``site_url -> shared_secret`` map.
|
||||||
|
|
||||||
|
The file is JSON; keys are normalised (lowercased, trailing slash
|
||||||
|
stripped). Access is serialised through a lock to keep reads and
|
||||||
|
writes atomic on a single-process server. Multi-process deployments
|
||||||
|
(gunicorn workers) can still race on the write path — that's fine
|
||||||
|
because the dashboard UI will be the only writer and operates in
|
||||||
|
the master process for this MVP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cache: dict[str, str] | None = None
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, str]:
|
||||||
|
if self._cache is not None:
|
||||||
|
return self._cache
|
||||||
|
if not self.path.exists():
|
||||||
|
self._cache = {}
|
||||||
|
return self._cache
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning(
|
||||||
|
"companion audit secret file %s is not a JSON object; ignoring.",
|
||||||
|
self.path,
|
||||||
|
)
|
||||||
|
self._cache = {}
|
||||||
|
else:
|
||||||
|
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
|
||||||
|
self._cache = {}
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
def _save(self, data: dict[str, str]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
tmp.replace(self.path)
|
||||||
|
try:
|
||||||
|
os.chmod(self.path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._cache = dict(data)
|
||||||
|
|
||||||
|
def get(self, site_url: str) -> str | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._load().get(_normalise_url(site_url))
|
||||||
|
|
||||||
|
def set(self, site_url: str, secret: str) -> None:
|
||||||
|
if not secret or len(secret) < 16:
|
||||||
|
raise ValueError("companion audit secret must be at least 16 characters")
|
||||||
|
with self._lock:
|
||||||
|
data = dict(self._load())
|
||||||
|
data[_normalise_url(site_url)] = secret
|
||||||
|
self._save(data)
|
||||||
|
|
||||||
|
def delete(self, site_url: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
data = dict(self._load())
|
||||||
|
key = _normalise_url(site_url)
|
||||||
|
if key not in data:
|
||||||
|
return False
|
||||||
|
del data[key]
|
||||||
|
self._save(data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_sites(self) -> list[dict[str, Any]]:
|
||||||
|
"""List sites with secret metadata only — never returns plaintext."""
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"site_url": site_url,
|
||||||
|
"secret_set": True,
|
||||||
|
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
|
||||||
|
}
|
||||||
|
for site_url, secret in self._load().items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_STORE_PATH = Path(
|
||||||
|
os.environ.get(
|
||||||
|
"COMPANION_AUDIT_SECRETS_PATH",
|
||||||
|
"/tmp/mcphub-data/companion-audit-secrets.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_default_store: CompanionAuditSecretStore | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_companion_audit_store(
|
||||||
|
path: str | Path | None = None,
|
||||||
|
) -> CompanionAuditSecretStore:
|
||||||
|
global _default_store
|
||||||
|
if path is not None:
|
||||||
|
return CompanionAuditSecretStore(path)
|
||||||
|
if _default_store is None:
|
||||||
|
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
|
||||||
|
return _default_store
|
||||||
|
|
||||||
|
|
||||||
|
def verify_companion_signature(
|
||||||
|
body_bytes: bytes, signature_header: str | None, secret: str
|
||||||
|
) -> bool:
|
||||||
|
"""Constant-time HMAC-SHA256 verification.
|
||||||
|
|
||||||
|
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
|
||||||
|
Returns False on any shape error so callers can emit 401 uniformly.
|
||||||
|
"""
|
||||||
|
if not signature_header or not secret:
|
||||||
|
return False
|
||||||
|
sig = signature_header.strip()
|
||||||
|
if sig.startswith("sha256="):
|
||||||
|
sig = sig[len("sha256=") :]
|
||||||
|
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
|
||||||
|
return False
|
||||||
|
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(expected.lower(), sig.lower())
|
||||||
|
|
||||||
|
|
||||||
|
# Accepted event names — mirror the PHP side. Events outside this list
|
||||||
|
# are still accepted but tagged so operators can spot rogue sources.
|
||||||
|
_KNOWN_EVENTS = frozenset(
|
||||||
|
{
|
||||||
|
"transition_post_status",
|
||||||
|
"deleted_post",
|
||||||
|
"user_register",
|
||||||
|
"profile_update",
|
||||||
|
"deleted_user",
|
||||||
|
"activated_plugin",
|
||||||
|
"deactivated_plugin",
|
||||||
|
"switch_theme",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_companion_audit_request(request: Request) -> JSONResponse:
|
||||||
|
"""Starlette handler for ``POST /api/companion-audit``.
|
||||||
|
|
||||||
|
Validates the HMAC signature using a per-site secret, parses the
|
||||||
|
envelope, applies a replay window on the ``timestamp`` field, and
|
||||||
|
writes an audit-log entry. Returns 200 on success, 400 on shape
|
||||||
|
errors, 401 on signature/replay failure, 413 on oversized body.
|
||||||
|
"""
|
||||||
|
# Pre-F.20 security sweep: bound the incoming body before reading the
|
||||||
|
# whole thing. Prefer the framework's Content-Length hint; fall back
|
||||||
|
# to reading up to one byte past the ceiling so we can cleanly 413.
|
||||||
|
content_length_header = request.headers.get("content-length")
|
||||||
|
if content_length_header:
|
||||||
|
try:
|
||||||
|
content_length = int(content_length_header)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
|
||||||
|
if content_length > _MAX_BODY_BYTES:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read raw body — we need the exact bytes for HMAC verification.
|
||||||
|
body_bytes = await request.body()
|
||||||
|
if len(body_bytes) > _MAX_BODY_BYTES:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
if not body_bytes:
|
||||||
|
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
|
||||||
|
|
||||||
|
site_header = request.headers.get("X-Airano-MCP-Site") or ""
|
||||||
|
signature = request.headers.get("X-Airano-MCP-Signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
envelope = json.loads(body_bytes.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
|
||||||
|
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
|
||||||
|
|
||||||
|
site_url = str(envelope.get("site_url") or site_header or "")
|
||||||
|
if not site_url:
|
||||||
|
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
|
||||||
|
|
||||||
|
store = get_companion_audit_store()
|
||||||
|
secret = store.get(site_url)
|
||||||
|
if secret is None:
|
||||||
|
# Don't leak whether the site exists; same response as a bad sig.
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
if not verify_companion_signature(body_bytes, signature, secret):
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
# Pre-F.20 security sweep: enforce a replay window on the signed
|
||||||
|
# timestamp. Captured webhooks replayed outside the window are
|
||||||
|
# rejected with 401 (same opaque response as a bad signature to
|
||||||
|
# avoid giving attackers a distinguishing oracle).
|
||||||
|
if _REPLAY_WINDOW_SECONDS > 0:
|
||||||
|
ts = _parse_timestamp(envelope.get("timestamp"))
|
||||||
|
if ts is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
now = time.time()
|
||||||
|
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"companion audit replay rejected site=%s skew=%.1fs",
|
||||||
|
site_url,
|
||||||
|
now - ts,
|
||||||
|
)
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
event_name = str(envelope.get("event") or "unknown")
|
||||||
|
details = {
|
||||||
|
"site_url": site_url,
|
||||||
|
"event": event_name,
|
||||||
|
"known_event": event_name in _KNOWN_EVENTS,
|
||||||
|
"timestamp": envelope.get("timestamp"),
|
||||||
|
"wp_user_id": envelope.get("user_id"),
|
||||||
|
"plugin_version": envelope.get("plugin_version"),
|
||||||
|
"data": envelope.get("data"),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Deferred import — avoid pulling audit_log into the store for tests.
|
||||||
|
from core.audit_log import get_audit_logger
|
||||||
|
|
||||||
|
audit_logger = get_audit_logger()
|
||||||
|
audit_logger.log_system_event(
|
||||||
|
event=f"companion_audit:{event_name}",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("failed to append companion audit event: %s", exc)
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "audit_write_failed"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "event": event_name}, status_code=200)
|
||||||
128
core/config_snippets.py
Normal file
128
core/config_snippets.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""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_connectors",
|
||||||
|
"label": "Claude.ai Connectors",
|
||||||
|
"description": "claude.ai/customize/connectors",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Clients that only need a URL (no JSON config snippet, no transport Note)
|
||||||
|
WEB_CLIENTS = {"claude_connectors", "chatgpt"}
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# Claude Desktop uses streamableHttp; Claude Code, VS Code, Cursor use http
|
||||||
|
transport_type = "streamableHttp" if client_type == "claude_desktop" else "http"
|
||||||
|
|
||||||
|
if client_type in ("claude_desktop", "claude_code"):
|
||||||
|
config = {
|
||||||
|
"mcpServers": {
|
||||||
|
server_name: {
|
||||||
|
"type": transport_type,
|
||||||
|
"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: {
|
||||||
|
"type": transport_type,
|
||||||
|
"url": endpoint_url,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
elif client_type in ("claude_connectors", "chatgpt"):
|
||||||
|
return endpoint_url
|
||||||
|
|
||||||
|
else:
|
||||||
|
valid = [c["id"] for c in SUPPORTED_CLIENTS]
|
||||||
|
raise ValueError(f"Unsupported client type '{client_type}'. Valid: {valid}")
|
||||||
@@ -13,6 +13,8 @@ from .routes import (
|
|||||||
# K.3: API Keys routes
|
# K.3: API Keys routes
|
||||||
dashboard_api_keys_list,
|
dashboard_api_keys_list,
|
||||||
dashboard_api_keys_revoke,
|
dashboard_api_keys_revoke,
|
||||||
|
# K.1: Core routes
|
||||||
|
dashboard_api_login,
|
||||||
dashboard_api_project_detail,
|
dashboard_api_project_detail,
|
||||||
dashboard_api_projects,
|
dashboard_api_projects,
|
||||||
dashboard_api_stats,
|
dashboard_api_stats,
|
||||||
@@ -22,7 +24,6 @@ from .routes import (
|
|||||||
dashboard_health_page,
|
dashboard_health_page,
|
||||||
dashboard_health_projects_partial,
|
dashboard_health_projects_partial,
|
||||||
dashboard_home,
|
dashboard_home,
|
||||||
# K.1: Core routes
|
|
||||||
dashboard_login_page,
|
dashboard_login_page,
|
||||||
dashboard_login_submit,
|
dashboard_login_submit,
|
||||||
dashboard_logout,
|
dashboard_logout,
|
||||||
@@ -44,6 +45,7 @@ __all__ = [
|
|||||||
"get_dashboard_auth",
|
"get_dashboard_auth",
|
||||||
"register_dashboard_routes",
|
"register_dashboard_routes",
|
||||||
# K.1
|
# K.1
|
||||||
|
"dashboard_api_login",
|
||||||
"dashboard_login_page",
|
"dashboard_login_page",
|
||||||
"dashboard_login_submit",
|
"dashboard_login_submit",
|
||||||
"dashboard_logout",
|
"dashboard_logout",
|
||||||
|
|||||||
@@ -89,11 +89,22 @@ class DashboardAuth:
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
return False, "", None
|
return False, "", None
|
||||||
|
|
||||||
|
api_key_clean = api_key.strip()
|
||||||
|
# Check if master key dashboard login is disabled
|
||||||
|
master_login_disabled = (
|
||||||
|
os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
# Check master API key (from env var)
|
# Check master API key (from env var)
|
||||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
if (
|
||||||
|
not master_login_disabled
|
||||||
|
and self.master_api_key
|
||||||
|
and secrets.compare_digest(api_key_clean, self.master_api_key.strip())
|
||||||
|
):
|
||||||
return True, "master", None
|
return True, "master", None
|
||||||
|
|
||||||
# Check AuthManager's master key (covers auto-generated temp keys)
|
# Check AuthManager's master key (covers auto-generated temp keys)
|
||||||
|
if not master_login_disabled:
|
||||||
try:
|
try:
|
||||||
from core.auth import get_auth_manager
|
from core.auth import get_auth_manager
|
||||||
|
|
||||||
@@ -202,6 +213,9 @@ class DashboardAuth:
|
|||||||
user_type=payload["type"],
|
user_type=payload["type"],
|
||||||
key_id=payload.get("kid"),
|
key_id=payload.get("kid"),
|
||||||
)
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
|
||||||
|
return None
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.debug("Dashboard session expired")
|
logger.debug("Dashboard session expired")
|
||||||
return None
|
return None
|
||||||
@@ -224,6 +238,27 @@ class DashboardAuth:
|
|||||||
return None
|
return None
|
||||||
return self.validate_session(token)
|
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:
|
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Set session cookie on response.
|
Set session cookie on response.
|
||||||
@@ -273,7 +308,9 @@ class DashboardAuth:
|
|||||||
RedirectResponse to login page if not authenticated, None if OK.
|
RedirectResponse to login page if not authenticated, None if OK.
|
||||||
"""
|
"""
|
||||||
session = self.get_session_from_request(request)
|
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
|
# Store original URL for redirect after login
|
||||||
next_url = str(request.url.path)
|
next_url = str(request.url.path)
|
||||||
if request.url.query:
|
if request.url.query:
|
||||||
@@ -291,3 +328,58 @@ def get_dashboard_auth() -> DashboardAuth:
|
|||||||
if _dashboard_auth is None:
|
if _dashboard_auth is None:
|
||||||
_dashboard_auth = DashboardAuth()
|
_dashboard_auth = DashboardAuth()
|
||||||
return _dashboard_auth
|
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):
|
||||||
|
session_type = "admin" if session.get("role") == "admin" else "user"
|
||||||
|
return {
|
||||||
|
"name": session.get("name") or session.get("email", "User"),
|
||||||
|
"type": session_type,
|
||||||
|
"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
345
core/dashboard/spa_routes.py
Normal file
345
core/dashboard/spa_routes.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
SPA Routes (Track G) — React SPA support on /dashboard/*.
|
||||||
|
|
||||||
|
This module exposes:
|
||||||
|
|
||||||
|
- ``GET /api/me`` — JSON description of the current session (no redirect side
|
||||||
|
effects). Used by the React SPA to decide whether to show login or dashboard.
|
||||||
|
- ``GET /api/i18n/{lang}`` — JSON dump of the existing
|
||||||
|
``DASHBOARD_TRANSLATIONS`` so the SPA can reuse the copy without duplication.
|
||||||
|
- ``GET /dashboard`` and ``GET /dashboard/{path:path}`` — catch-all that
|
||||||
|
serves the SPA's compiled ``index.html``. Static asset handling is wired up
|
||||||
|
in ``register_spa_routes`` via a ``StaticFiles`` mount.
|
||||||
|
|
||||||
|
Coexistence: the legacy Jinja UI lives at ``/dashboard-legacy/*`` while the
|
||||||
|
SPA owns ``/dashboard/*``. Old ``/dashboard-v2/*`` links are redirected to the
|
||||||
|
new prefix by the main Starlette app (see Track G.12 in ROADMAP.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .auth import get_dashboard_auth, is_admin_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Path to the Vite build output. Templates dir lives one level above this file.
|
||||||
|
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
||||||
|
SPA_DIST_DIR = os.path.join(_TEMPLATES_DIR, "static", "dist")
|
||||||
|
|
||||||
|
# Marker placed just before </head> so the analytics injector has a stable,
|
||||||
|
# build-tool-agnostic insertion point.
|
||||||
|
_HEAD_CLOSE = "</head>"
|
||||||
|
|
||||||
|
|
||||||
|
def _spa_index_path() -> str:
|
||||||
|
return os.path.join(SPA_DIST_DIR, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
def _analytics_snippet() -> str:
|
||||||
|
"""Return analytics <script> tags assembled from env vars.
|
||||||
|
|
||||||
|
Each provider is independent: leaving its env vars unset silently skips
|
||||||
|
that block. Returns an empty string when nothing is configured so the
|
||||||
|
injection is a no-op.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
umami_id = os.environ.get("UMAMI_WEBSITE_ID", "").strip()
|
||||||
|
umami_url = os.environ.get("UMAMI_URL", "").strip()
|
||||||
|
if umami_id and umami_url:
|
||||||
|
parts.append(f'<script defer src="{umami_url}" data-website-id="{umami_id}"></script>')
|
||||||
|
|
||||||
|
op_api = os.environ.get("OPENPANEL_API_URL", "").strip()
|
||||||
|
op_client = os.environ.get("OPENPANEL_CLIENT_ID", "").strip()
|
||||||
|
if op_api and op_client:
|
||||||
|
# OpenPanel ships a small bootstrap that loads their tracker SDK.
|
||||||
|
parts.append(
|
||||||
|
"<script>"
|
||||||
|
"(function(){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments);};"
|
||||||
|
f'window.op("init",{{apiUrl:"{op_api}",clientId:"{op_client}"}});'
|
||||||
|
"var s=document.createElement('script');s.async=1;"
|
||||||
|
f's.src="{op_api.rstrip("/")}/script.js";'
|
||||||
|
"document.head.appendChild(s);})();"
|
||||||
|
"</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_spa_index(index_path: str) -> bytes:
|
||||||
|
"""Read the built index.html and inject analytics tags before </head>.
|
||||||
|
|
||||||
|
Falls back to the unmodified bytes when no provider is configured or
|
||||||
|
when the </head> marker is somehow missing (older build).
|
||||||
|
"""
|
||||||
|
with open(index_path, "rb") as fh:
|
||||||
|
html = fh.read()
|
||||||
|
snippet = _analytics_snippet()
|
||||||
|
if not snippet:
|
||||||
|
return html
|
||||||
|
marker = _HEAD_CLOSE.encode("utf-8")
|
||||||
|
if marker not in html:
|
||||||
|
return html
|
||||||
|
return html.replace(marker, snippet.encode("utf-8") + marker, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _master_key_login_enabled() -> bool:
|
||||||
|
"""Return True when admin login by master API key is enabled.
|
||||||
|
|
||||||
|
Mirrors the env semantics used in ``core/dashboard/auth.py`` and
|
||||||
|
``core/dashboard/routes.py``: the env var inverts the meaning, so
|
||||||
|
``DISABLE_MASTER_KEY_LOGIN=true`` → master-key form is hidden.
|
||||||
|
"""
|
||||||
|
return os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() != "true"
|
||||||
|
|
||||||
|
|
||||||
|
async def _max_sites_per_user() -> int:
|
||||||
|
from core.settings import get_setting
|
||||||
|
|
||||||
|
try:
|
||||||
|
val = await get_setting("MAX_SITES_PER_USER", "10")
|
||||||
|
return max(0, int(val or "10"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 10
|
||||||
|
|
||||||
|
|
||||||
|
async def api_me(request: Request) -> Response:
|
||||||
|
"""Return JSON describing the current session.
|
||||||
|
|
||||||
|
Returns ``{"authenticated": false}`` with status 200 when the caller has no
|
||||||
|
valid session, instead of redirecting — this lets the SPA render its public
|
||||||
|
pages without round-trips.
|
||||||
|
|
||||||
|
Also exposes the per-request ``csrf_token`` (set by ``DashboardCSRFMiddleware``
|
||||||
|
on every request) and the ``master_key_login_enabled`` flag so the SPA can
|
||||||
|
submit guarded POSTs and conditionally render the admin-key form. The
|
||||||
|
underlying ``dashboard_csrf`` cookie stays HttpOnly; the JSON copy is the
|
||||||
|
only path JS has to read it.
|
||||||
|
"""
|
||||||
|
# Lazy import: avoids circular import with .routes (which imports from
|
||||||
|
# spa_routes is fine, but other dependents of routes.py shouldn't trigger
|
||||||
|
# heavy imports here).
|
||||||
|
from .routes import detect_language
|
||||||
|
|
||||||
|
auth = get_dashboard_auth()
|
||||||
|
admin_session = auth.get_session_from_request(request)
|
||||||
|
user_session = auth.get_user_session_from_request(request)
|
||||||
|
session = admin_session or user_session
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language")
|
||||||
|
query_lang = request.query_params.get("lang")
|
||||||
|
lang = detect_language(accept_language, query_lang)
|
||||||
|
|
||||||
|
csrf_token = getattr(request.state, "csrf_token", None)
|
||||||
|
master_key_login_enabled = _master_key_login_enabled()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"authenticated": False,
|
||||||
|
"is_admin": False,
|
||||||
|
"lang": lang,
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"master_key_login_enabled": master_key_login_enabled,
|
||||||
|
"max_sites_per_user": await _max_sites_per_user(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check is_admin against the *resolved* session, not just `admin_session`.
|
||||||
|
# Master-key login intentionally swaps the admin session token for a user
|
||||||
|
# session token (so the master admin can access My Sites etc.), which
|
||||||
|
# left `admin_session` as None and forced `is_admin` to False even though
|
||||||
|
# the user session dict carries role="admin". The SPA's RequireAuth then
|
||||||
|
# redirected admin-only routes (Health, OAuth clients, Audit logs) back
|
||||||
|
# to /sites for the master admin. Use `is_admin_session(session)` so the
|
||||||
|
# check works regardless of which slot the cookie resolved into.
|
||||||
|
is_admin = is_admin_session(session)
|
||||||
|
user_id = None
|
||||||
|
email = None
|
||||||
|
name = None
|
||||||
|
role = "user"
|
||||||
|
sess_type = "oauth_user"
|
||||||
|
|
||||||
|
# Extract fields defensively — DashboardSession and user-session dicts
|
||||||
|
# have different shapes.
|
||||||
|
if isinstance(session, dict):
|
||||||
|
user_id = session.get("user_id") or session.get("uid")
|
||||||
|
email = session.get("email")
|
||||||
|
name = session.get("name")
|
||||||
|
role = session.get("role") or ("admin" if is_admin else "user")
|
||||||
|
sess_type = session.get("type") or "oauth_user"
|
||||||
|
else:
|
||||||
|
# DashboardSession dataclass
|
||||||
|
user_id = getattr(session, "user_id", None) or getattr(session, "sid", None)
|
||||||
|
email = getattr(session, "email", None)
|
||||||
|
name = getattr(session, "name", None)
|
||||||
|
role = getattr(session, "role", None) or ("admin" if is_admin else "user")
|
||||||
|
sess_type = getattr(session, "type", None) or "master"
|
||||||
|
if sess_type in {"master", "api_key"}:
|
||||||
|
is_admin = True
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"authenticated": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
"role": role,
|
||||||
|
"type": sess_type,
|
||||||
|
"is_admin": bool(is_admin),
|
||||||
|
"lang": lang,
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"master_key_login_enabled": master_key_login_enabled,
|
||||||
|
"max_sites_per_user": await _max_sites_per_user(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_i18n(request: Request) -> Response:
|
||||||
|
"""Return the dashboard translation dictionary for the requested language."""
|
||||||
|
from .routes import DASHBOARD_TRANSLATIONS, get_translations
|
||||||
|
|
||||||
|
lang = request.path_params.get("lang", "en")
|
||||||
|
if lang not in DASHBOARD_TRANSLATIONS:
|
||||||
|
lang = "en"
|
||||||
|
return JSONResponse(get_translations(lang))
|
||||||
|
|
||||||
|
|
||||||
|
async def api_plugins(request: Request) -> Response:
|
||||||
|
"""Return the plugin catalog + per-plugin credential field definitions.
|
||||||
|
|
||||||
|
Used by the SPA's Site Add/Edit dialog to render the correct form
|
||||||
|
for the selected plugin without bouncing to the legacy Jinja page.
|
||||||
|
Admins see every plugin; user sessions see only the ones flagged
|
||||||
|
public by ``ENABLED_PLUGINS``. The shape mirrors the same map that
|
||||||
|
the Jinja form binds to so backend validation stays identical.
|
||||||
|
"""
|
||||||
|
auth = get_dashboard_auth()
|
||||||
|
admin_session = auth.get_session_from_request(request)
|
||||||
|
user_session = auth.get_user_session_from_request(request)
|
||||||
|
if admin_session is None and user_session is None:
|
||||||
|
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
from core.site_api import get_user_credential_fields, get_user_plugin_names
|
||||||
|
|
||||||
|
is_admin = is_admin_session(admin_session or user_session)
|
||||||
|
fields = get_user_credential_fields(is_admin=is_admin)
|
||||||
|
names = get_user_plugin_names(is_admin=is_admin)
|
||||||
|
|
||||||
|
plugins = [
|
||||||
|
{"type": ptype, "name": names.get(ptype, ptype), "fields": flds}
|
||||||
|
for ptype, flds in fields.items()
|
||||||
|
]
|
||||||
|
plugins.sort(key=lambda p: p["name"].lower())
|
||||||
|
return JSONResponse({"plugins": plugins})
|
||||||
|
|
||||||
|
|
||||||
|
async def serve_spa(_request: Request, status_code: int = 200) -> Response:
|
||||||
|
"""Serve the SPA's compiled ``index.html`` for any /dashboard/* path."""
|
||||||
|
index = _spa_index_path()
|
||||||
|
if not os.path.exists(index):
|
||||||
|
# Friendly fallback when running before the first frontend build.
|
||||||
|
return Response(
|
||||||
|
(
|
||||||
|
"<!doctype html><html><body style='font-family:sans-serif;padding:40px'>"
|
||||||
|
"<h1>Dashboard SPA not built</h1>"
|
||||||
|
"<p>Run <code>cd web && npm install && npm run build</code> to "
|
||||||
|
"generate <code>core/templates/static/dist/index.html</code>, "
|
||||||
|
"or use the legacy dashboard at "
|
||||||
|
"<a href='/dashboard-legacy'>/dashboard-legacy</a>.</p>"
|
||||||
|
"</body></html>"
|
||||||
|
),
|
||||||
|
status_code=status_code,
|
||||||
|
media_type="text/html",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
_render_spa_index(index),
|
||||||
|
status_code=status_code,
|
||||||
|
media_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def redirect_dashboard_v2(request: Request) -> Response:
|
||||||
|
"""308 redirect old /dashboard-v2/* URLs to the new /dashboard/* prefix."""
|
||||||
|
suffix = request.path_params.get("path", "")
|
||||||
|
target = "/dashboard"
|
||||||
|
if suffix:
|
||||||
|
target += f"/{suffix}"
|
||||||
|
elif request.url.path.endswith("/"):
|
||||||
|
target += "/"
|
||||||
|
if request.url.query:
|
||||||
|
target += f"?{request.url.query}"
|
||||||
|
return RedirectResponse(url=target, status_code=308)
|
||||||
|
|
||||||
|
|
||||||
|
def register_spa_routes(mcp: Any) -> None:
|
||||||
|
"""Register the SPA support routes on a FastMCP instance.
|
||||||
|
|
||||||
|
Mounts:
|
||||||
|
- ``/static/dist`` (StaticFiles) for hashed JS/CSS/asset bundles.
|
||||||
|
- ``/api/me`` and ``/api/i18n/{lang}`` JSON endpoints.
|
||||||
|
- ``/dashboard`` and ``/dashboard/{path:path}`` SPA index serving.
|
||||||
|
"""
|
||||||
|
logger.info("Registering SPA (/dashboard) routes...")
|
||||||
|
|
||||||
|
# JSON helpers
|
||||||
|
mcp.custom_route("/api/me", methods=["GET"])(api_me)
|
||||||
|
mcp.custom_route("/api/i18n/{lang}", methods=["GET"])(api_i18n)
|
||||||
|
mcp.custom_route("/api/plugins", methods=["GET"])(api_plugins)
|
||||||
|
|
||||||
|
# SPA catch-alls. Both the bare path and any sub-path resolve to index.html
|
||||||
|
# so react-router-dom can take over.
|
||||||
|
mcp.custom_route("/dashboard", methods=["GET"])(serve_spa)
|
||||||
|
mcp.custom_route("/dashboard/", methods=["GET"])(serve_spa)
|
||||||
|
mcp.custom_route("/dashboard/{path:path}", methods=["GET"])(serve_spa)
|
||||||
|
mcp.custom_route("/dashboard-v2", methods=["GET"])(redirect_dashboard_v2)
|
||||||
|
mcp.custom_route("/dashboard-v2/", methods=["GET"])(redirect_dashboard_v2)
|
||||||
|
mcp.custom_route("/dashboard-v2/{path:path}", methods=["GET"])(redirect_dashboard_v2)
|
||||||
|
|
||||||
|
# Static assets (JS/CSS bundles) — only mount if dist/ exists, otherwise
|
||||||
|
# FastMCP's underlying Starlette app refuses the mount on a missing dir.
|
||||||
|
if os.path.isdir(SPA_DIST_DIR):
|
||||||
|
try:
|
||||||
|
# FastMCP exposes its inner Starlette app via .http_app() in v3+.
|
||||||
|
# Fall back to .app if available.
|
||||||
|
inner_app = None
|
||||||
|
for attr in ("http_app", "app", "_app"):
|
||||||
|
candidate = getattr(mcp, attr, None)
|
||||||
|
inner_app = candidate() if callable(candidate) else candidate
|
||||||
|
if inner_app is not None:
|
||||||
|
break
|
||||||
|
if inner_app is not None and hasattr(inner_app, "mount"):
|
||||||
|
inner_app.mount(
|
||||||
|
"/static/dist",
|
||||||
|
StaticFiles(directory=SPA_DIST_DIR, check_dir=False),
|
||||||
|
name="spa-dist",
|
||||||
|
)
|
||||||
|
logger.info("Mounted SPA static at /static/dist -> %s", SPA_DIST_DIR)
|
||||||
|
else:
|
||||||
|
# If we can't mount, fall back to a per-file route handler so
|
||||||
|
# bundles still load. This is slower but functional.
|
||||||
|
async def _serve_dist_file(request: Request) -> Response:
|
||||||
|
rel = request.path_params.get("filename", "")
|
||||||
|
full = os.path.normpath(os.path.join(SPA_DIST_DIR, rel))
|
||||||
|
if not full.startswith(SPA_DIST_DIR) or not os.path.isfile(full):
|
||||||
|
return Response(status_code=404)
|
||||||
|
return FileResponse(full)
|
||||||
|
|
||||||
|
mcp.custom_route("/static/dist/{filename:path}", methods=["GET"])(_serve_dist_file)
|
||||||
|
logger.info("Registered fallback /static/dist/{path} route")
|
||||||
|
except Exception as exc: # pragma: no cover — defensive
|
||||||
|
logger.warning("Could not mount SPA static dir: %s", exc)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"SPA dist not present yet (%s); build with `cd web && npm run build`", SPA_DIST_DIR
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SPA routes registered successfully")
|
||||||
1185
core/database.py
Normal file
1185
core/database.py
Normal file
File diff suppressed because it is too large
Load Diff
241
core/encryption.py
Normal file
241
core/encryption.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
def encrypt_for_scope(self, plaintext: str, scope: str) -> bytes:
|
||||||
|
"""Encrypt a plaintext string using an arbitrary HKDF scope string.
|
||||||
|
|
||||||
|
Used when the encrypted value is not a per-site credentials blob but
|
||||||
|
still needs per-key isolation (e.g. per-site AI provider API keys
|
||||||
|
where scope is ``site_provider:{site_id}:{provider}``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plaintext: The string to encrypt.
|
||||||
|
scope: Scope string used as HKDF info for key derivation. Any
|
||||||
|
caller reading back the ciphertext must pass the same scope.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes (same wire format as :meth:`encrypt`).
|
||||||
|
"""
|
||||||
|
return self.encrypt(plaintext, scope)
|
||||||
|
|
||||||
|
def decrypt_for_scope(self, cipherdata: bytes, scope: str) -> str:
|
||||||
|
"""Decrypt cipherdata produced by :meth:`encrypt_for_scope`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipherdata: Encrypted bytes.
|
||||||
|
scope: Scope string — must exactly match what was used to encrypt.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original plaintext string.
|
||||||
|
"""
|
||||||
|
return self.decrypt(cipherdata, scope)
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -7,7 +7,7 @@ Each endpoint exposes only the tools relevant to its purpose.
|
|||||||
Endpoints:
|
Endpoints:
|
||||||
/mcp - Admin endpoint (all tools, requires Master API Key)
|
/mcp - Admin endpoint (all tools, requires Master API Key)
|
||||||
/mcp/wordpress - WordPress tools only (92 tools)
|
/mcp/wordpress - WordPress tools only (92 tools)
|
||||||
/mcp/wordpress-advanced - WordPress Advanced tools (22 tools)
|
/mcp/wordpress-specialist - WordPress Specialist tools (companion-backed)
|
||||||
/mcp/gitea - Gitea tools only (55 tools)
|
/mcp/gitea - Gitea tools only (55 tools)
|
||||||
/mcp/project/{id} - Project-specific tools
|
/mcp/project/{id} - Project-specific tools
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class EndpointType(Enum):
|
|||||||
SYSTEM = "system" # Phase X.3 - System tools only
|
SYSTEM = "system" # Phase X.3 - System tools only
|
||||||
WORDPRESS = "wordpress"
|
WORDPRESS = "wordpress"
|
||||||
WOOCOMMERCE = "woocommerce"
|
WOOCOMMERCE = "woocommerce"
|
||||||
WORDPRESS_ADVANCED = "wordpress_advanced"
|
WORDPRESS_SPECIALIST = "wordpress_specialist"
|
||||||
GITEA = "gitea"
|
GITEA = "gitea"
|
||||||
N8N = "n8n"
|
N8N = "n8n"
|
||||||
SUPABASE = "supabase" # Phase G
|
SUPABASE = "supabase" # Phase G
|
||||||
@@ -181,15 +181,28 @@ ENDPOINT_CONFIGS = {
|
|||||||
},
|
},
|
||||||
max_tools=35,
|
max_tools=35,
|
||||||
),
|
),
|
||||||
# WordPress Advanced endpoint - advanced operations
|
# F.19.1 WordPress Specialist endpoint - companion-backed advanced
|
||||||
EndpointType.WORDPRESS_ADVANCED: EndpointConfig(
|
# management (plugins/themes/users/options/cron/maintenance). No
|
||||||
path="/wordpress-advanced",
|
# Docker socket; only needs Airano MCP Bridge v2.11.0+ on the WP
|
||||||
name="WordPress Advanced",
|
# side. Currently 6 read-only tools; F.19.2 will expand the surface.
|
||||||
description="WordPress advanced operations (database, bulk, system)",
|
EndpointType.WORDPRESS_SPECIALIST: EndpointConfig(
|
||||||
endpoint_type=EndpointType.WORDPRESS_ADVANCED,
|
path="/wordpress-specialist",
|
||||||
plugin_types=["wordpress_advanced"],
|
name="WordPress Specialist",
|
||||||
|
description="Specialist WordPress management (plugins, themes, users, options, cron) — companion-backed",
|
||||||
|
endpoint_type=EndpointType.WORDPRESS_SPECIALIST,
|
||||||
|
plugin_types=["wordpress_specialist"],
|
||||||
require_master_key=False,
|
require_master_key=False,
|
||||||
allowed_scopes={"admin"}, # Admin scope required
|
allowed_scopes={"read", "write", "admin"},
|
||||||
|
# Same blacklist as the basic wordpress endpoint — keep system
|
||||||
|
# tools off the per-plugin endpoint regardless of how the tool
|
||||||
|
# registry expands.
|
||||||
|
tool_blacklist={
|
||||||
|
"manage_api_keys_create",
|
||||||
|
"manage_api_keys_delete",
|
||||||
|
"manage_api_keys_rotate",
|
||||||
|
"oauth_register_client",
|
||||||
|
"oauth_revoke_client",
|
||||||
|
},
|
||||||
max_tools=30,
|
max_tools=30,
|
||||||
),
|
),
|
||||||
# Gitea endpoint - Git repository management
|
# Gitea endpoint - Git repository management
|
||||||
@@ -249,16 +262,15 @@ ENDPOINT_CONFIGS = {
|
|||||||
},
|
},
|
||||||
max_tools=80,
|
max_tools=80,
|
||||||
),
|
),
|
||||||
# OpenPanel endpoint - Product Analytics (73 tools) - Phase H
|
# OpenPanel endpoint - Product Analytics (42 tools) - Phase H
|
||||||
EndpointType.OPENPANEL: EndpointConfig(
|
EndpointType.OPENPANEL: EndpointConfig(
|
||||||
path="/openpanel",
|
path="/openpanel",
|
||||||
name="OpenPanel Analytics",
|
name="OpenPanel Analytics",
|
||||||
description="OpenPanel product analytics management (events, export, funnels, dashboards)",
|
description="OpenPanel self-hosted product analytics (events, export, insights, project/client management)",
|
||||||
endpoint_type=EndpointType.OPENPANEL,
|
endpoint_type=EndpointType.OPENPANEL,
|
||||||
plugin_types=["openpanel"],
|
plugin_types=["openpanel"],
|
||||||
require_master_key=False,
|
require_master_key=False,
|
||||||
allowed_scopes={"read", "write", "admin"},
|
allowed_scopes={"read", "write", "admin"},
|
||||||
# Blacklist system and admin tools
|
|
||||||
tool_blacklist={
|
tool_blacklist={
|
||||||
"manage_api_keys_create",
|
"manage_api_keys_create",
|
||||||
"manage_api_keys_delete",
|
"manage_api_keys_delete",
|
||||||
@@ -266,7 +278,7 @@ ENDPOINT_CONFIGS = {
|
|||||||
"oauth_register_client",
|
"oauth_register_client",
|
||||||
"oauth_revoke_client",
|
"oauth_revoke_client",
|
||||||
},
|
},
|
||||||
max_tools=80,
|
max_tools=50,
|
||||||
),
|
),
|
||||||
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
|
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
|
||||||
EndpointType.APPWRITE: EndpointConfig(
|
EndpointType.APPWRITE: EndpointConfig(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class MCPEndpointFactory:
|
|||||||
self.tool_registry = tool_registry
|
self.tool_registry = tool_registry
|
||||||
self.middleware_classes = middleware_classes or []
|
self.middleware_classes = middleware_classes or []
|
||||||
self.endpoints: dict[str, FastMCP] = {}
|
self.endpoints: dict[str, FastMCP] = {}
|
||||||
|
self._tool_counts: dict[str, int] = {}
|
||||||
self._tool_handlers: dict[str, Callable] = {}
|
self._tool_handlers: dict[str, Callable] = {}
|
||||||
|
|
||||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||||
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
|
|||||||
for middleware in custom_middleware:
|
for middleware in custom_middleware:
|
||||||
mcp.add_middleware(middleware)
|
mcp.add_middleware(middleware)
|
||||||
|
|
||||||
# Store endpoint
|
# Store endpoint and tool count
|
||||||
self.endpoints[config.path] = mcp
|
self.endpoints[config.path] = mcp
|
||||||
|
self._tool_counts[config.path] = len(tools)
|
||||||
|
|
||||||
logger.info(f" - Endpoint {config.path} created successfully")
|
logger.info(f" - Endpoint {config.path} created successfully")
|
||||||
|
|
||||||
@@ -160,10 +162,13 @@ class MCPEndpointFactory:
|
|||||||
Returns:
|
Returns:
|
||||||
Plugin type or None for system tools
|
Plugin type or None for system tools
|
||||||
"""
|
"""
|
||||||
# Check for wordpress_advanced first (before wordpress_)
|
# Check the multi-word WordPress variant BEFORE the bare
|
||||||
# Tools are named: wordpress_advanced_wp_db_*, wordpress_advanced_bulk_*, wordpress_advanced_system_*
|
# ``wordpress_`` prefix — tool names are
|
||||||
if tool_name.startswith("wordpress_advanced_"):
|
# ``wordpress_specialist_wp_plugin_list``,
|
||||||
return "wordpress_advanced"
|
# ``wordpress_create_post`` etc. (``wordpress_advanced`` was
|
||||||
|
# sunset in F.19.3.2-.3 / 2026-05-04.)
|
||||||
|
if tool_name.startswith("wordpress_specialist_"):
|
||||||
|
return "wordpress_specialist"
|
||||||
|
|
||||||
if tool_name.startswith("wordpress_"):
|
if tool_name.startswith("wordpress_"):
|
||||||
return "wordpress"
|
return "wordpress"
|
||||||
@@ -282,9 +287,7 @@ class MCPEndpointFactory:
|
|||||||
"""
|
"""
|
||||||
info = []
|
info = []
|
||||||
for path, mcp in self.endpoints.items():
|
for path, mcp in self.endpoints.items():
|
||||||
# Get tool count
|
tool_count = self._tool_counts.get(path, 0)
|
||||||
# Note: This requires accessing FastMCP internals
|
|
||||||
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
|
|
||||||
|
|
||||||
info.append(
|
info.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class EndpointRegistry:
|
|||||||
"""
|
"""
|
||||||
Initialize the default set of endpoints.
|
Initialize the default set of endpoints.
|
||||||
|
|
||||||
Creates admin, wordpress, wordpress-advanced, and gitea endpoints.
|
Creates admin, wordpress, wordpress-specialist, and gitea endpoints.
|
||||||
"""
|
"""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
logger.warning("Endpoints already initialized")
|
logger.warning("Endpoints already initialized")
|
||||||
@@ -149,11 +149,7 @@ class EndpointRegistry:
|
|||||||
tool_count = 0
|
tool_count = 0
|
||||||
|
|
||||||
if mcp:
|
if mcp:
|
||||||
# Try to get tool count from FastMCP
|
tool_count = self.factory._tool_counts.get(path, 0)
|
||||||
try:
|
|
||||||
tool_count = len(mcp._tool_manager._tools)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
endpoints.append(
|
endpoints.append(
|
||||||
EndpointInfo(
|
EndpointInfo(
|
||||||
|
|||||||
153
core/health.py
153
core/health.py
@@ -12,6 +12,7 @@ This module provides comprehensive health monitoring capabilities including:
|
|||||||
Author: MCP Hub Team
|
Author: MCP Hub Team
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -22,7 +23,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.audit_log import AuditLogger
|
from core.audit_log import AuditLogger
|
||||||
from core.project_manager import ProjectManager
|
|
||||||
from core.site_manager import SiteManager
|
from core.site_manager import SiteManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -129,7 +129,6 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
metrics_retention_hours: int = 24,
|
metrics_retention_hours: int = 24,
|
||||||
max_metrics_per_project: int = 1000,
|
max_metrics_per_project: int = 1000,
|
||||||
@@ -139,13 +138,11 @@ class HealthMonitor:
|
|||||||
Initialize health monitor.
|
Initialize health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger for logging health events
|
audit_logger: Optional audit logger for logging health events
|
||||||
metrics_retention_hours: Hours to retain historical metrics
|
metrics_retention_hours: Hours to retain historical metrics
|
||||||
max_metrics_per_project: Maximum metrics to store per project
|
max_metrics_per_project: Maximum metrics to store per project
|
||||||
site_manager: Optional SiteManager for comprehensive site discovery
|
site_manager: SiteManager for site discovery
|
||||||
"""
|
"""
|
||||||
self.project_manager = project_manager
|
|
||||||
self.site_manager = site_manager
|
self.site_manager = site_manager
|
||||||
self.audit_logger = audit_logger
|
self.audit_logger = audit_logger
|
||||||
self.metrics_retention_hours = metrics_retention_hours
|
self.metrics_retention_hours = metrics_retention_hours
|
||||||
@@ -175,6 +172,11 @@ class HealthMonitor:
|
|||||||
# Request rate tracking (for requests per minute)
|
# Request rate tracking (for requests per minute)
|
||||||
self.request_timestamps: deque = deque(maxlen=1000)
|
self.request_timestamps: deque = deque(maxlen=1000)
|
||||||
|
|
||||||
|
# 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")
|
logger.info("HealthMonitor initialized")
|
||||||
|
|
||||||
def _setup_default_thresholds(self):
|
def _setup_default_thresholds(self):
|
||||||
@@ -404,15 +406,7 @@ class HealthMonitor:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get plugin instance from ProjectManager
|
if self.site_manager:
|
||||||
plugin = self.project_manager.projects.get(project_id)
|
|
||||||
|
|
||||||
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)
|
health_result = await self._site_manager_health_check(project_id)
|
||||||
else:
|
else:
|
||||||
return ProjectHealthStatus(
|
return ProjectHealthStatus(
|
||||||
@@ -470,7 +464,7 @@ class HealthMonitor:
|
|||||||
}
|
}
|
||||||
alerts = self._check_alerts(project_id, alert_check_data)
|
alerts = self._check_alerts(project_id, alert_check_data)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=is_healthy,
|
healthy=is_healthy,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -480,6 +474,8 @@ class HealthMonitor:
|
|||||||
alerts=alerts,
|
alerts=alerts,
|
||||||
details=health_result,
|
details=health_result,
|
||||||
)
|
)
|
||||||
|
self.latest_health_status[project_id] = status
|
||||||
|
return status
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
response_time_ms = (time.time() - start_time) * 1000
|
response_time_ms = (time.time() - start_time) * 1000
|
||||||
@@ -493,7 +489,7 @@ class HealthMonitor:
|
|||||||
error_message=error_msg,
|
error_message=error_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=False,
|
healthy=False,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -502,6 +498,8 @@ class HealthMonitor:
|
|||||||
recent_errors=[error_msg],
|
recent_errors=[error_msg],
|
||||||
alerts=[f"CRITICAL: Health check failed - {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:
|
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
|
||||||
"""Find site info from SiteManager by full_id."""
|
"""Find site info from SiteManager by full_id."""
|
||||||
@@ -514,7 +512,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Health check for sites managed by SiteManager (not in legacy ProjectManager).
|
Health check for a site via SiteManager.
|
||||||
|
|
||||||
Creates a temporary plugin instance and calls its health_check() method,
|
Creates a temporary plugin instance and calls its health_check() method,
|
||||||
falling back to a basic HTTP check if plugin instantiation fails.
|
falling back to a basic HTTP check if plugin instantiation fails.
|
||||||
@@ -522,7 +520,7 @@ class HealthMonitor:
|
|||||||
if not self.site_manager:
|
if not self.site_manager:
|
||||||
return {"healthy": False, "message": "SiteManager not available"}
|
return {"healthy": False, "message": "SiteManager not available"}
|
||||||
|
|
||||||
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
|
# Look up site info by full_id (handles multi-word plugin types like wordpress_specialist)
|
||||||
site_info = self._find_site_info(project_id)
|
site_info = self._find_site_info(project_id)
|
||||||
if not site_info:
|
if not site_info:
|
||||||
return {"healthy": False, "message": f"Site not found: {project_id}"}
|
return {"healthy": False, "message": f"Site not found: {project_id}"}
|
||||||
@@ -543,16 +541,58 @@ class HealthMonitor:
|
|||||||
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
||||||
return await plugin_instance.health_check()
|
return await plugin_instance.health_check()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.warning(
|
||||||
f"Could not create plugin instance for {project_id}, "
|
f"Could not create plugin instance for {project_id}, "
|
||||||
f"falling back to basic HTTP check: {e}"
|
f"falling back to authenticated HTTP check: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: basic HTTP check if plugin instantiation fails
|
# Fallback: authenticated health check for WordPress-based plugins
|
||||||
|
if plugin_type in (
|
||||||
|
"wordpress",
|
||||||
|
"wordpress_specialist",
|
||||||
|
"woocommerce",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
if plugin_type == "woocommerce":
|
||||||
|
# WooCommerce uses consumer_key/consumer_secret
|
||||||
|
ck = getattr(config, "consumer_key", None) or ""
|
||||||
|
cs = getattr(config, "consumer_secret", None) or ""
|
||||||
|
# consumer_key/consumer_secret may be in model_extra
|
||||||
|
if not ck and hasattr(config, "model_extra"):
|
||||||
|
ck = (config.model_extra or {}).get("consumer_key", "")
|
||||||
|
cs = (config.model_extra or {}).get("consumer_secret", "")
|
||||||
|
auth = aiohttp.BasicAuth(ck, cs)
|
||||||
|
auth_check_url = f"{config.url}/wp-json/wc/v3/system_status"
|
||||||
|
else:
|
||||||
|
auth = aiohttp.BasicAuth(
|
||||||
|
config.username or "",
|
||||||
|
config.app_password or "",
|
||||||
|
)
|
||||||
|
auth_check_url = f"{config.url}/wp-json/wp/v2/users/me"
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(auth=auth) as session:
|
||||||
|
async with session.get(
|
||||||
|
auth_check_url,
|
||||||
|
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)
|
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]:
|
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."""
|
"""Basic HTTP health check as a last-resort fallback."""
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
@@ -568,8 +608,27 @@ class HealthMonitor:
|
|||||||
"status_code": resp.status,
|
"status_code": resp.status,
|
||||||
"message": f"HTTP {resp.status} from {url}",
|
"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:
|
except Exception as e:
|
||||||
return {"healthy": False, "message": f"Connection failed: {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]:
|
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -583,8 +642,8 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
health_statuses = {}
|
health_statuses = {}
|
||||||
|
|
||||||
# Collect all known project/site IDs from both sources
|
# Collect all known site IDs from SiteManager
|
||||||
all_project_ids = set(self.project_manager.projects.keys())
|
all_project_ids = set()
|
||||||
if self.site_manager:
|
if self.site_manager:
|
||||||
for site_info in self.site_manager.list_all_sites():
|
for site_info in self.site_manager.list_all_sites():
|
||||||
all_project_ids.add(site_info["full_id"])
|
all_project_ids.add(site_info["full_id"])
|
||||||
@@ -742,8 +801,46 @@ class HealthMonitor:
|
|||||||
self.failed_requests = 0
|
self.failed_requests = 0
|
||||||
self.response_times.clear()
|
self.response_times.clear()
|
||||||
self.request_timestamps.clear()
|
self.request_timestamps.clear()
|
||||||
|
self.latest_health_status.clear()
|
||||||
logger.warning("All metrics have been reset")
|
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
|
# Singleton instance
|
||||||
_health_monitor: HealthMonitor | None = None
|
_health_monitor: HealthMonitor | None = None
|
||||||
@@ -755,7 +852,6 @@ def get_health_monitor() -> HealthMonitor | None:
|
|||||||
|
|
||||||
|
|
||||||
def initialize_health_monitor(
|
def initialize_health_monitor(
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
site_manager: SiteManager | None = None,
|
site_manager: SiteManager | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -764,16 +860,13 @@ def initialize_health_monitor(
|
|||||||
Initialize the global health monitor.
|
Initialize the global health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger
|
audit_logger: Optional audit logger
|
||||||
site_manager: Optional SiteManager for comprehensive site discovery
|
site_manager: SiteManager for site discovery
|
||||||
**kwargs: Additional configuration options
|
**kwargs: Additional configuration options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthMonitor instance
|
HealthMonitor instance
|
||||||
"""
|
"""
|
||||||
global _health_monitor
|
global _health_monitor
|
||||||
_health_monitor = HealthMonitor(
|
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
|
||||||
project_manager, audit_logger, site_manager=site_manager, **kwargs
|
|
||||||
)
|
|
||||||
return _health_monitor
|
return _health_monitor
|
||||||
|
|||||||
67
core/media_audit.py
Normal file
67
core/media_audit.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""F.5a.6.4 — Audit-log emission for media uploads.
|
||||||
|
|
||||||
|
One ``media.upload`` entry is written per **successful** upload regardless
|
||||||
|
of source (base64 / url / chunked / ai:<provider>). Failures are intentionally
|
||||||
|
NOT logged here — they surface to the caller as typed ``UploadError`` JSON
|
||||||
|
and to the dashboard via the existing tool-call audit emitted by the
|
||||||
|
ToolRouter wrapper. Logging failures twice would double-count error rates.
|
||||||
|
|
||||||
|
GDPR: never log raw bytes, base64, prompts, or URLs that may carry tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def log_media_upload(
|
||||||
|
*,
|
||||||
|
site: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
mime: str | None,
|
||||||
|
size_bytes: int,
|
||||||
|
source: str,
|
||||||
|
media_id: int | None,
|
||||||
|
cost_usd: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Emit a single ``media.upload`` audit entry. Best-effort — never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site: Site URL or alias (whichever the handler has on hand).
|
||||||
|
user_id: Calling user id (None for admin / env-fallback).
|
||||||
|
mime: Sniffed MIME type after security validation.
|
||||||
|
size_bytes: Final uploaded byte count (post-optimization).
|
||||||
|
source: One of ``"base64"``, ``"url"``, ``"chunked"``, ``"ai:<provider>"``.
|
||||||
|
media_id: WordPress media library id of the resulting attachment.
|
||||||
|
cost_usd: Provider cost in USD for AI-generated uploads only.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.audit_log import get_audit_logger
|
||||||
|
|
||||||
|
audit = get_audit_logger()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"site": site,
|
||||||
|
"mime": mime,
|
||||||
|
"size_bytes": int(size_bytes),
|
||||||
|
"source": source,
|
||||||
|
"media_id": media_id,
|
||||||
|
}
|
||||||
|
if cost_usd is not None:
|
||||||
|
params["cost_usd"] = round(float(cost_usd), 6)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audit.log_tool_call(
|
||||||
|
tool_name="media.upload",
|
||||||
|
site=site,
|
||||||
|
params=params,
|
||||||
|
result_summary=(f"{source} {size_bytes}B {mime or '?'} -> media_id={media_id}"),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("media.upload audit emit failed", exc_info=True)
|
||||||
91
core/media_error_codes.py
Normal file
91
core/media_error_codes.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""F.5a.6.2 — Stable error-code taxonomy for media upload tools.
|
||||||
|
|
||||||
|
Every :class:`plugins.wordpress.handlers._media_security.UploadError` and
|
||||||
|
:class:`core.upload_sessions.UploadSessionError` raised inside the media
|
||||||
|
stack MUST use one of the codes listed here (or match the dynamic
|
||||||
|
``WP_<status>`` pattern for upstream WordPress REST responses).
|
||||||
|
|
||||||
|
The accompanying test ``tests/plugins/wordpress/test_media_error_taxonomy.py``
|
||||||
|
scans the source files for raise-site literals and asserts each one is in
|
||||||
|
this set — so the contract stays stable as the code evolves.
|
||||||
|
|
||||||
|
The full reference is in ``docs/media-error-codes.md``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: All stable upload/media error codes. Keep alphabetical within groups.
|
||||||
|
MEDIA_ERROR_CODES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
# --- Input / validation ------------------------------------------
|
||||||
|
"BAD_BASE64",
|
||||||
|
"BAD_MODE",
|
||||||
|
"BAD_ROLE",
|
||||||
|
"BAD_SIZE",
|
||||||
|
"BAD_SOURCE",
|
||||||
|
"EMPTY_FILE",
|
||||||
|
"MEDIA_NOT_FOUND",
|
||||||
|
"MIME_REJECTED",
|
||||||
|
"MISSING_FIELD",
|
||||||
|
"SSRF",
|
||||||
|
"TOO_LARGE",
|
||||||
|
"URL_FETCH_FAILED",
|
||||||
|
# --- WordPress REST upstream -------------------------------------
|
||||||
|
"WP_413",
|
||||||
|
"WP_AUTH",
|
||||||
|
"WP_BAD_RESPONSE",
|
||||||
|
# F.X.fix-pass4 — WC sites with consumer_key/consumer_secret
|
||||||
|
# auth need a separate WP Application Password to upload to
|
||||||
|
# /wp/v2/media. Surfaced by media_attach.py when the user
|
||||||
|
# hasn't filled wp_username/wp_app_password in Connection
|
||||||
|
# Settings.
|
||||||
|
"WP_CREDENTIALS_MISSING",
|
||||||
|
# WP_<status> (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE
|
||||||
|
# --- Companion plugin upload-chunk route (F.5a.7) ----------------
|
||||||
|
"COMPANION_BAD_RESPONSE",
|
||||||
|
# COMPANION_<status> (e.g. COMPANION_500) is also allowed — same
|
||||||
|
# shape as WP_<status>, see _COMPANION_STATUS_RE.
|
||||||
|
# --- Chunked upload session --------------------------------------
|
||||||
|
"BAD_STATE",
|
||||||
|
"CHECKSUM_MISMATCH",
|
||||||
|
"CHUNK_CHECKSUM",
|
||||||
|
"CHUNK_ORDER",
|
||||||
|
"CHUNK_OVERFLOW",
|
||||||
|
"EXPIRED",
|
||||||
|
"INCOMPLETE",
|
||||||
|
"NO_SESSION",
|
||||||
|
"QUOTA_EXCEEDED",
|
||||||
|
"SESSION_TOO_LARGE",
|
||||||
|
# --- AI generation providers -------------------------------------
|
||||||
|
"GENERATION_FAILED",
|
||||||
|
"NO_PROVIDER_KEY",
|
||||||
|
"PROVIDER_AUTH",
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
"PROVIDER_QUOTA",
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
"PROVIDER_UNKNOWN",
|
||||||
|
# --- Rate / policy -----------------------------------------------
|
||||||
|
"TOOL_RATE_LIMITED",
|
||||||
|
# --- Catchall ----------------------------------------------------
|
||||||
|
"INTERNAL",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Dynamic WP REST status codes (e.g. WP_400, WP_500).
|
||||||
|
_WP_STATUS_RE = re.compile(r"^WP_\d{3}$")
|
||||||
|
|
||||||
|
#: Dynamic companion upload-chunk status codes (e.g. COMPANION_400, COMPANION_500).
|
||||||
|
_COMPANION_STATUS_RE = re.compile(r"^COMPANION_\d{3}$")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_code(code: str) -> bool:
|
||||||
|
"""Return True if ``code`` is a documented media error code."""
|
||||||
|
return (
|
||||||
|
code in MEDIA_ERROR_CODES
|
||||||
|
or bool(_WP_STATUS_RE.match(code))
|
||||||
|
or bool(_COMPANION_STATUS_RE.match(code))
|
||||||
|
)
|
||||||
@@ -76,6 +76,7 @@ class ClientRegistry:
|
|||||||
grant_types: list[str] | None = None,
|
grant_types: list[str] | None = None,
|
||||||
allowed_scopes: list[str] | None = None,
|
allowed_scopes: list[str] | None = None,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
owner_user_id: str | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Create new OAuth client.
|
Create new OAuth client.
|
||||||
@@ -97,8 +98,9 @@ class ClientRegistry:
|
|||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
||||||
allowed_scopes=allowed_scopes or ["read", "write"],
|
allowed_scopes=allowed_scopes or ["read", "write", "admin"],
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save
|
# Save
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ class OAuthClient(BaseModel):
|
|||||||
default=["authorization_code", "refresh_token"], description="Allowed grant types"
|
default=["authorization_code", "refresh_token"], description="Allowed grant types"
|
||||||
)
|
)
|
||||||
response_types: list[str] = Field(default=["code"], description="Allowed response types")
|
response_types: list[str] = Field(default=["code"], description="Allowed response types")
|
||||||
scope: str = Field(default="read", description="Default scope for this client")
|
scope: str = Field(default="read write admin", description="Default scope for this client")
|
||||||
allowed_scopes: list[str] = Field(
|
allowed_scopes: list[str] = Field(
|
||||||
default=["read", "write"], description="All scopes this client can request"
|
default=["read", "write", "admin"], description="All scopes this client can request"
|
||||||
)
|
)
|
||||||
token_endpoint_auth_method: str = Field(
|
token_endpoint_auth_method: str = Field(
|
||||||
default="client_secret_post", description="Token endpoint authentication method"
|
default="client_secret_post", description="Token endpoint authentication method"
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
metadata: dict = Field(default_factory=dict)
|
metadata: dict = Field(default_factory=dict)
|
||||||
|
owner_user_id: str | None = Field(
|
||||||
|
default=None, description="ID of the OAuth user who created this client (None = admin)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("redirect_uris")
|
@field_validator("redirect_uris")
|
||||||
def validate_redirect_uris(cls, v):
|
def validate_redirect_uris(cls, v):
|
||||||
@@ -63,6 +66,7 @@ class AuthorizationCode(BaseModel):
|
|||||||
api_key_id: str | None = None # API Key ID for scope/project inheritance
|
api_key_id: str | None = None # API Key ID for scope/project inheritance
|
||||||
api_key_project_id: str | None = None # Project ID from API Key
|
api_key_project_id: str | None = None # Project ID from API Key
|
||||||
api_key_scope: str | None = None # Scope from API Key
|
api_key_scope: str | None = None # Scope from API Key
|
||||||
|
resource: str | None = None # RFC 8707: Resource indicator
|
||||||
|
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
"""Check if code is expired"""
|
"""Check if code is expired"""
|
||||||
|
|||||||
@@ -114,8 +114,13 @@ class OAuthServer:
|
|||||||
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
|
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate scope
|
# Validate scope. F.19.2.2: default to every tier explicitly so
|
||||||
requested_scopes = scope.split() if scope else ["read"]
|
# an OAuth client without a scope hint gets the same surface as a
|
||||||
|
# dashboard-issued user key. The binding narrowing is per-site
|
||||||
|
# (intersection with the site's tool_scope preset).
|
||||||
|
requested_scopes = (
|
||||||
|
scope.split() if scope else ["read", "editor", "settings", "install", "write", "admin"]
|
||||||
|
)
|
||||||
for s in requested_scopes:
|
for s in requested_scopes:
|
||||||
if s not in client.allowed_scopes:
|
if s not in client.allowed_scopes:
|
||||||
raise OAuthError(
|
raise OAuthError(
|
||||||
@@ -143,6 +148,7 @@ class OAuthServer:
|
|||||||
api_key_id: str | None = None,
|
api_key_id: str | None = None,
|
||||||
api_key_project_id: str | None = None,
|
api_key_project_id: str | None = None,
|
||||||
api_key_scope: str | None = None,
|
api_key_scope: str | None = None,
|
||||||
|
resource: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create authorization code (Step 2 of Authorization Code flow)
|
Create authorization code (Step 2 of Authorization Code flow)
|
||||||
@@ -157,6 +163,7 @@ class OAuthServer:
|
|||||||
api_key_id: Optional API Key ID for scope/project inheritance
|
api_key_id: Optional API Key ID for scope/project inheritance
|
||||||
api_key_project_id: Optional project ID from API Key
|
api_key_project_id: Optional project ID from API Key
|
||||||
api_key_scope: Optional scope from API Key
|
api_key_scope: Optional scope from API Key
|
||||||
|
resource: Optional resource indicator (RFC 8707)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Authorization code (valid for 5 minutes)
|
Authorization code (valid for 5 minutes)
|
||||||
@@ -178,6 +185,7 @@ class OAuthServer:
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
api_key_project_id=api_key_project_id,
|
api_key_project_id=api_key_project_id,
|
||||||
api_key_scope=api_key_scope,
|
api_key_scope=api_key_scope,
|
||||||
|
resource=resource,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to storage
|
# Save to storage
|
||||||
@@ -257,12 +265,14 @@ class OAuthServer:
|
|||||||
# If authorization code has API Key metadata, use it for scoping
|
# If authorization code has API Key metadata, use it for scoping
|
||||||
project_id = auth_code.api_key_project_id or "*"
|
project_id = auth_code.api_key_project_id or "*"
|
||||||
token_scope = auth_code.api_key_scope or auth_code.scope
|
token_scope = auth_code.api_key_scope or auth_code.scope
|
||||||
|
resource = auth_code.resource
|
||||||
|
|
||||||
access_token = self.token_manager.generate_access_token(
|
access_token = self.token_manager.generate_access_token(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
scope=token_scope,
|
scope=token_scope,
|
||||||
user_id=auth_code.user_id or auth_code.api_key_id,
|
user_id=auth_code.user_id or auth_code.api_key_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
resource=resource,
|
||||||
)
|
)
|
||||||
|
|
||||||
refresh_token = self.token_manager.generate_refresh_token(
|
refresh_token = self.token_manager.generate_refresh_token(
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ class TokenManager:
|
|||||||
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
|
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
|
||||||
|
|
||||||
def generate_access_token(
|
def generate_access_token(
|
||||||
self, client_id: str, scope: str, user_id: str | None = None, project_id: str = "*"
|
self,
|
||||||
|
client_id: str,
|
||||||
|
scope: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
project_id: str = "*",
|
||||||
|
resource: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate JWT access token.
|
Generate JWT access token.
|
||||||
@@ -64,6 +69,7 @@ class TokenManager:
|
|||||||
scope: Granted scopes (space-separated)
|
scope: Granted scopes (space-separated)
|
||||||
user_id: User ID (optional, for user-based auth)
|
user_id: User ID (optional, for user-based auth)
|
||||||
project_id: Project ID for scoping (default: "*" for global)
|
project_id: Project ID for scoping (default: "*" for global)
|
||||||
|
resource: Resource indicator for aud claim (RFC 8707)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JWT access token
|
JWT access token
|
||||||
@@ -87,6 +93,9 @@ class TokenManager:
|
|||||||
if user_id:
|
if user_id:
|
||||||
payload["sub"] = user_id # Subject (user ID)
|
payload["sub"] = user_id # Subject (user ID)
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
payload["aud"] = resource
|
||||||
|
|
||||||
# Encode JWT
|
# Encode JWT
|
||||||
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ class TokenManager:
|
|||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_nbf": True,
|
"verify_nbf": True,
|
||||||
|
"verify_aud": False, # Server is the resource server; no external aud check needed
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,8 +251,8 @@ class TokenManager:
|
|||||||
try:
|
try:
|
||||||
old_payload = self.validate_access_token(token_data.access_token)
|
old_payload = self.validate_access_token(token_data.access_token)
|
||||||
scope = old_payload.get("scope", "read")
|
scope = old_payload.get("scope", "read")
|
||||||
except:
|
except Exception:
|
||||||
pass # Old token might be expired, use default
|
pass # Old token might be expired, use default scope
|
||||||
|
|
||||||
# Generate new tokens
|
# Generate new tokens
|
||||||
new_access_token = self.generate_access_token(
|
new_access_token = self.generate_access_token(
|
||||||
|
|||||||
68
core/plugin_visibility.py
Normal file
68
core/plugin_visibility.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Plugin visibility control for public vs admin users (Track F.1).
|
||||||
|
|
||||||
|
Controls which plugins are visible to public (OAuth) users vs admin users.
|
||||||
|
Admin users (MASTER_API_KEY) always see all plugins. Public users only see
|
||||||
|
plugins listed in the ENABLED_PLUGINS setting (DB > ENV > default).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.plugin_visibility import get_public_plugin_types, is_plugin_public
|
||||||
|
|
||||||
|
public_types = get_public_plugin_types()
|
||||||
|
if is_plugin_public("gitea"): # True
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Default plugins available to public (OAuth) users.
|
||||||
|
DEFAULT_PUBLIC_PLUGINS = {
|
||||||
|
"wordpress",
|
||||||
|
"woocommerce",
|
||||||
|
"wordpress_specialist",
|
||||||
|
"supabase",
|
||||||
|
"openpanel",
|
||||||
|
"gitea",
|
||||||
|
"n8n",
|
||||||
|
"coolify",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_plugins(val: str) -> set[str]:
|
||||||
|
"""Parse comma-separated plugin string into set."""
|
||||||
|
return {p.strip().lower() for p in val.split(",") if p.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_plugin_types() -> set[str]:
|
||||||
|
"""Return the set of plugin types visible to public users.
|
||||||
|
|
||||||
|
Checks DB settings first (sync-safe), then env var, then defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of lowercase plugin type strings.
|
||||||
|
"""
|
||||||
|
# Try DB setting (sync access via cached value)
|
||||||
|
try:
|
||||||
|
from core.settings import _cached_plugins
|
||||||
|
|
||||||
|
if _cached_plugins is not None:
|
||||||
|
return set(_cached_plugins)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback to env var
|
||||||
|
env_val = os.getenv("ENABLED_PLUGINS", "").strip()
|
||||||
|
if not env_val:
|
||||||
|
return set(DEFAULT_PUBLIC_PLUGINS)
|
||||||
|
return _parse_plugins(env_val)
|
||||||
|
|
||||||
|
|
||||||
|
def is_plugin_public(plugin_type: str) -> bool:
|
||||||
|
"""Check if a plugin type is enabled for public users.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type string (e.g., "wordpress").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the plugin is in the public set.
|
||||||
|
"""
|
||||||
|
return plugin_type.lower() in get_public_plugin_types()
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"""
|
|
||||||
Project Manager
|
|
||||||
|
|
||||||
Discovers and manages project instances from environment variables.
|
|
||||||
Handles plugin lifecycle and tool registration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from plugins import BasePlugin, registry
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectManager:
|
|
||||||
"""
|
|
||||||
Manage multiple project instances.
|
|
||||||
|
|
||||||
Projects are discovered from environment variables:
|
|
||||||
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
|
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_URL=https://other.com
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize project manager."""
|
|
||||||
self.projects: dict[str, BasePlugin] = {}
|
|
||||||
self.logger = logging.getLogger("ProjectManager")
|
|
||||||
|
|
||||||
def discover_projects(self) -> None:
|
|
||||||
"""
|
|
||||||
Discover projects from environment variables.
|
|
||||||
|
|
||||||
Scans environment for project configurations and creates
|
|
||||||
plugin instances.
|
|
||||||
"""
|
|
||||||
self.logger.info("Starting project discovery...")
|
|
||||||
|
|
||||||
# Get all registered plugin types
|
|
||||||
plugin_types = registry.get_registered_types()
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
self._discover_plugin_type(plugin_type)
|
|
||||||
|
|
||||||
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
|
|
||||||
|
|
||||||
def _discover_plugin_type(self, plugin_type: str) -> None:
|
|
||||||
"""
|
|
||||||
Discover all projects of a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
|
||||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
|
||||||
plugin_types = registry.get_registered_types()
|
|
||||||
longer_prefixes = [
|
|
||||||
pt.upper() + "_"
|
|
||||||
for pt in plugin_types
|
|
||||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Find all project IDs for this plugin type
|
|
||||||
project_ids = set()
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
# Skip env vars that belong to a more specific plugin type
|
|
||||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
|
||||||
continue
|
|
||||||
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
project_id = match.group(1).lower()
|
|
||||||
project_ids.add(project_id)
|
|
||||||
|
|
||||||
# Create plugin instance for each project
|
|
||||||
for project_id in project_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_project_config(plugin_type, project_id)
|
|
||||||
if config:
|
|
||||||
self._create_project_instance(plugin_type, project_id, config)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}")
|
|
||||||
|
|
||||||
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a project from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
project_id: Project ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with configuration or None if incomplete
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# Collect all config keys for this project
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config[config_key] = env_value
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
def _create_project_instance(
|
|
||||||
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create a plugin instance for a project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
project_id: Project ID
|
|
||||||
config: Project configuration
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create plugin instance
|
|
||||||
plugin = registry.create_instance(plugin_type, project_id, config)
|
|
||||||
|
|
||||||
# Store with full identifier
|
|
||||||
full_id = f"{plugin_type}_{project_id}"
|
|
||||||
self.projects[full_id] = plugin
|
|
||||||
|
|
||||||
self.logger.info(f"Created project: {full_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
|
|
||||||
|
|
||||||
def get_project(self, full_id: str) -> BasePlugin | None:
|
|
||||||
"""
|
|
||||||
Get a project plugin instance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: Full project identifier (plugin_type_project_id)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Plugin instance or None
|
|
||||||
"""
|
|
||||||
return self.projects.get(full_id)
|
|
||||||
|
|
||||||
def get_all_projects(self) -> dict[str, BasePlugin]:
|
|
||||||
"""Get all project instances."""
|
|
||||||
return self.projects.copy()
|
|
||||||
|
|
||||||
def get_projects_by_type(self, plugin_type: str) -> dict[str, BasePlugin]:
|
|
||||||
"""
|
|
||||||
Get all projects of a specific type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type to filter by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of project_id -> plugin
|
|
||||||
"""
|
|
||||||
prefix = plugin_type + "_"
|
|
||||||
return {
|
|
||||||
full_id: plugin
|
|
||||||
for full_id, plugin in self.projects.items()
|
|
||||||
if full_id.startswith(prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_all_tools(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all MCP tools from all projects.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of tool definitions
|
|
||||||
"""
|
|
||||||
all_tools = []
|
|
||||||
|
|
||||||
for full_id, plugin in self.projects.items():
|
|
||||||
try:
|
|
||||||
tools = plugin.get_tools()
|
|
||||||
all_tools.extend(tools)
|
|
||||||
self.logger.debug(f"Loaded {len(tools)} tools from {full_id}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error loading tools from {full_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
self.logger.debug(f"Total tools loaded: {len(all_tools)}")
|
|
||||||
return all_tools
|
|
||||||
|
|
||||||
async def check_all_health(self) -> dict[str, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Check health of all projects.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping project ID to health status
|
|
||||||
"""
|
|
||||||
health_results = {}
|
|
||||||
|
|
||||||
for full_id, plugin in self.projects.items():
|
|
||||||
try:
|
|
||||||
health = await plugin.health_check()
|
|
||||||
health_results[full_id] = health
|
|
||||||
except Exception as e:
|
|
||||||
health_results[full_id] = {
|
|
||||||
"healthy": False,
|
|
||||||
"message": f"Health check failed: {str(e)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return health_results
|
|
||||||
|
|
||||||
def get_project_info(self, full_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Get information about a specific project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: Full project identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Project info dict or None
|
|
||||||
"""
|
|
||||||
plugin = self.get_project(full_id)
|
|
||||||
if plugin:
|
|
||||||
return plugin.get_project_info()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def list_projects(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all projects with basic information.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of project info dicts
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
{"id": full_id, "type": plugin.get_plugin_name(), "project_id": plugin.project_id}
|
|
||||||
for full_id, plugin in self.projects.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Global project manager instance
|
|
||||||
_project_manager: ProjectManager | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_project_manager() -> ProjectManager:
|
|
||||||
"""Get the global project manager instance."""
|
|
||||||
global _project_manager
|
|
||||||
if _project_manager is None:
|
|
||||||
_project_manager = ProjectManager()
|
|
||||||
_project_manager.discover_projects()
|
|
||||||
return _project_manager
|
|
||||||
230
core/settings.py
Normal file
230
core/settings.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Unified settings access with DB > ENV > Default priority (Phase 4C.3).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.settings import get_setting
|
||||||
|
|
||||||
|
enabled = await get_setting(
|
||||||
|
"ENABLED_PLUGINS",
|
||||||
|
"wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
|
||||||
|
)
|
||||||
|
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cached plugin set for sync access (updated when settings change)
|
||||||
|
_cached_plugins: set[str] | None = None
|
||||||
|
|
||||||
|
# Cached int settings for sync access (updated when settings change)
|
||||||
|
_cached_max_sites: int | None = None
|
||||||
|
_cached_rate_per_min: int | None = None
|
||||||
|
_cached_rate_per_hr: int | None = None
|
||||||
|
|
||||||
|
# Default values for all managed settings
|
||||||
|
SETTING_DEFAULTS: dict[str, str] = {
|
||||||
|
"ENABLED_PLUGINS": "wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
|
||||||
|
"MAX_SITES_PER_USER": "10",
|
||||||
|
"USER_RATE_LIMIT_PER_MIN": "30",
|
||||||
|
"USER_RATE_LIMIT_PER_HR": "500",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human-readable labels for the settings UI
|
||||||
|
SETTING_LABELS: dict[str, dict[str, str]] = {
|
||||||
|
"ENABLED_PLUGINS": {
|
||||||
|
"label": "Enabled Plugins",
|
||||||
|
"label_fa": "پلاگینهای فعال",
|
||||||
|
"hint": "Comma-separated plugin types visible to public users",
|
||||||
|
"hint_fa": "انواع پلاگین قابل مشاهده برای کاربران عمومی (با کاما جدا شوند)",
|
||||||
|
},
|
||||||
|
"MAX_SITES_PER_USER": {
|
||||||
|
"label": "Max Sites per User",
|
||||||
|
"label_fa": "حداکثر سایت هر کاربر",
|
||||||
|
"hint": "Maximum number of sites each user can create",
|
||||||
|
"hint_fa": "حداکثر تعداد سایتهایی که هر کاربر میتواند بسازد",
|
||||||
|
},
|
||||||
|
"USER_RATE_LIMIT_PER_MIN": {
|
||||||
|
"label": "User Rate Limit (per minute)",
|
||||||
|
"label_fa": "محدودیت نرخ کاربر (در دقیقه)",
|
||||||
|
"hint": "Maximum MCP requests per user per minute",
|
||||||
|
"hint_fa": "حداکثر درخواست MCP هر کاربر در دقیقه",
|
||||||
|
},
|
||||||
|
"USER_RATE_LIMIT_PER_HR": {
|
||||||
|
"label": "User Rate Limit (per hour)",
|
||||||
|
"label_fa": "محدودیت نرخ کاربر (در ساعت)",
|
||||||
|
"hint": "Maximum MCP requests per user per hour",
|
||||||
|
"hint_fa": "حداکثر درخواست MCP هر کاربر در ساعت",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""Get a setting value with priority: Database > Environment > Default.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key (e.g., "ENABLED_PLUGINS").
|
||||||
|
default: Fallback if not found anywhere. If None, uses SETTING_DEFAULTS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Setting value string, or None if not found anywhere.
|
||||||
|
"""
|
||||||
|
# 1. Try database
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db_val = await db.get_setting(key)
|
||||||
|
if db_val is not None:
|
||||||
|
return db_val
|
||||||
|
except Exception:
|
||||||
|
pass # DB not initialized yet (startup) — fall through
|
||||||
|
|
||||||
|
# 2. Try environment variable
|
||||||
|
env_val = os.environ.get(key)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
|
||||||
|
# 3. Use provided default or SETTING_DEFAULTS
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
return SETTING_DEFAULTS.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_max_sites() -> int:
|
||||||
|
"""Return the cached MAX_SITES_PER_USER value (sync, uses last refresh)."""
|
||||||
|
if _cached_max_sites is not None:
|
||||||
|
return _cached_max_sites
|
||||||
|
env_val = os.environ.get("MAX_SITES_PER_USER")
|
||||||
|
if env_val:
|
||||||
|
try:
|
||||||
|
return max(1, int(env_val))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_rate_per_min() -> int:
|
||||||
|
"""Return the cached USER_RATE_LIMIT_PER_MIN value (sync, uses last refresh)."""
|
||||||
|
if _cached_rate_per_min is not None:
|
||||||
|
return _cached_rate_per_min
|
||||||
|
env_val = os.environ.get("USER_RATE_LIMIT_PER_MIN")
|
||||||
|
if env_val:
|
||||||
|
try:
|
||||||
|
return max(1, int(env_val))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_rate_per_hr() -> int:
|
||||||
|
"""Return the cached USER_RATE_LIMIT_PER_HR value (sync, uses last refresh)."""
|
||||||
|
if _cached_rate_per_hr is not None:
|
||||||
|
return _cached_rate_per_hr
|
||||||
|
env_val = os.environ.get("USER_RATE_LIMIT_PER_HR")
|
||||||
|
if env_val:
|
||||||
|
try:
|
||||||
|
return max(1, int(env_val))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_plugin_cache() -> None:
|
||||||
|
"""Refresh all sync-readable setting caches from DB/ENV/default."""
|
||||||
|
global _cached_plugins, _cached_max_sites, _cached_rate_per_min, _cached_rate_per_hr
|
||||||
|
|
||||||
|
val = await get_setting("ENABLED_PLUGINS")
|
||||||
|
if val:
|
||||||
|
_cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()}
|
||||||
|
else:
|
||||||
|
_cached_plugins = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await get_setting("MAX_SITES_PER_USER")
|
||||||
|
_cached_max_sites = max(1, int(raw)) if raw else int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_cached_max_sites = int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await get_setting("USER_RATE_LIMIT_PER_MIN")
|
||||||
|
_cached_rate_per_min = (
|
||||||
|
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_cached_rate_per_min = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await get_setting("USER_RATE_LIMIT_PER_HR")
|
||||||
|
_cached_rate_per_hr = (
|
||||||
|
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_cached_rate_per_hr = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
|
||||||
|
|
||||||
|
|
||||||
|
async def save_setting(key: str, value: str) -> None:
|
||||||
|
"""Save a setting to database and refresh caches."""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_setting(key, value)
|
||||||
|
|
||||||
|
await refresh_plugin_cache()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_setting_value(key: str) -> bool:
|
||||||
|
"""Delete a setting from database (revert to ENV/default)."""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_setting(key)
|
||||||
|
|
||||||
|
await refresh_plugin_cache()
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_managed_settings() -> list[dict[str, str]]:
|
||||||
|
"""Get all managed settings with their current values and sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with: key, value, source ("database"/"environment"/"default"),
|
||||||
|
label, hint.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db_settings = await db.get_all_settings()
|
||||||
|
except Exception:
|
||||||
|
db_settings = {}
|
||||||
|
|
||||||
|
for key, default_val in SETTING_DEFAULTS.items():
|
||||||
|
meta = SETTING_LABELS.get(key, {})
|
||||||
|
db_val = db_settings.get(key)
|
||||||
|
env_val = os.environ.get(key)
|
||||||
|
|
||||||
|
if db_val is not None:
|
||||||
|
value, source = db_val, "database"
|
||||||
|
elif env_val is not None:
|
||||||
|
value, source = env_val, "environment"
|
||||||
|
else:
|
||||||
|
value, source = default_val, "default"
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"source": source,
|
||||||
|
"default": default_val,
|
||||||
|
"label": meta.get("label", key),
|
||||||
|
"label_fa": meta.get("label_fa", key),
|
||||||
|
"hint": meta.get("hint", ""),
|
||||||
|
"hint_fa": meta.get("hint_fa", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
933
core/site_api.py
Normal file
933
core/site_api.py
Normal file
@@ -0,0 +1,933 @@
|
|||||||
|
"""Site management logic for the Live Platform (Track E.3).
|
||||||
|
|
||||||
|
Provides site CRUD operations, connection validation, and credential
|
||||||
|
field definitions for all 9 plugin types. Coordinates between the
|
||||||
|
database, encryption, and plugin health check layers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.site_api import create_user_site, get_user_sites, validate_site_connection
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection("wordpress", "https://example.com", creds)
|
||||||
|
site = await create_user_site(user_id, "wordpress", "myblog", url, creds)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Fallback used when the settings DB is unavailable at import time
|
||||||
|
_MAX_SITES_FALLBACK = int(os.getenv("MAX_SITES_PER_USER", "10"))
|
||||||
|
|
||||||
|
# Plugin credential field definitions — drives the dynamic "Add Site" form
|
||||||
|
# and server-side validation. Each field has:
|
||||||
|
# name: form input name (matches credential JSON key)
|
||||||
|
# label: display label
|
||||||
|
# type: "text" or "password"
|
||||||
|
# required: whether the field is mandatory
|
||||||
|
PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"wordpress": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"WordPress Admin → Users → Profile → Application Passwords. "
|
||||||
|
"This IS the API credential — no separate API key is needed. "
|
||||||
|
"Paste the value WP shows once after creation (spaces can stay or be removed)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"woocommerce": [
|
||||||
|
{
|
||||||
|
"name": "consumer_key",
|
||||||
|
"label": "Consumer Key",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"WooCommerce → Settings → Advanced → REST API → Add Key. "
|
||||||
|
"Read/Write permission. This pair IS the API auth — no extra API key field exists."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "consumer_secret",
|
||||||
|
"label": "Consumer Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"Shown once when the REST API key is created. "
|
||||||
|
"Starts with ``cs_``. Save it immediately — WooCommerce will not display it again."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# F.X.fix-pass4 — optional WP Application Password, only used by
|
||||||
|
# media tools (upload_and_attach_to_product / attach_media_to_
|
||||||
|
# product / set_featured_image). WooCommerce's Consumer Key +
|
||||||
|
# Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``;
|
||||||
|
# WordPress core REST media uploads require an Application
|
||||||
|
# Password from the WP admin user. Leave blank if the store
|
||||||
|
# never needs MCP-driven image uploads.
|
||||||
|
{
|
||||||
|
"name": "wp_username",
|
||||||
|
"label": "WordPress Username (for media tools)",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Only required if you want the AI / media tools "
|
||||||
|
"(upload_and_attach_to_product, attach_media_to_product, "
|
||||||
|
"set_featured_image, generate_and_upload_image with attach_to_post). "
|
||||||
|
"Other WC tools work with Consumer Key / Secret alone."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wp_app_password",
|
||||||
|
"label": "WordPress Application Password (for media tools)",
|
||||||
|
"type": "password",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"WP Admin → Users → Profile → Application Passwords. "
|
||||||
|
"WC media uploads hit /wp/v2/media which only accepts "
|
||||||
|
"Application Passwords (not Consumer Key / Secret). "
|
||||||
|
"Leave empty if you don't use MCP for image uploads."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
# F.19.1 WordPress Specialist — companion-backed, no Docker socket.
|
||||||
|
# Same auth shape as the basic wordpress plugin (Application Password)
|
||||||
|
# but the WP user behind the password must have ``manage_options``,
|
||||||
|
# AND Airano MCP Bridge v2.11.0+ must be active on the WP site for
|
||||||
|
# the admin namespace to exist.
|
||||||
|
"wordpress_specialist": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WordPress admin username (the user that owns the Application Password).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"WordPress Admin → Users → Profile → Application Passwords. "
|
||||||
|
"The user MUST have manage_options. Requires Airano MCP "
|
||||||
|
"Bridge v2.11.0+ on the WP side — install from "
|
||||||
|
"wordpress-plugin/airano-mcp-bridge.zip in the repo."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"gitea": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Access Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Gitea → Settings → Applications → Generate Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"n8n": [
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "n8n → Settings → API → Create API Key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supabase": [
|
||||||
|
{
|
||||||
|
"name": "service_role_key",
|
||||||
|
"label": "Service Role Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → service_role key. "
|
||||||
|
"Note: On supabase.com cloud, postgres-meta tools "
|
||||||
|
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
|
||||||
|
"they only work on self-hosted Supabase."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "anon_key",
|
||||||
|
"label": "Anon Key (Optional)",
|
||||||
|
"type": "password",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Supabase Dashboard → Settings → API → anon key. "
|
||||||
|
"Optional — if omitted, service_role_key is used for all calls. "
|
||||||
|
"Only useful for testing RLS policies as a regular user."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta_url",
|
||||||
|
"label": "postgres-meta URL (Optional)",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Only needed if your Supabase setup does not expose /pg/ through Kong. "
|
||||||
|
"Most self-hosted installs (including Coolify) work without this. "
|
||||||
|
"Example: http://supabase-meta:8080 or https://your-meta.example.com"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meta_auth",
|
||||||
|
"label": "postgres-meta Auth (Optional)",
|
||||||
|
"type": "password",
|
||||||
|
"required": False,
|
||||||
|
"advanced": True,
|
||||||
|
"hint": (
|
||||||
|
"Basic Auth for postgres-meta (format: username:password). "
|
||||||
|
"Only needed when postgres-meta is exposed via a public URL."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"openpanel": [
|
||||||
|
{
|
||||||
|
"name": "client_id",
|
||||||
|
"label": "Client ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client_secret",
|
||||||
|
"label": "Client Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Generated with your Client ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "project_id",
|
||||||
|
"label": "Project ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "organization_id",
|
||||||
|
"label": "Organization ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"appwrite": [
|
||||||
|
{
|
||||||
|
"name": "project_id",
|
||||||
|
"label": "Project ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → Project ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → API Keys → Create",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"directus": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Static Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Directus → Settings → User → Static Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"coolify": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "API Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Coolify → Keys & Tokens → API tokens → Create",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plugin display names for UI
|
||||||
|
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
"wordpress": "WordPress",
|
||||||
|
"woocommerce": "WooCommerce",
|
||||||
|
"wordpress_specialist": "WordPress Specialist",
|
||||||
|
"gitea": "Gitea",
|
||||||
|
"n8n": "n8n",
|
||||||
|
"supabase": "Supabase",
|
||||||
|
"openpanel": "OpenPanel",
|
||||||
|
"appwrite": "Appwrite",
|
||||||
|
"directus": "Directus",
|
||||||
|
"coolify": "Coolify",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoints per plugin type
|
||||||
|
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
|
||||||
|
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
|
||||||
|
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
|
||||||
|
# F.19.1: hits the companion's lightest admin route. Returns 404 if
|
||||||
|
# Airano MCP Bridge v2.11.0+ is missing, 403 if the user lacks
|
||||||
|
# ``manage_options``, 200 with a small JSON body otherwise. This
|
||||||
|
# makes "Test Connection" surface the actual end-to-end requirement
|
||||||
|
# instead of just "auth works" (which /users/me would).
|
||||||
|
"wordpress_specialist": {"path": "/wp-json/airano-mcp/v1/admin/maintenance", "method": "GET"},
|
||||||
|
"gitea": {"path": "/api/v1/user", "method": "GET"},
|
||||||
|
"n8n": {"path": "/healthz", "method": "GET"},
|
||||||
|
"supabase": {"path": "/rest/v1/", "method": "GET"},
|
||||||
|
"openpanel": {"path": "/healthcheck", "method": "GET"},
|
||||||
|
"appwrite": {"path": "/v1/health", "method": "GET"},
|
||||||
|
"directus": {"path": "/server/health", "method": "GET"},
|
||||||
|
"coolify": {"path": "/api/v1/version", "method": "GET"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get credential field definitions for a plugin type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of field definition dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If plugin_type is unknown.
|
||||||
|
"""
|
||||||
|
fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type)
|
||||||
|
if fields is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_credential_fields(is_admin: bool = False) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Get credential fields for the dashboard's Add/Edit Site forms.
|
||||||
|
|
||||||
|
Admin users see every registered plugin so they can use admin-only
|
||||||
|
plugins (wordpress_specialist) without having to add them to
|
||||||
|
``ENABLED_PLUGINS`` for everyone. Public users get only the plugins
|
||||||
|
enabled via ``ENABLED_PLUGINS`` env var (Track F.1).
|
||||||
|
|
||||||
|
F.19.1 (2026-05-01) — surfaced when an admin OAuth user added a
|
||||||
|
wordpress_specialist site and the manage page rendered an empty
|
||||||
|
credential form on revisit because the unfiltered map was being
|
||||||
|
filtered for public visibility. The site row exists but the
|
||||||
|
template can't render its fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_admin: When True, return every plugin's credential fields.
|
||||||
|
Defaults to False for backward compatibility.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of plugin_type -> field definitions.
|
||||||
|
"""
|
||||||
|
if is_admin:
|
||||||
|
return dict(PLUGIN_CREDENTIAL_FIELDS)
|
||||||
|
|
||||||
|
from core.plugin_visibility import get_public_plugin_types
|
||||||
|
|
||||||
|
public = get_public_plugin_types()
|
||||||
|
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_plugin_names(is_admin: bool = False) -> dict[str, str]:
|
||||||
|
"""Get plugin display names for the dashboard's plugin pickers.
|
||||||
|
|
||||||
|
Admin users see every registered plugin (so the Add Site dropdown
|
||||||
|
surfaces ``wordpress_specialist`` etc.); public users get only the
|
||||||
|
plugins enabled via ``ENABLED_PLUGINS`` env var.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_admin: When True, return every plugin's display name.
|
||||||
|
Defaults to False for backward compatibility.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of plugin_type -> display name.
|
||||||
|
"""
|
||||||
|
if is_admin:
|
||||||
|
return dict(PLUGIN_DISPLAY_NAMES)
|
||||||
|
|
||||||
|
from core.plugin_visibility import get_public_plugin_types
|
||||||
|
|
||||||
|
public = get_public_plugin_types()
|
||||||
|
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate that all required credential fields are present and non-empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
credentials: Dict of credential key→value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_error_messages).
|
||||||
|
"""
|
||||||
|
fields = get_credential_fields(plugin_type)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
if field["required"]:
|
||||||
|
value = credentials.get(field["name"], "").strip()
|
||||||
|
if not value:
|
||||||
|
errors.append(f"'{field['label']}' is required")
|
||||||
|
|
||||||
|
return (len(errors) == 0, errors)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_site_connection(
|
||||||
|
plugin_type: str, url: str, credentials: dict[str, str]
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to a site using HTTP health check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message). Message is "OK" on success or
|
||||||
|
a human-readable error description.
|
||||||
|
"""
|
||||||
|
endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type)
|
||||||
|
if endpoint_info is None:
|
||||||
|
return False, f"Unknown plugin type '{plugin_type}'"
|
||||||
|
|
||||||
|
check_url = url.rstrip("/") + endpoint_info["path"]
|
||||||
|
method = endpoint_info["method"]
|
||||||
|
|
||||||
|
# Build auth headers per plugin type
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if plugin_type in ("wordpress", "wordpress_specialist"):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
app_password = credentials.get("app_password", "")
|
||||||
|
token = base64.b64encode(f"{username}:{app_password}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "woocommerce":
|
||||||
|
import base64
|
||||||
|
|
||||||
|
ck = credentials.get("consumer_key", "")
|
||||||
|
cs = credentials.get("consumer_secret", "")
|
||||||
|
token = base64.b64encode(f"{ck}:{cs}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "gitea":
|
||||||
|
headers["Authorization"] = f"token {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "n8n":
|
||||||
|
headers["X-N8N-API-KEY"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type == "supabase":
|
||||||
|
headers["apikey"] = credentials.get("service_role_key", "")
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}"
|
||||||
|
elif plugin_type == "appwrite":
|
||||||
|
headers["X-Appwrite-Project"] = credentials.get("project_id", "")
|
||||||
|
headers["X-Appwrite-Key"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type in ("directus", "coolify"):
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "openpanel":
|
||||||
|
headers["openpanel-client-id"] = credentials.get("client_id", "")
|
||||||
|
headers["openpanel-client-secret"] = credentials.get("client_secret", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
if method == "POST":
|
||||||
|
async with session.post(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
else:
|
||||||
|
async with session.get(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
|
||||||
|
if status_code < 400:
|
||||||
|
return True, "OK"
|
||||||
|
elif status_code == 401:
|
||||||
|
return False, "Authentication failed — check credentials"
|
||||||
|
elif status_code == 403:
|
||||||
|
return False, "Access forbidden — check permissions or API may be disabled"
|
||||||
|
elif status_code == 404:
|
||||||
|
return False, f"Endpoint not found at {check_url} — check URL"
|
||||||
|
else:
|
||||||
|
return False, f"HTTP {status_code}: {resp_text[:200]}"
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return False, "Connection failed — check URL and ensure the site is reachable"
|
||||||
|
except TimeoutError:
|
||||||
|
return False, "Connection timed out (15s) — site may be slow or unreachable"
|
||||||
|
except aiohttp.InvalidURL:
|
||||||
|
return False, "Invalid URL protocol — use https:// or http://"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Connection error: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user_site(
|
||||||
|
user_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
alias: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new site for a user.
|
||||||
|
|
||||||
|
Validates credentials, tests the connection, encrypts credentials,
|
||||||
|
and stores in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
alias: User-chosen friendly name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On validation errors (bad plugin type, missing fields,
|
||||||
|
alias taken, site limit reached, connection failed).
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
# Validate plugin type
|
||||||
|
if plugin_type not in PLUGIN_CREDENTIAL_FIELDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate alias format
|
||||||
|
alias = alias.strip().lower()
|
||||||
|
if not alias or len(alias) < 2 or len(alias) > 50:
|
||||||
|
raise ValueError("Alias must be 2-50 characters")
|
||||||
|
if not alias.replace("-", "").replace("_", "").isalnum():
|
||||||
|
raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores")
|
||||||
|
|
||||||
|
# Validate required credential fields
|
||||||
|
valid, errors = validate_credentials(plugin_type, credentials)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Check site limit (reads DB > ENV > default so dashboard/settings changes take effect)
|
||||||
|
from core.settings import get_cached_max_sites
|
||||||
|
|
||||||
|
max_sites = get_cached_max_sites()
|
||||||
|
count = await db.count_sites_by_user(user_id)
|
||||||
|
if count >= max_sites:
|
||||||
|
raise ValueError(f"Site limit reached ({max_sites} sites per user)")
|
||||||
|
|
||||||
|
# Check alias uniqueness (DB constraint will also catch this)
|
||||||
|
existing = await db.get_site_by_alias(user_id, alias)
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Alias '{alias}' is already in use")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
status = "active"
|
||||||
|
status_msg = "Connection verified"
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, credentials)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt credentials
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
# We need the site_id for encryption, but we don't have it yet.
|
||||||
|
# Use a pre-generated UUID as the site_id.
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
site_id = str(uuid.uuid4())
|
||||||
|
encrypted = encryptor.encrypt_credentials(credentials, site_id)
|
||||||
|
|
||||||
|
# Store in database — we bypass db.create_site() to use our pre-generated ID
|
||||||
|
from core.database import _utc_now
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
||||||
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the created site (without credentials blob)
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||||
|
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_sites(user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all sites for a user (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dicts.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
sites = await db.get_sites_by_user(user_id)
|
||||||
|
# Strip credentials blob from response
|
||||||
|
return [{k: v for k, v in site.items() if k != "credentials"} for site in sites]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a single site (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict or None.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
return None
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete a user's site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_site(site_id, user_id)
|
||||||
|
if deleted:
|
||||||
|
logger.info("Deleted site %s for user %s", site_id, user_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_site(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update URL and credentials for an existing site.
|
||||||
|
|
||||||
|
Password fields left blank are preserved from the existing encrypted credentials.
|
||||||
|
Re-validates the connection after update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
url: New base URL (required).
|
||||||
|
credentials: Credential dict — blank password fields keep their current value.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found, validation fails, or connection test fails.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Verify site ownership
|
||||||
|
existing_site = await db.get_site(site_id, user_id)
|
||||||
|
if existing_site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
plugin_type = existing_site["plugin_type"]
|
||||||
|
|
||||||
|
# Validate URL
|
||||||
|
url = url.strip().rstrip("/")
|
||||||
|
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||||
|
raise ValueError("URL must start with http:// or https://")
|
||||||
|
|
||||||
|
# Merge new credentials with existing ones — blank fields keep existing values
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
|
||||||
|
|
||||||
|
merged: dict[str, str] = dict(existing_credentials)
|
||||||
|
for key, value in credentials.items():
|
||||||
|
# Only override if the new value is non-empty
|
||||||
|
if value and value.strip():
|
||||||
|
merged[key] = value.strip()
|
||||||
|
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
|
||||||
|
else:
|
||||||
|
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
|
||||||
|
if key in field_defs and not field_defs[key].get("required", True):
|
||||||
|
merged[key] = ""
|
||||||
|
|
||||||
|
# Strip empty optional values before storing (keep storage clean)
|
||||||
|
merged = {k: v for k, v in merged.items() if v}
|
||||||
|
|
||||||
|
# Validate required fields are still present
|
||||||
|
valid, errors = validate_credentials(plugin_type, merged)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, merged)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt merged credentials
|
||||||
|
encrypted = encryptor.encrypt_credentials(merged, site_id)
|
||||||
|
|
||||||
|
# Persist
|
||||||
|
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
|
||||||
|
if not updated:
|
||||||
|
raise RuntimeError(f"Failed to update site {site_id}")
|
||||||
|
|
||||||
|
# Mark active after successful connection test
|
||||||
|
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
|
||||||
|
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
|
||||||
|
|
||||||
|
result = await db.get_site(site_id, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back updated site {site_id}")
|
||||||
|
|
||||||
|
site_dict = dict(result)
|
||||||
|
site_dict.pop("credentials", None)
|
||||||
|
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
|
||||||
|
return site_dict
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Only WordPress / WooCommerce sites can host AI image provider keys — the
|
||||||
|
# only consumer is ``wordpress_generate_and_upload_image``, which uploads into
|
||||||
|
# a WordPress media library. Other plugin types never surface the section.
|
||||||
|
PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"})
|
||||||
|
|
||||||
|
# Providers supported for per-site keys. Must stay in sync with
|
||||||
|
# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to
|
||||||
|
# drive the UI).
|
||||||
|
SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter")
|
||||||
|
|
||||||
|
|
||||||
|
def site_provider_scope(site_id: str, provider: str) -> str:
|
||||||
|
"""Return the HKDF scope used for per-site provider-key encryption.
|
||||||
|
|
||||||
|
Format: ``site_provider:{site_id}:{provider}`` — distinct from the
|
||||||
|
per-site credentials scope (bare ``site_id``) so the same master key
|
||||||
|
never produces the same derived key for both credential types.
|
||||||
|
"""
|
||||||
|
return f"site_provider:{site_id}:{provider}"
|
||||||
|
|
||||||
|
|
||||||
|
def site_supports_provider_keys(plugin_type: str) -> bool:
|
||||||
|
"""Return True if the plugin type can hold AI provider keys."""
|
||||||
|
return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
async def set_site_provider_key(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
provider: str,
|
||||||
|
api_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Encrypt and store an AI provider API key for a user-owned site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (enforces tenant isolation).
|
||||||
|
provider: One of :data:`SITE_PROVIDERS`.
|
||||||
|
api_key: The plaintext API key to store.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored row (without plaintext).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the site is not found, the plugin type does not
|
||||||
|
support provider keys, the provider is unknown, or the key
|
||||||
|
is empty.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
if provider not in SITE_PROVIDERS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}"
|
||||||
|
)
|
||||||
|
if not api_key or not api_key.strip():
|
||||||
|
raise ValueError("API key must not be empty")
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
if not site_supports_provider_keys(site["plugin_type"]):
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin type '{site['plugin_type']}' does not support AI "
|
||||||
|
f"provider keys (only WordPress/WooCommerce)."
|
||||||
|
)
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
ciphertext = encryptor.encrypt_for_scope(
|
||||||
|
api_key.strip(), site_provider_scope(site_id, provider)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await db.upsert_site_provider_key(site_id, provider, ciphertext)
|
||||||
|
logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def get_site_provider_key(
|
||||||
|
site_id: str,
|
||||||
|
provider: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the decrypted AI provider key for a site, or None.
|
||||||
|
|
||||||
|
Caller is responsible for verifying site ownership — this helper is
|
||||||
|
also called from the AI tool handler which already trusts the
|
||||||
|
resolved site_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
provider: Provider identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted API key string, or None if no key is stored.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
if provider not in SITE_PROVIDERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
row = await db.get_site_provider_key(site_id, provider)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
try:
|
||||||
|
return encryptor.decrypt_for_scope(
|
||||||
|
row["key_ciphertext"], site_provider_scope(site_id, provider)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to decrypt site provider key (site=%s provider=%s)",
|
||||||
|
site_id,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def list_site_providers_set(site_id: str) -> set[str]:
|
||||||
|
"""Return the set of providers that have a key stored for the site."""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
rows = await db.list_site_provider_keys(site_id)
|
||||||
|
return {row["provider"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_site_provider_key(
|
||||||
|
site_id: str,
|
||||||
|
user_id: str,
|
||||||
|
provider: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove an AI provider key for a site. Returns True if a row was deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID — enforces tenant isolation before deletion.
|
||||||
|
provider: Provider identifier.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
deleted = await db.delete_site_provider_key(site_id, provider)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
"Deleted %s provider key for site %s (user %s)",
|
||||||
|
provider,
|
||||||
|
site_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to an existing site.
|
||||||
|
|
||||||
|
Decrypts credentials from the database, runs the health check,
|
||||||
|
and updates the site status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials)
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
new_status = "active" if ok else "error"
|
||||||
|
await db.update_site_status(site_id, new_status, msg, user_id=user_id)
|
||||||
|
|
||||||
|
return ok, msg
|
||||||
@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
|
|||||||
Manages site configurations with Pydantic validation.
|
Manages site configurations with Pydantic validation.
|
||||||
Part of Option B clean architecture refactoring.
|
Part of Option B clean architecture refactoring.
|
||||||
|
|
||||||
Discovers sites from environment variables:
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
The SiteManager provides registration and lookup infrastructure for
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
plugin tool generation and endpoint routing.
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_ALIAS=myblog
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||||
@@ -50,6 +42,7 @@ class SiteConfig(BaseModel):
|
|||||||
site_id: str = Field(..., description="Unique site identifier")
|
site_id: str = Field(..., description="Unique site identifier")
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
alias: str | None = Field(None, description="Friendly alias for the site")
|
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)
|
# Common config fields (plugins may require additional fields)
|
||||||
url: str | None = Field(None, description="Site URL")
|
url: str | None = Field(None, description="Site URL")
|
||||||
@@ -110,7 +103,8 @@ class SiteManager:
|
|||||||
"""
|
"""
|
||||||
Manage site configurations with type safety.
|
Manage site configurations with type safety.
|
||||||
|
|
||||||
Discovers, validates, and provides access to site configurations.
|
Provides registration and lookup of site configurations.
|
||||||
|
Sites are registered programmatically (e.g., from database) via register_site().
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
sites: Dictionary mapping plugin_type to site configurations
|
sites: Dictionary mapping plugin_type to site configurations
|
||||||
@@ -119,7 +113,7 @@ class SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = SiteManager()
|
>>> manager = SiteManager()
|
||||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
>>> manager.register_site(config)
|
||||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||||
>>> sites = manager.list_sites('wordpress')
|
>>> sites = manager.list_sites('wordpress')
|
||||||
"""
|
"""
|
||||||
@@ -135,167 +129,6 @@ class SiteManager:
|
|||||||
self.logger = logging.getLogger("SiteManager")
|
self.logger = logging.getLogger("SiteManager")
|
||||||
self.logger.info("SiteManager initialized")
|
self.logger.info("SiteManager initialized")
|
||||||
|
|
||||||
def discover_sites(self, plugin_types: list[str]) -> int:
|
|
||||||
"""
|
|
||||||
Discover sites from environment variables.
|
|
||||||
|
|
||||||
Scans environment for site configurations and registers them.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sites discovered
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> count = manager.discover_sites(['wordpress', 'gitea'])
|
|
||||||
>>> print(f"Discovered {count} sites")
|
|
||||||
"""
|
|
||||||
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
|
|
||||||
|
|
||||||
total_discovered = 0
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
count = self._discover_plugin_sites(plugin_type)
|
|
||||||
total_discovered += count
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Discovery complete. Found {total_discovered} sites "
|
|
||||||
f"across {len(plugin_types)} plugin types."
|
|
||||||
)
|
|
||||||
|
|
||||||
return total_discovered
|
|
||||||
|
|
||||||
# Reserved words that should NOT be interpreted as site IDs
|
|
||||||
RESERVED_SITE_WORDS = {
|
|
||||||
"limit",
|
|
||||||
"rate",
|
|
||||||
"config",
|
|
||||||
"debug",
|
|
||||||
"log",
|
|
||||||
"level",
|
|
||||||
"mode",
|
|
||||||
"timeout",
|
|
||||||
"retry",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"default",
|
|
||||||
"global",
|
|
||||||
"enabled",
|
|
||||||
"disabled",
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"path",
|
|
||||||
"key",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"advanced",
|
|
||||||
"basic",
|
|
||||||
"simple",
|
|
||||||
"pro",
|
|
||||||
"premium",
|
|
||||||
"standard",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _discover_plugin_sites(self, plugin_type: str) -> int:
|
|
||||||
"""
|
|
||||||
Discover all sites for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of sites discovered for this plugin type
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> count = manager._discover_plugin_sites('wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
|
||||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
|
||||||
from plugins import registry as plugin_registry
|
|
||||||
|
|
||||||
all_plugin_types = plugin_registry.get_registered_types()
|
|
||||||
longer_prefixes = [
|
|
||||||
pt.upper() + "_"
|
|
||||||
for pt in all_plugin_types
|
|
||||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
# Find all unique site IDs
|
|
||||||
site_ids = set()
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
# Skip env vars that belong to a more specific plugin type
|
|
||||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
|
||||||
continue
|
|
||||||
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
site_id = match.group(1).lower()
|
|
||||||
# Skip reserved words that are not real site IDs
|
|
||||||
if site_id not in self.RESERVED_SITE_WORDS:
|
|
||||||
site_ids.add(site_id)
|
|
||||||
|
|
||||||
# Load configuration for each site
|
|
||||||
discovered_count = 0
|
|
||||||
for site_id in site_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_site_config(plugin_type, site_id)
|
|
||||||
if config:
|
|
||||||
self.register_site(config)
|
|
||||||
discovered_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return discovered_count
|
|
||||||
|
|
||||||
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a site from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_id: Site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SiteConfig if successful, None if incomplete
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> config = manager._load_site_config('wordpress', 'site1')
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
|
||||||
config_data = {"site_id": site_id, "plugin_type": plugin_type}
|
|
||||||
|
|
||||||
# Collect all config keys for this site
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config_data[config_key] = env_value
|
|
||||||
|
|
||||||
# Must have at least some configuration beyond site_id and plugin_type
|
|
||||||
if len(config_data) <= 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create and validate SiteConfig
|
|
||||||
config = SiteConfig(**config_data)
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def register_site(self, config: SiteConfig) -> None:
|
def register_site(self, config: SiteConfig) -> None:
|
||||||
"""
|
"""
|
||||||
Register a site configuration.
|
Register a site configuration.
|
||||||
@@ -355,7 +188,7 @@ class SiteManager:
|
|||||||
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No sites configured for plugin type: {plugin_type}. "
|
f"No sites configured for plugin type: {plugin_type}. "
|
||||||
f"Please check your environment variables."
|
f"Please add a site via the dashboard."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try direct lookup
|
# Try direct lookup
|
||||||
@@ -372,7 +205,7 @@ class SiteManager:
|
|||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Site '{site}' not configured for {plugin_type}. "
|
f"Site '{site}' not configured for {plugin_type}. "
|
||||||
f"Please verify the site alias/ID and check environment variables."
|
f"Please verify the site alias/ID in the dashboard."
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_sites(self, plugin_type: str) -> list[str]:
|
def list_sites(self, plugin_type: str) -> list[str]:
|
||||||
@@ -452,6 +285,7 @@ class SiteManager:
|
|||||||
"site_id": config.site_id,
|
"site_id": config.site_id,
|
||||||
"alias": config.alias,
|
"alias": config.alias,
|
||||||
"full_id": config.get_full_id(),
|
"full_id": config.get_full_id(),
|
||||||
|
"user_id": config.user_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -543,7 +377,7 @@ def get_site_manager() -> SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = get_site_manager()
|
>>> manager = get_site_manager()
|
||||||
>>> manager.discover_sites(['wordpress'])
|
>>> manager.register_site(config)
|
||||||
"""
|
"""
|
||||||
global _site_manager
|
global _site_manager
|
||||||
if _site_manager is None:
|
if _site_manager is None:
|
||||||
|
|||||||
@@ -1,371 +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.info("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.info(f" Alias '{alias}': {winner} (winner), {losers} (using full_id)")
|
|
||||||
|
|
||||||
# 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.info(
|
|
||||||
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
|
|
||||||
@@ -1,18 +1,100 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ lang|default('en') }}">
|
<html lang="{{ lang|default('en') }}" dir="{% if lang|default('en') == 'fa' %}rtl{% else %}ltr{% endif %}" data-theme="dark" data-theme-pref="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>{% block title %}MCP Hub{% endblock %}</title>
|
<title>{% block title %}MCP Hub{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var html = document.documentElement;
|
||||||
|
var raw = localStorage.getItem("mcphub-ui");
|
||||||
|
var parsed = raw ? JSON.parse(raw) : null;
|
||||||
|
var state = parsed && parsed.state ? parsed.state : null;
|
||||||
|
var serverLang = html.getAttribute("lang") === "fa" ? "fa" : "en";
|
||||||
|
var storedLang = state && state.lang === "fa" ? "fa" : state && state.lang === "en" ? "en" : null;
|
||||||
|
var lang = storedLang || serverLang;
|
||||||
|
html.setAttribute("lang", lang);
|
||||||
|
html.setAttribute("dir", lang === "fa" ? "rtl" : "ltr");
|
||||||
|
|
||||||
|
var themePref = state && (state.theme === "light" || state.theme === "system" || state.theme === "dark")
|
||||||
|
? state.theme
|
||||||
|
: "dark";
|
||||||
|
var resolved = themePref;
|
||||||
|
if (themePref === "system" && window.matchMedia) {
|
||||||
|
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
html.setAttribute("data-theme", resolved);
|
||||||
|
html.setAttribute("data-theme-pref", themePref);
|
||||||
|
html.classList.toggle("dark", resolved === "dark");
|
||||||
|
html.style.colorScheme = resolved;
|
||||||
|
if (state && state.brandHue) {
|
||||||
|
html.style.setProperty("--brand-hue", String(state.brandHue));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedLang && storedLang !== serverLang && location.pathname === "/oauth/authorize") {
|
||||||
|
var url = new URL(location.href);
|
||||||
|
if (!url.searchParams.has("lang")) {
|
||||||
|
url.searchParams.set("lang", storedLang);
|
||||||
|
location.replace(url.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Tailwind CSS from CDN -->
|
<!-- Tailwind CSS from CDN -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = { darkMode: "class" };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.bunny.net/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Vazirmatn:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
<!-- Custom styling -->
|
<!-- Custom styling -->
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: rgb(249 250 251);
|
||||||
|
}
|
||||||
|
html[lang="fa"] body,
|
||||||
|
html[lang="fa"] button,
|
||||||
|
html[lang="fa"] input,
|
||||||
|
html[lang="fa"] textarea,
|
||||||
|
html[lang="fa"] select {
|
||||||
|
font-family: Vazirmatn, Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] body {
|
||||||
|
background: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:bg-gray-900 {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:bg-gray-800 {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:bg-gray-700 {
|
||||||
|
background-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:text-white {
|
||||||
|
color: rgb(255 255 255);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:text-gray-200 {
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:text-gray-300 {
|
||||||
|
color: rgb(209 213 219);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .dark\:text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
}
|
}
|
||||||
.auth-container {
|
.auth-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
59
core/templates/dashboard/404.html
Normal file
59
core/templates/dashboard/404.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark: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-500 dark: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-primary-500 dark:text-primary-400 mb-4">404</h1>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-300 mb-3">Page Not Found</p>
|
||||||
|
<p class="text-sm text-gray-500 dark: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-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors">
|
||||||
|
Health Check
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-12 text-xs text-gray-400 dark:text-gray-600">MCP Hub</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<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 %}
|
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- 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">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select
|
||||||
name="project"
|
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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select
|
||||||
name="status"
|
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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_project or selected_status != 'active' %}
|
{% 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-gray-800 dark:text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -84,42 +84,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Table -->
|
<!-- 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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<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 %}
|
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if api_keys %}
|
{% if api_keys %}
|
||||||
{% for key in 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 -->
|
<!-- Key ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
<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
|
<button
|
||||||
onclick="copyToClipboard('{{ key.key_id }}')"
|
onclick="copyToClipboard('{{ key.key_id }}')"
|
||||||
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
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 %}
|
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<td class="px-6 py-4">
|
<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>
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
|
|
||||||
<!-- Usage -->
|
<!-- Usage -->
|
||||||
<td class="px-6 py-4">
|
<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>
|
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
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 %}"
|
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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">
|
<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"/>
|
<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>
|
</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 %}
|
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -236,8 +236,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<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-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% 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 %}"
|
<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-gray-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<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-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) %}
|
{% 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 %}"
|
<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-gray-800 dark:text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% 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 %}"
|
<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-gray-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -280,9 +280,9 @@
|
|||||||
|
|
||||||
<!-- Create Key Modal -->
|
<!-- Create Key Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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="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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,13 +291,13 @@
|
|||||||
|
|
||||||
<!-- Project -->
|
<!-- Project -->
|
||||||
<div>
|
<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 %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="project_id"
|
name="project_id"
|
||||||
required
|
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>
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
{% for project in available_projects %}
|
{% for project in available_projects %}
|
||||||
@@ -308,46 +308,46 @@
|
|||||||
|
|
||||||
<!-- Scope -->
|
<!-- Scope -->
|
||||||
<div>
|
<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 %}
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center">
|
<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">
|
<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>
|
||||||
<label class="flex items-center">
|
<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">
|
<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>
|
||||||
<label class="flex items-center">
|
<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">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<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 %}
|
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Expiration -->
|
<!-- Expiration -->
|
||||||
<div>
|
<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 %}
|
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="expires_in_days"
|
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="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
||||||
@@ -357,10 +357,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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
|
<button
|
||||||
onclick="closeCreateModal()"
|
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-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -376,9 +376,9 @@
|
|||||||
|
|
||||||
<!-- New Key Modal (shows the actual key) -->
|
<!-- 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 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="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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white flex items-center">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="newKeyValue"
|
id="newKeyValue"
|
||||||
readonly
|
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
|
<button
|
||||||
onclick="copyNewKey()"
|
onclick="copyNewKey()"
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<button
|
||||||
onclick="closeNewKeyModal()"
|
onclick="closeNewKeyModal()"
|
||||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
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 -->
|
<!-- Revoke Modal -->
|
||||||
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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="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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -445,10 +445,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onclick="closeRevokeModal()"
|
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-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -464,14 +464,14 @@
|
|||||||
|
|
||||||
<!-- Delete Modal -->
|
<!-- Delete Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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="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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -482,10 +482,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
onclick="closeDeleteModal()"
|
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-gray-800 dark:text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Filters -->
|
<!-- 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">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select
|
||||||
name="project"
|
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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
|
|
||||||
<!-- Event Type Filter -->
|
<!-- Event Type Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select
|
||||||
name="event_type"
|
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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
|
|
||||||
<!-- Level Filter -->
|
<!-- Level Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select
|
||||||
name="level"
|
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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
@@ -60,12 +60,12 @@
|
|||||||
|
|
||||||
<!-- Date Filter -->
|
<!-- Date Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="date"
|
name="date"
|
||||||
value="{{ selected_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()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
{% 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'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
|
|
||||||
<!-- Stats Summary -->
|
<!-- Stats Summary -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</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-white">{{ stats.total }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</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-white">{{ stats.tool_calls }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.tool_calls }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</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-white">{{ stats.auth_events }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.auth_events }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</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-white">{{ stats.errors }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.errors }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -150,35 +150,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Table -->
|
<!-- 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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<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 %}
|
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
{% for log in 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 -->
|
<!-- Timestamp -->
|
||||||
<td class="px-6 py-4">
|
<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>
|
</td>
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
<!-- Event/Message -->
|
<!-- Event/Message -->
|
||||||
<td class="px-6 py-4">
|
<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 %}
|
{% if log.project_id %}
|
||||||
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
||||||
{% endif %}
|
{% 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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -249,8 +249,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<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-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -261,17 +261,17 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% 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 %}"
|
<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 %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in range(1, total_pages + 1) %}
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
{% if page_num == page_number %}
|
{% 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) %}
|
{% 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 %}"
|
<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 }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% 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 %}"
|
<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 %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
209
core/templates/dashboard/auth-login.html
Normal file
209
core/templates/dashboard/auth-login.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!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 + Admin Login Link (hidden when DISABLE_MASTER_KEY_LOGIN=true) -->
|
||||||
|
{% if not master_login_disabled %}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
@@ -1,69 +1,44 @@
|
|||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Vazirmatn Font for Persian -->
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
<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>
|
<style>
|
||||||
[x-cloak] { display: none !important; }
|
/* Custom scrollbar — light mode */
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1f2937;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: #d1d5db;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-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;
|
background: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +51,7 @@
|
|||||||
.card-hover {
|
.card-hover {
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover:hover {
|
.card-hover:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||||
@@ -85,9 +61,17 @@
|
|||||||
.status-pulse {
|
.status-pulse {
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* RTL adjustments */
|
/* RTL adjustments */
|
||||||
@@ -95,80 +79,168 @@
|
|||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir="ltr"] .sidebar-icon {
|
[dir="ltr"] .sidebar-icon {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vazirmatn font for Persian */
|
|
||||||
[dir="rtl"],
|
|
||||||
[dir="rtl"] * {
|
|
||||||
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-gray-100 min-h-screen" x-data="{ sidebarOpen: true, darkMode: true }">
|
|
||||||
|
{# ── Derive RBAC info from session ── #}
|
||||||
|
{% set _is_admin = is_admin_session(session) %}
|
||||||
|
{% set _is_oauth_admin = _is_admin and session is mapping %}
|
||||||
|
{% 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">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col {% if lang=='fa' %}border-l{% else %}border-r{% endif %} border-gray-200 dark:border-gray-700"
|
||||||
class="sidebar-transition bg-gray-800 border-gray-700 flex flex-col"
|
:class="sidebarOpen ? 'w-64' : 'w-20'">
|
||||||
:class="sidebarOpen ? 'w-64' : 'w-20'"
|
|
||||||
{% if lang == 'fa' %}style="border-left: 1px solid;"{% else %}style="border-right: 1px solid;"{% endif %}
|
|
||||||
>
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-700">
|
<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">
|
<div class="flex items-center" x-show="sidebarOpen">
|
||||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
<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">MCP Hub</span>
|
<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>
|
||||||
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
<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">
|
||||||
<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-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||||
{% set nav_items = [
|
{# ── Common nav items (all users) ── #}
|
||||||
('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'),
|
{% set common_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'),
|
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1
|
||||||
('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'),
|
1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
||||||
('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 %}
|
{# ── User-only nav items ── #}
|
||||||
<a
|
{% set user_nav = [
|
||||||
href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
('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
|
||||||
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 %}"
|
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'),
|
||||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
||||||
|
('keys', t.get('keys', '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/keys'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# ── 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'),
|
||||||
|
('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 }}" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
</svg>
|
</svg>
|
||||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Render user nav (non-admin users + OAuth admins) #}
|
||||||
|
{% if not _is_admin or _is_oauth_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>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Support link -->
|
||||||
|
<div x-show="sidebarOpen" class="px-4 py-2">
|
||||||
|
<a href="https://nowpayments.io/donation/airano" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="flex items-center text-xs text-gray-400 dark:text-gray-500 hover:text-pink-400 dark:hover:text-pink-400 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 flex-shrink-0" 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>
|
||||||
|
{% if lang == 'fa' %}حمایت از MCP Hub{% else %}Support MCP Hub{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version footer -->
|
||||||
|
{% if project_version %}
|
||||||
|
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
v{{ project_version }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- User Section -->
|
<!-- User Section -->
|
||||||
<div class="p-4 border-t border-gray-700">
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a
|
{# Profile link for OAuth users (including OAuth admins) #}
|
||||||
href="/dashboard/logout"
|
{% if not _is_admin or _is_oauth_admin %}
|
||||||
class="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg transition-colors"
|
<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">
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -176,39 +248,57 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 overflow-y-auto bg-gray-900">
|
<main class="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
||||||
<!-- Top Header -->
|
<!-- Top Header -->
|
||||||
<header class="sticky top-0 z-10 bg-gray-800/95 backdrop-blur border-b border-gray-700">
|
<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">
|
<div class="flex items-center justify-between h-16 px-6">
|
||||||
<h1 class="text-xl font-semibold">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
<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 %}">
|
<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 -->
|
<!-- Language Toggle -->
|
||||||
<a
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||||
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">
|
||||||
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 %}
|
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Refresh Button -->
|
<!-- Refresh Button -->
|
||||||
<button
|
<button hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
hx-target="body" hx-swap="outerHTML"
|
||||||
hx-target="body"
|
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"
|
||||||
hx-swap="outerHTML"
|
title="{{ t.refresh }}">
|
||||||
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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
{% if session %}
|
{% if session %}
|
||||||
<div class="flex items-center text-sm text-gray-400">
|
<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="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
{{ session.user_type }}
|
<span class="text-gray-600 dark:text-gray-400">{{ _display.name }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,4 +314,5 @@
|
|||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
297
core/templates/dashboard/connect.html
Normal file
297
core/templates/dashboard/connect.html
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
{% 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 Modal (stays open until user clicks Done) -->
|
||||||
|
<div id="new-key-display" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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 gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.your_api_key }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ t.key_shown_once }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-key-value" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyNewKey()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}تأیید{% else %}Done{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 + Connection Guide (unified) -->
|
||||||
|
<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 }}" data-plugin="{{ site.plugin_type }}">{{ 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>
|
||||||
|
|
||||||
|
<!-- Transport type info (hidden for web-based clients via JS) -->
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WordPress SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
|
||||||
|
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>وردپرس:</strong> برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
|
||||||
|
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||||
|
را دانلود و روی سایت وردپرسی خود نصب کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
|
||||||
|
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||||
|
companion plugin on your WordPress site.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection tip (shown only for web-based clients via JS) -->
|
||||||
|
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key hint for desktop/CLI clients (hidden for web clients via JS) -->
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one above and use it in your config:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</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');
|
||||||
|
document.getElementById('new-key-display').classList.add('flex');
|
||||||
|
// Modal stays open — user clicks "Done" to dismiss
|
||||||
|
} 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const siteSelect = document.getElementById('config-site');
|
||||||
|
const alias = siteSelect?.value;
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!alias || !client) return;
|
||||||
|
|
||||||
|
const isWebClient = WEB_CLIENTS.includes(client);
|
||||||
|
|
||||||
|
// Show/hide transport note
|
||||||
|
const transportNote = document.getElementById('transport-note');
|
||||||
|
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
// Show/hide connection tip (only for web clients)
|
||||||
|
const connTip = document.getElementById('connection-tip');
|
||||||
|
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||||
|
|
||||||
|
// Show/hide bearer hint (only for desktop/CLI clients)
|
||||||
|
const bearerHint = document.getElementById('bearer-hint');
|
||||||
|
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
// Show/hide WordPress SEO note based on selected site's plugin type
|
||||||
|
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||||
|
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||||
|
const wpNote = document.getElementById('wp-seo-note');
|
||||||
|
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||||
|
|
||||||
|
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 closeNewKeyModal() {
|
||||||
|
document.getElementById('new-key-display').classList.add('hidden');
|
||||||
|
document.getElementById('new-key-display').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -->
|
<!-- System Status Overview -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Overall Status -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<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 %}
|
{% if async_load %}
|
||||||
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{{ t.system_uptime }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ uptime.formatted }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ uptime.formatted }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -59,11 +59,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Requests -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</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-white">{{ metrics.total_requests|default(0) }}</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>
|
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Rate -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<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 %}">
|
<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) }}%
|
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
||||||
</p>
|
</p>
|
||||||
@@ -113,8 +113,8 @@
|
|||||||
<!-- Response Time & Throughput -->
|
<!-- Response Time & Throughput -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Response Time -->
|
<!-- Response Time -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
@@ -134,22 +134,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Request Summary -->
|
<!-- Request Summary -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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>
|
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<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 total = metrics.total_requests|default(0) %}
|
||||||
{% set successful = metrics.successful_requests|default(0) %}
|
{% set successful = metrics.successful_requests|default(0) %}
|
||||||
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
||||||
@@ -164,12 +164,12 @@
|
|||||||
<div id="alerts-container"></div>
|
<div id="alerts-container"></div>
|
||||||
|
|
||||||
<!-- Projects Health -->
|
<!-- Projects Health -->
|
||||||
<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="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
|
<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-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
||||||
</h3>
|
</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 async_load %}
|
||||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -180,21 +180,21 @@
|
|||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<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 %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
<!-- HTMX async loading - wraps tbody for proper swap -->
|
<!-- HTMX async loading - wraps tbody for proper swap -->
|
||||||
<tbody
|
<tbody
|
||||||
id="projects-health-body"
|
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-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<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>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,12 +223,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% 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">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<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 %}">
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<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 %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -272,26 +272,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Information -->
|
<!-- System Information -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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-gray-900 dark:text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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-gray-900 dark:text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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-gray-900 dark:text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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-gray-900 dark:text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
<!-- Refresh Buttons -->
|
<!-- Refresh Buttons -->
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
{% if is_cached %}
|
{% 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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<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-gray-800 dark:text-white text-sm transition-colors">
|
||||||
{{ t.refresh }}
|
{{ t.refresh }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- Projects Table Rows -->
|
<!-- Projects Table Rows -->
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% 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">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<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 %}">
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<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 %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,14 +5,20 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Stats Cards -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-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-3 gap-6">
|
||||||
<!-- Projects Card -->
|
<!-- Projects 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.total_projects }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_projects }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.projects_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.projects_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -29,11 +35,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.active_api_keys }}</p>
|
<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-white mt-1">{{ stats.api_keys_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -50,11 +56,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.total_tools }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_tools }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.tools_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -63,17 +69,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm text-gray-400">
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Card -->
|
<!-- Uptime 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.system_uptime }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -88,22 +94,57 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Users 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">{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.users_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}کاربران ثبتنام شده{% else %}Registered users{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- User 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">{% if lang == 'fa' %}سایتهای کاربران{% else %}User Sites{% endif %}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.user_sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-teal-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-teal-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>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}سایتهای متصل شده{% else %}Connected sites{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Main Content Grid -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if recent_activity %}
|
{% if recent_activity %}
|
||||||
{% for activity in recent_activity %}
|
{% for activity in recent_activity %}
|
||||||
<div class="p-4 hover:bg-gray-700/50 transition-colors">
|
<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 justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- Icon based on type -->
|
|
||||||
{% if activity.type == 'tool_call' %}
|
{% 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 %}">
|
<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">
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -131,16 +172,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-white">{{ activity.message }}</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ activity.message }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ activity.project }}</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>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:19] }}</span>
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-8 text-center text-gray-400">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -149,9 +190,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if recent_activity %}
|
{% if recent_activity %}
|
||||||
<div class="p-4 border-t border-gray-700">
|
<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">
|
<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 }} →
|
{{ t.view_all }} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -160,15 +201,15 @@
|
|||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Projects by Type -->
|
<!-- Projects by Type -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.projects_by_type }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.projects_by_type }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
{% set plugin_colors = {
|
{% set plugin_colors = {
|
||||||
'wordpress': 'bg-blue-500',
|
'wordpress': 'bg-blue-500',
|
||||||
'woocommerce': 'bg-purple-500',
|
'woocommerce': 'bg-purple-500',
|
||||||
'wordpress_advanced': 'bg-indigo-500',
|
'wordpress_specialist': 'bg-indigo-500',
|
||||||
'gitea': 'bg-green-500',
|
'gitea': 'bg-green-500',
|
||||||
'n8n': 'bg-orange-500',
|
'n8n': 'bg-orange-500',
|
||||||
'supabase': 'bg-emerald-500',
|
'supabase': 'bg-emerald-500',
|
||||||
@@ -180,7 +221,7 @@
|
|||||||
{% set plugin_icons = {
|
{% set plugin_icons = {
|
||||||
'wordpress': 'W',
|
'wordpress': 'W',
|
||||||
'woocommerce': 'WC',
|
'woocommerce': 'WC',
|
||||||
'wordpress_advanced': 'WA',
|
'wordpress_specialist': 'WS',
|
||||||
'gitea': 'G',
|
'gitea': 'G',
|
||||||
'n8n': 'n8n',
|
'n8n': 'n8n',
|
||||||
'supabase': 'SB',
|
'supabase': 'SB',
|
||||||
@@ -196,13 +237,13 @@
|
|||||||
<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 %}">
|
<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>
|
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-300">{{ plugin_type|plugin_name }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ plugin_type|plugin_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-white">{{ count }}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ count }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-center text-gray-400 text-sm">
|
<p class="text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -210,83 +251,189 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Health Status -->
|
<!-- Health Status -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.health_status }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.health_status }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
{% for component, status in health_summary.components.items() %}
|
{% for component, status in health_summary.components.items() %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-300 capitalize">{{ component }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ component }}</span>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% if status == 'healthy' %}
|
{% 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="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>
|
<span class="text-sm text-green-600 dark:text-green-400">{{ t.healthy }}</span>
|
||||||
{% elif status == 'warning' %}
|
{% 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="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>
|
<span class="text-sm text-yellow-600 dark:text-yellow-400">{{ t.warning }}</span>
|
||||||
{% else %}
|
{% 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="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>
|
<span class="text-sm text-red-600 dark:text-red-400">{{ t.error }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="pt-4 border-t border-gray-700">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
||||||
{{ health_summary.last_check[:19] }}
|
{{ health_summary.last_check[:19] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 border-t border-gray-700">
|
<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">
|
<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 }} →
|
{{ t.view_all }} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Support / Donate -->
|
</div>
|
||||||
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
|
</div>
|
||||||
<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">
|
{% else %}
|
||||||
<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"/>
|
{# USER DASHBOARD #}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- User Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-sm font-semibold text-white mb-1">
|
</div>
|
||||||
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
|
<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">
|
||||||
</h3>
|
{{ t.view_all }}
|
||||||
<p class="text-xs text-gray-400 mb-4">
|
<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">
|
||||||
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
</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>
|
</svg>
|
||||||
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}اتصالات فعال{% else %}Active connections{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
</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_specialist': '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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
674
core/templates/dashboard/keys/list.html
Normal file
674
core/templates/dashboard/keys/list.html
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
{# ── Admin view: full filters, all project keys ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
|
||||||
|
</p>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<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="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin 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 %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
|
<select name="project" onchange="this.form.submit()"
|
||||||
|
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">
|
||||||
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
|
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
|
||||||
|
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||||
|
<select name="status" onchange="this.form.submit()"
|
||||||
|
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">
|
||||||
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
|
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
|
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<input type="text" name="search" value="{{ search_query }}"
|
||||||
|
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% endif %}"
|
||||||
|
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 focus:ring-2 focus:ring-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_project or selected_status != 'active' %}
|
||||||
|
<a href="/dashboard/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-gray-800 dark:text-white text-sm transition-colors">{{ t['clear'] }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Keys 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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-{{ 'right' if lang == 'fa' else 'left' }} 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 api_keys %}
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<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" title="Copy ID">
|
||||||
|
<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"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.project_id == '*' %}
|
||||||
|
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</span>
|
||||||
|
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for scope in key.scope.split() %}
|
||||||
|
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.revoked %}<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 %}Revoked{% endif %}</span></div>
|
||||||
|
{% else %}<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">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if not key.revoked %}
|
||||||
|
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
|
||||||
|
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
|
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{% endif %}">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="px-6 py-12 text-center">
|
||||||
|
<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-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
|
||||||
|
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<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 %}Showing {{ ((page_number-1)*per_page)+1 }}–{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
|
||||||
|
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% 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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
|
||||||
|
{% 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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ── User view: personal keys with scope selector ── #}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
|
||||||
|
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام میشود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
|
||||||
|
<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="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Keys 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-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</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">Prefix</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">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</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">Uses</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">
|
||||||
|
{% if user_keys %}
|
||||||
|
{% for key in user_keys %}
|
||||||
|
<tr id="ukey-{{ key.id }}">
|
||||||
|
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if key.site_id %}
|
||||||
|
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded font-medium">
|
||||||
|
{% for site in sites %}{% if site.id == key.site_id %}{{ site.alias }}{% endif %}{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-xs rounded font-medium">{% if lang == 'fa' %}همه سرویسها{% else %}All Services{% endif %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="px-6 py-12 text-center">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
|
||||||
|
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
|
||||||
|
</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not is_admin %}
|
||||||
|
<!-- Configuration Snippet -->
|
||||||
|
<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 %}Configuration Snippet{% endif %}
|
||||||
|
</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">{% if lang == 'fa' %}سایت{% else %}Site{% endif %}</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 }}" data-site-id="{{ site.id }}" data-plugin="{{ site.plugin_type }}">{{ 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">{% if lang == 'fa' %}کلاینت{% else %}Client{% endif %}</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]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</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">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transport type info -->
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) میباشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> میشود.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WordPress SEO plugin note -->
|
||||||
|
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>وردپرس:</strong> برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
|
||||||
|
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||||
|
را دانلود و روی سایت وردپرسی خود نصب کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
|
||||||
|
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
|
||||||
|
companion plugin on your WordPress site.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection tip for web-based clients -->
|
||||||
|
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key hint for desktop/CLI clients -->
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one above and use it in your config:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Modals ── #}
|
||||||
|
|
||||||
|
<!-- Create Key Modal -->
|
||||||
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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>
|
||||||
|
{% if is_admin %}
|
||||||
|
{# Admin create form — posts to existing admin endpoint #}
|
||||||
|
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
|
||||||
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
<div>
|
||||||
|
<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-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">
|
||||||
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
|
{% for project in available_projects %}
|
||||||
|
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
|
||||||
|
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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., CI/CD key{% endif %}"
|
||||||
|
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">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
|
||||||
|
<select name="expires_in_days" 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">
|
||||||
|
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
|
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
|
||||||
|
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# User create form — uses /api/keys via JS #}
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
|
||||||
|
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude Desktop{% endif %}"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}محدوده سرویس{% else %}Service Scope{% endif %}</label>
|
||||||
|
<select id="new-key-site"
|
||||||
|
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">
|
||||||
|
<option value="">{% if lang == 'fa' %}همه سرویسها (عمومی){% else %}All Services (global){% endif %}</option>
|
||||||
|
{% if sites %}
|
||||||
|
{% for site in sites %}
|
||||||
|
<option value="{{ site.id }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
{% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخابشده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||||
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{% if lang == 'fa' %}فیلتر ابزار هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">تنظیمات سایت</a> انجام میشود.{% else %}Tool access is controlled per-site in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">Site Settings</a>.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Key Display Modal -->
|
||||||
|
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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 gap-2">
|
||||||
|
<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>
|
||||||
|
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده میشود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" id="newKeyValue" readonly 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()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<!-- Revoke Modal -->
|
||||||
|
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Modal -->
|
||||||
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
|
||||||
|
<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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
|
||||||
|
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const isAdmin = {{ 'true' if is_admin else 'false' }};
|
||||||
|
const lang = '{{ lang }}';
|
||||||
|
let currentRevokeKeyId = null;
|
||||||
|
let currentDeleteKeyId = null;
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
|
||||||
|
}
|
||||||
|
window.copyToClipboard = copyToClipboard;
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.remove('hidden');
|
||||||
|
document.getElementById('createModal').classList.add('flex');
|
||||||
|
// F.7d: pre-select site from ?site= query param when present
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const siteParam = params.get('site');
|
||||||
|
if (siteParam) {
|
||||||
|
const siteSelect = document.getElementById('new-key-site');
|
||||||
|
if (siteSelect) {
|
||||||
|
const match = Array.from(siteSelect.options).find(o => o.value === siteParam);
|
||||||
|
if (match) siteSelect.value = siteParam;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.openCreateModal = openCreateModal;
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('createModal').classList.add('hidden');
|
||||||
|
document.getElementById('createModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeCreateModal = closeCreateModal;
|
||||||
|
|
||||||
|
function closeNewKeyModal() {
|
||||||
|
document.getElementById('newKeyModal').classList.add('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
window.closeNewKeyModal = closeNewKeyModal;
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
copyToClipboard(document.getElementById('newKeyValue').value);
|
||||||
|
}
|
||||||
|
window.copyNewKey = copyNewKey;
|
||||||
|
|
||||||
|
function showNewKey(key) {
|
||||||
|
document.getElementById('newKeyValue').value = key;
|
||||||
|
document.getElementById('newKeyModal').classList.remove('hidden');
|
||||||
|
document.getElementById('newKeyModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin: collect scopes + submit form via JS
|
||||||
|
const form = document.getElementById('createKeyForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const scopes = [];
|
||||||
|
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
|
||||||
|
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
|
||||||
|
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
|
||||||
|
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
|
||||||
|
const data = {
|
||||||
|
project_id: formData.get('project_id'),
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
description: formData.get('description') || null,
|
||||||
|
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
|
||||||
|
};
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/create', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.error) { alert(res.error); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key);
|
||||||
|
} else {
|
||||||
|
// User: POST to /api/keys
|
||||||
|
const name = document.getElementById('new-key-name').value.trim();
|
||||||
|
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
|
||||||
|
const siteId = document.getElementById('new-key-site')?.value || null;
|
||||||
|
const payload = { name, scopes: 'read write admin' };
|
||||||
|
if (siteId) payload.site_id = siteId;
|
||||||
|
const r = await fetch('/api/keys', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (!r.ok) { alert(res.error || 'Failed'); return; }
|
||||||
|
closeCreateModal();
|
||||||
|
showNewKey(res.key.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.submitCreate = submitCreate;
|
||||||
|
|
||||||
|
async function deleteUserKey(keyId) {
|
||||||
|
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
|
||||||
|
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||||
|
if (r.ok) {
|
||||||
|
const row = document.getElementById('ukey-' + keyId);
|
||||||
|
if (row) row.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.deleteUserKey = deleteUserKey;
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
function openRevokeModal(keyId, keyName) {
|
||||||
|
currentRevokeKeyId = keyId;
|
||||||
|
document.getElementById('revokeKeyName').textContent = keyName;
|
||||||
|
document.getElementById('revokeModal').classList.remove('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openRevokeModal = openRevokeModal;
|
||||||
|
|
||||||
|
function closeRevokeModal() {
|
||||||
|
document.getElementById('revokeModal').classList.add('hidden');
|
||||||
|
document.getElementById('revokeModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeRevokeModal = closeRevokeModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmRevokeBtn').onclick = async function() {
|
||||||
|
if (!currentRevokeKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function openDeleteModal(keyId, keyName) {
|
||||||
|
currentDeleteKeyId = keyId;
|
||||||
|
document.getElementById('deleteKeyName').textContent = keyName;
|
||||||
|
document.getElementById('deleteModal').classList.remove('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
window.openDeleteModal = openDeleteModal;
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').classList.add('hidden');
|
||||||
|
document.getElementById('deleteModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
|
|
||||||
|
document.getElementById('confirmDeleteBtn').onclick = async function() {
|
||||||
|
if (!currentDeleteKeyId) return;
|
||||||
|
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
if (r.ok) location.reload();
|
||||||
|
else { const d = await r.json(); alert(d.error || 'Failed'); }
|
||||||
|
};
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// ── Config Snippets (user view) ────────────────────────────────
|
||||||
|
{% if not is_admin %}
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
const WP_TYPES = ['wordpress', 'woocommerce'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const siteSelect = document.getElementById('config-site');
|
||||||
|
const alias = siteSelect?.value;
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!alias || !client) return;
|
||||||
|
|
||||||
|
const isWebClient = WEB_CLIENTS.includes(client);
|
||||||
|
|
||||||
|
const transportNote = document.getElementById('transport-note');
|
||||||
|
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
const connTip = document.getElementById('connection-tip');
|
||||||
|
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
|
||||||
|
|
||||||
|
const bearerHint = document.getElementById('bearer-hint');
|
||||||
|
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
|
||||||
|
|
||||||
|
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
|
||||||
|
const pluginType = selectedOption?.dataset?.plugin || '';
|
||||||
|
const wpNote = document.getElementById('wp-seo-note');
|
||||||
|
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.updateConfig = updateConfig;
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000);
|
||||||
|
}
|
||||||
|
window.copyConfig = copyConfig;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// F.7d: support ?site=<id-or-alias> from /dashboard/sites/{id} link
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const siteParam = params.get('site');
|
||||||
|
const siteSelect = document.getElementById('config-site');
|
||||||
|
if (siteParam && siteSelect) {
|
||||||
|
// Match either the option value (alias) or its data-site-id attribute
|
||||||
|
const match = Array.from(siteSelect.options).find(
|
||||||
|
o => o.value === siteParam || o.dataset?.siteId === siteParam
|
||||||
|
);
|
||||||
|
if (match) siteSelect.value = match.value;
|
||||||
|
}
|
||||||
|
if (siteSelect) updateConfig();
|
||||||
|
// F.7d: auto-open create modal when ?create=1 (e.g. from Connect section)
|
||||||
|
if (params.get('create') === '1') openCreateModal();
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,46 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.login_title }} - MCP Hub</title>
|
<title>{{ t.login_title }} - MCP Hub</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-gradient {
|
.login-gradient {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-shadow {
|
.card-shadow {
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-focus:focus {
|
.input-focus:focus {
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px); }
|
|
||||||
50% { transform: translateY(-20px); }
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||||
<!-- Background decoration -->
|
<!-- Background decoration -->
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
<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 -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>
|
</div>
|
||||||
|
|
||||||
<!-- Login Card -->
|
<!-- Login Card -->
|
||||||
@@ -49,9 +64,8 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<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">
|
<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">
|
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
<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>
|
||||||
@@ -59,10 +73,13 @@
|
|||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{% if error %}
|
{% 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">
|
<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">
|
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<span class="text-red-400 text-sm">
|
<span class="text-red-400 text-sm">
|
||||||
{% if error == 'rate_limit' %}
|
{% if error == 'rate_limit' %}
|
||||||
@@ -76,49 +93,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form method="POST" action="/dashboard/login" class="space-y-6">
|
<form method="POST" action="/dashboard-legacy/login" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
|
||||||
<input type="hidden" name="next" value="{{ next_url }}">
|
<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">
|
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
{{ t.api_key_label }}
|
{{ t.api_key_label }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input type="password" id="api_key" name="api_key" required autocomplete="off"
|
||||||
type="password"
|
|
||||||
id="api_key"
|
|
||||||
name="api_key"
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="{{ t.api_key_placeholder }}"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit"
|
||||||
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">
|
||||||
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 }}
|
{{ t.login_button }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Security Note -->
|
<!-- 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">
|
<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">
|
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<div class="text-sm text-blue-300">
|
<div class="text-sm text-blue-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
|
<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>
|
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code
|
||||||
|
class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the dashboard.</p>
|
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the
|
||||||
<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>
|
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 %}
|
{% endif %}
|
||||||
<p class="mt-2">
|
<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">
|
<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 %}
|
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -128,19 +146,18 @@
|
|||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<a
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
||||||
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">
|
||||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
{% if lang == 'fa' %}English{% else %}<span lang="fa" dir="rtl">فارسی</span>{% endif %}
|
||||||
>
|
|
||||||
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<p class="text-center text-gray-500 text-sm mt-6">
|
<p class="text-center text-gray-500 text-sm mt-6">
|
||||||
MCP Hub v{{ version|default('3.0.1') }}
|
MCP Hub v{{ version }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</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-white">{{ total_count }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<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>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</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-white">OAuth 2.1</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">OAuth 2.1</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -71,37 +71,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients Table -->
|
<!-- 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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<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 %}
|
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if clients %}
|
{% if clients %}
|
||||||
{% for client in 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">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<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>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -143,15 +143,15 @@
|
|||||||
<td colspan="5" class="px-6 py-4">
|
<td colspan="5" class="px-6 py-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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">
|
<div class="space-y-1">
|
||||||
{% for uri in client.redirect_uris %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="flex flex-wrap gap-1">
|
||||||
{% for grant in client.grant_types %}
|
{% 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>
|
<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">
|
<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"/>
|
<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>
|
</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 %}
|
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -188,63 +188,53 @@
|
|||||||
|
|
||||||
<!-- Create Client Modal -->
|
<!-- Create Client Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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">
|
<h3 class="text-xl font-semibold text-white mb-4">
|
||||||
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form id="createForm" class="space-y-4">
|
<form id="createForm" class="space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="client_name"
|
name="client_name"
|
||||||
required
|
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 %}"
|
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<textarea
|
||||||
name="redirect_uris"
|
name="redirect_uris"
|
||||||
required
|
required
|
||||||
rows="3"
|
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"
|
>https://claude.ai/api/mcp/auth_callback
|
||||||
></textarea>
|
https://claude.com/api/mcp/auth_callback</textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
|
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex flex-wrap gap-2">
|
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
|
||||||
<label class="flex items-center gap-2">
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
</span>
|
||||||
<span class="text-sm text-gray-300">read</span>
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل میشود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
{% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="closeCreateModal()"
|
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 }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +251,7 @@
|
|||||||
|
|
||||||
<!-- Success Modal (shows client secret) -->
|
<!-- Success Modal (shows client secret) -->
|
||||||
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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="text-center">
|
||||||
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<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">
|
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -271,16 +261,16 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
||||||
</h3>
|
</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 %}
|
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<div>
|
<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">
|
<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">
|
<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">
|
<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"/>
|
<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 +279,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
||||||
@@ -312,7 +302,7 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<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="text-center">
|
||||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<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">
|
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -322,7 +312,7 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
||||||
</h3>
|
</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 %}
|
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
||||||
@@ -331,7 +321,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeDeleteModal()"
|
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 }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -426,10 +416,8 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
var formData = new FormData(this);
|
var formData = new FormData(this);
|
||||||
var scopes = [];
|
// F.7c: All clients get full scope — tool access controlled per-site
|
||||||
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
|
var scopes = ['read', 'write', 'admin'];
|
||||||
scopes.push(cb.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse redirect URIs (one per line)
|
// Parse redirect URIs (one per line)
|
||||||
var redirectUrisText = formData.get('redirect_uris') || '';
|
var redirectUrisText = formData.get('redirect_uris') || '';
|
||||||
|
|||||||
77
core/templates/dashboard/partials/head_assets.html
Normal file
77
core/templates/dashboard/partials/head_assets.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!-- 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 (pre-built) -->
|
||||||
|
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||||
|
|
||||||
|
<!-- 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.14.8/dist/cdn.min.js"></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>
|
||||||
|
|
||||||
|
<!-- 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 title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
<div class="flex items-center">
|
<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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- Status Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div class="flex items-center mt-2">
|
||||||
{% if project.health.status == 'healthy' %}
|
{% 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</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-white mt-1">{{ project.tools_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</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-white mt-1">{{ project.api_keys_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.api_keys_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -91,11 +91,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Requests Card -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</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-white mt-1">{{ project.requests_24h }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.requests_24h }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<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">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,33 +112,33 @@
|
|||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Config Section -->
|
<!-- Config Section -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.full_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.full_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
||||||
<dd class="text-white">{{ project.plugin_type|plugin_name }}</dd>
|
<dd class="text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.site_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.site_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if project.alias %}
|
{% if project.alias %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
<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>
|
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.url %}
|
{% if project.url %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">URL</dt>
|
<dt class="text-gray-500 dark:text-gray-400">URL</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
||||||
{{ project.url }}
|
{{ project.url }}
|
||||||
@@ -150,22 +150,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex justify-between py-2">
|
<div class="flex justify-between py-2">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
<dt class="text-gray-500 dark: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>
|
<dd class="text-gray-900 dark:text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Available Tools -->
|
<!-- Available Tools -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700 flex items-center justify-between">
|
<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-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
<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-700 rounded-lg text-sm text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
<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>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
<table class="w-full">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
<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 %}
|
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||||
@@ -178,14 +178,14 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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] %}
|
{% 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">
|
<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>
|
||||||
<td class="px-4 py-2">
|
<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 %}
|
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -213,9 +213,9 @@
|
|||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Health Check Result Display -->
|
<!-- Health Check Result Display -->
|
||||||
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::before-request="showHealthCheckLoading()"
|
hx-on::before-request="showHealthCheckLoading()"
|
||||||
hx-on::after-request="handleHealthCheckResponse(event)"
|
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>
|
<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">
|
<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
|
<a
|
||||||
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
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>
|
<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">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
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>
|
<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">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -262,14 +262,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<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-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if project.recent_activity %}
|
{% if project.recent_activity %}
|
||||||
{% for activity in project.recent_activity[:5] %}
|
{% 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 justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% if activity.level == 'ERROR' %}
|
{% if activity.level == 'ERROR' %}
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-sm text-white">{{ activity.message }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ activity.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,19 +343,19 @@
|
|||||||
resultDiv.classList.add('bg-green-500/20');
|
resultDiv.classList.add('bg-green-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
(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: ') +
|
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
||||||
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
||||||
} else if (response.status === 'unhealthy') {
|
} else if (response.status === 'unhealthy') {
|
||||||
resultDiv.classList.add('bg-red-500/20');
|
resultDiv.classList.add('bg-red-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
(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 {
|
} else {
|
||||||
resultDiv.classList.add('bg-yellow-500/20');
|
resultDiv.classList.add('bg-yellow-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
||||||
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
(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
|
// Auto-hide after 5 seconds
|
||||||
|
|||||||
@@ -6,17 +6,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Filters -->
|
<!-- 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">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
<!-- Plugin Type Filter -->
|
<!-- Plugin Type Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<select name="plugin_type" onchange="this.form.submit()"
|
||||||
name="plugin_type"
|
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">
|
||||||
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>
|
<option value="">{{ t.all }} Types</option>
|
||||||
{% for plugin_type in available_plugin_types %}
|
{% for plugin_type in available_plugin_types %}
|
||||||
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
|
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
|
||||||
@@ -28,36 +25,34 @@
|
|||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select
|
<select name="status" onchange="this.form.submit()"
|
||||||
name="status"
|
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">
|
||||||
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="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
||||||
<option value="healthy" {% if selected_status == 'healthy' %}selected{% endif %}>{{ t.healthy }}</option>
|
<option value="healthy" {% if selected_status=='healthy' %}selected{% endif %}>{{ t.healthy }}
|
||||||
<option value="warning" {% if selected_status == 'warning' %}selected{% endif %}>{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</option>
|
</option>
|
||||||
<option value="unhealthy" {% if selected_status == 'unhealthy' %}selected{% endif %}>{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
<option value="warning" {% if selected_status=='warning' %}selected{% endif %}>{% if lang == 'fa'
|
||||||
<option value="unknown" {% if selected_status == 'unknown' %}selected{% endif %}>{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</option>
|
%}هشدار{% 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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<input
|
<input type="text" name="search" value="{{ search_query }}" placeholder="{{ t.search }}..."
|
||||||
type="text"
|
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">
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
<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 }}
|
{{ t.search }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_plugin_type or selected_status %}
|
{% 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">
|
<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-gray-800 dark:text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -65,42 +60,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Projects Table -->
|
<!-- Projects 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">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<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 %}Plugin{% endif %}
|
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
||||||
</th>
|
</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 %}Site ID{% endif %}
|
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
||||||
</th>
|
</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 %}Alias{% endif %}
|
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
||||||
</th>
|
</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">
|
||||||
URL
|
URL
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</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 %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if projects %}
|
{% if projects %}
|
||||||
{% for project in projects %}
|
{% for project in projects %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<!-- Plugin Type -->
|
<!-- Plugin Type -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% set plugin_colors = {
|
{% set plugin_colors = {
|
||||||
'wordpress': 'bg-blue-500',
|
'wordpress': 'bg-blue-500',
|
||||||
'woocommerce': 'bg-purple-500',
|
'woocommerce': 'bg-purple-500',
|
||||||
'wordpress_advanced': 'bg-indigo-500',
|
'wordpress_specialist': 'bg-indigo-500',
|
||||||
'gitea': 'bg-green-500',
|
'gitea': 'bg-green-500',
|
||||||
'n8n': 'bg-orange-500',
|
'n8n': 'bg-orange-500',
|
||||||
'supabase': 'bg-emerald-500',
|
'supabase': 'bg-emerald-500',
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
{% set plugin_icons = {
|
{% set plugin_icons = {
|
||||||
'wordpress': 'W',
|
'wordpress': 'W',
|
||||||
'woocommerce': 'WC',
|
'woocommerce': 'WC',
|
||||||
'wordpress_advanced': 'WA',
|
'wordpress_specialist': 'WS',
|
||||||
'gitea': 'G',
|
'gitea': 'G',
|
||||||
'n8n': 'n8n',
|
'n8n': 'n8n',
|
||||||
'supabase': 'SB',
|
'supabase': 'SB',
|
||||||
@@ -119,16 +120,18 @@
|
|||||||
'appwrite': 'AW',
|
'appwrite': 'AW',
|
||||||
'directus': 'DI',
|
'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 %}">
|
<div
|
||||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type, project.plugin_type[:2]|upper) }}</span>
|
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>
|
</div>
|
||||||
<span class="text-sm text-white">{{ project.plugin_type|plugin_name }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Site ID -->
|
<!-- Site ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ project.site_id }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ project.site_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Alias -->
|
<!-- Alias -->
|
||||||
@@ -145,7 +148,9 @@
|
|||||||
<!-- URL -->
|
<!-- URL -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.url %}
|
{% 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 }}">
|
<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 %}
|
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -157,36 +162,52 @@
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.health_status == 'healthy' %}
|
{% if project.health_status == 'healthy' %}
|
||||||
<div class="flex items-center">
|
<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="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>
|
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif project.health_status == 'warning' %}
|
{% elif project.health_status == 'warning' %}
|
||||||
<div class="flex items-center">
|
<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
|
||||||
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</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>
|
</div>
|
||||||
{% elif project.health_status == 'unhealthy' %}
|
{% elif project.health_status == 'unhealthy' %}
|
||||||
<div class="flex items-center">
|
<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 %}</span>
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex items-center">
|
<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
|
||||||
<span class="text-sm text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<a
|
<a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
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-gray-800 dark:text-white transition-colors">
|
||||||
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 }}
|
{{ 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">
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -195,16 +216,26 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="px-6 py-12 text-center">
|
<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">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% if search_query or selected_plugin_type %}
|
{% if search_query or selected_plugin_type %}
|
||||||
<p class="text-gray-500 text-sm mt-2">
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
سایتها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایتها</a> مدیریت میشوند.
|
||||||
|
{% else %}
|
||||||
|
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -216,18 +247,20 @@
|
|||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} پروژه
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{
|
||||||
|
total_count }} پروژه
|
||||||
{% else %}
|
{% else %}
|
||||||
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of {{ total_count }} projects
|
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of
|
||||||
|
{{ total_count }} projects
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% 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 %}"
|
<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">
|
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-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -235,9 +268,10 @@
|
|||||||
{% for page_num in range(1, total_pages + 1) %}
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
{% if page_num == page_number %}
|
{% 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-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) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <=
|
||||||
<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 %}"
|
page_number + 2) %} <a
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
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-gray-800 dark:text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -245,9 +279,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %} <a
|
||||||
<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 %}"
|
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">
|
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-800 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
281
core/templates/dashboard/service.html
Normal file
281
core/templates/dashboard/service.html
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ service.display_name }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ service.display_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Back button + Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/dashboard/services{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-400 hover:text-gray-300 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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ service.display_name }}</h2>
|
||||||
|
{% if service.is_public %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/sites/add?plugin_type={{ service.plugin_type }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.get('add_site', 'Add Site') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description & Notes -->
|
||||||
|
{% if service.description %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
{% if lang == 'fa' and service.description.fa %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed" dir="rtl">{{ service.description.fa }}</p>
|
||||||
|
{% elif service.description.en %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ service.description.en }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set notes_key = 'notes_fa' if lang == 'fa' else 'notes_en' %}
|
||||||
|
{% if service.description[notes_key] %}
|
||||||
|
<ul class="mt-4 space-y-2{% if lang == 'fa' %} text-right{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
|
{% for note in service.description[notes_key] %}
|
||||||
|
<li class="flex items-start gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-4 h-4 text-primary-400 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>
|
||||||
|
<span>{{ note|safe }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Overview Cards -->
|
||||||
|
{% set admin_tools = service.tools|selectattr('scope', 'equalto', 'admin')|list %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-{{ 4 if admin_tools|length > 0 else 3 }} gap-6">
|
||||||
|
<!-- Tools Count -->
|
||||||
|
<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 justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ service.tools_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="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"/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Read Tools -->
|
||||||
|
{% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %}
|
||||||
|
<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 justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ read_tools|length }}</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 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Write Tools -->
|
||||||
|
{% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %}
|
||||||
|
<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 justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ write_tools|length }}</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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Tools (F.7d) -->
|
||||||
|
{% if admin_tools|length > 0 %}
|
||||||
|
<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 justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزار مدیر{% else %}Admin Tools{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ admin_tools|length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-red-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-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- F.20 prep: Companion plugin download hint (WP / WC only) -->
|
||||||
|
{% if companion_download_url %}
|
||||||
|
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-10 h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
افزونه همراه Airano MCP Bridge (اختیاری اما توصیهشده)
|
||||||
|
{% else %}
|
||||||
|
Airano MCP Bridge — companion plugin (optional but recommended)
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
<p class="mt-1 text-xs text-indigo-800/90 dark:text-indigo-300/90 leading-relaxed">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
نصب افزونه همراه، این قابلیتها را فعال میکند: آپلود فایل بزرگتر از
|
||||||
|
<code class="font-mono">upload_max_filesize</code>، گزارش یکپارچهی سلامت سایت،
|
||||||
|
purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیتها،
|
||||||
|
و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار میکنند اما موارد فوق غیرفعال خواهند بود.
|
||||||
|
{% else %}
|
||||||
|
Installing the companion plugin unlocks: uploads larger than
|
||||||
|
<code class="font-mono">upload_max_filesize</code>, unified site-health snapshot,
|
||||||
|
cache purge, transient flush, bulk meta writes, structured export, capability probe,
|
||||||
|
and audit-hook webhooks. Without it, basic tools still work but those features stay disabled.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<a href="{{ companion_download_url }}"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium transition-colors">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-indigo-700/70 dark:text-indigo-300/70">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
نصب: WP Admin → افزونهها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعالسازی
|
||||||
|
{% else %}
|
||||||
|
Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Setup Requirements -->
|
||||||
|
{% if service.credential_fields %}
|
||||||
|
<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">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in service.credential_fields %}
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div class="w-8 h-8 bg-purple-500/20 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
{% if field.type == 'password' %}
|
||||||
|
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4 text-purple-400" 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ field.label }}</p>
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.required %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{% if lang == 'fa' %}الزامی{% else %}Required{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Tools List -->
|
||||||
|
<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">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %}
|
||||||
|
<span class="text-sm font-normal text-gray-400 dark:text-gray-500">({{ service.tools_count }})</span>
|
||||||
|
</h3>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search tools...{% endif %}"
|
||||||
|
class="w-48 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700" id="tools-list">
|
||||||
|
{% for tool in service.tools %}
|
||||||
|
<div class="tool-item p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" data-name="{{ tool.name }}" data-desc="{{ tool.description|lower }}">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="text-sm font-mono text-primary-600 dark:text-primary-400">{{ tool.name }}</code>
|
||||||
|
{% if tool.scope == 'read' %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">read</span>
|
||||||
|
{% elif tool.scope == 'write' %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300">write</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">admin</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{{ tool.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('tool-search').addEventListener('input', function(e) {
|
||||||
|
const q = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.tool-item').forEach(function(item) {
|
||||||
|
const name = item.dataset.name || '';
|
||||||
|
const desc = item.dataset.desc || '';
|
||||||
|
item.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
61
core/templates/dashboard/services_list.html
Normal file
61
core/templates/dashboard/services_list.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.get('services', 'Services') }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.get('services', 'Services') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}سرویسهای MCP{% else %}MCP Services{% endif %}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'blue',
|
||||||
|
'woocommerce': 'purple',
|
||||||
|
'supabase': 'emerald',
|
||||||
|
'gitea': 'green',
|
||||||
|
'n8n': 'orange',
|
||||||
|
'openpanel': 'cyan',
|
||||||
|
'appwrite': 'pink',
|
||||||
|
'directus': 'violet',
|
||||||
|
'wordpress_specialist': 'indigo',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for svc in services %}
|
||||||
|
{% set color = plugin_colors.get(svc.plugin_type, 'gray') %}
|
||||||
|
<a href="/dashboard/services/{{ svc.plugin_type }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all group">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white transition-colors">
|
||||||
|
{{ svc.display_name }}
|
||||||
|
</h3>
|
||||||
|
{% if svc.is_public %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
|
||||||
|
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
|
||||||
|
{% set admin_count = svc.tools|selectattr('scope', 'equalto', 'admin')|list|length %}
|
||||||
|
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
|
||||||
|
{% if admin_count > 0 %}
|
||||||
|
<span class="text-red-600 dark:text-red-400">{{ admin_count }} admin</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,22 +6,22 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Language Settings -->
|
<!-- Language Settings -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/dashboard/settings?lang=en"
|
<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 %}">
|
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
English
|
English
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/settings?lang=fa"
|
<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 %}">
|
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
فارسی
|
فارسی
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
زبان فعلی: فارسی
|
زبان فعلی: فارسی
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -32,71 +32,128 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Configuration -->
|
<!-- System Configuration -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.server_mode }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.server_mode }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.port }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.port }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.log_level }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.log_level }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Settings -->
|
<!-- Security Settings -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Managed Settings (4C.3) -->
|
||||||
|
{% if managed_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 %}Managed Settings{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
{% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیشفرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for s in managed_settings %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4" id="setting-{{ s.key }}">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}{{ s.label_fa }}{% else %}{{ s.label }}{% endif %}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
{% if s.source == 'database' %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300
|
||||||
|
{% elif s.source == 'environment' %}bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400{% endif %}"
|
||||||
|
id="source-{{ s.key }}">
|
||||||
|
{{ s.source }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" value="{{ s.value }}" id="input-{{ s.key }}"
|
||||||
|
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<button onclick="saveSetting('{{ s.key }}')"
|
||||||
|
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||||
|
id="save-{{ s.key }}">
|
||||||
|
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if s.source == 'database' %}
|
||||||
|
<button onclick="resetSetting('{{ s.key }}')"
|
||||||
|
class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 text-sm rounded-lg transition-colors"
|
||||||
|
id="reset-{{ s.key }}"
|
||||||
|
title="{% if lang == 'fa' %}بازگشت به پیشفرض{% else %}Reset to default{% endif %}">
|
||||||
|
<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="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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1" id="default-{{ s.key }}">
|
||||||
|
{% if lang == 'fa' %}پیشفرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Registered Plugins -->
|
<!-- Registered Plugins -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||||
@@ -106,10 +163,10 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{% if plugins %}
|
{% if plugins %}
|
||||||
{% for plugin in plugins %}
|
{% for plugin in plugins %}
|
||||||
<div class="flex items-center justify-between py-2 border-b border-gray-700 last:border-0">
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-white font-medium">{{ plugin.name }}</p>
|
<p class="text-gray-900 dark:text-white font-medium">{{ plugin.name }}</p>
|
||||||
<p class="text-sm text-gray-400">{{ plugin.description }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ plugin.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
||||||
{{ t.active }}
|
{{ t.active }}
|
||||||
@@ -117,8 +174,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-gray-700/30 rounded-lg p-4">
|
<div class="bg-gray-100 dark:bg-gray-700/30 rounded-lg p-4">
|
||||||
<p class="text-gray-400 mb-2">
|
<p class="text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
@@ -134,8 +191,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -146,30 +203,30 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xl font-bold text-white">MCP Hub</h4>
|
<h4 class="text-xl font-bold text-gray-900 dark:text-white">MCP Hub</h4>
|
||||||
<p class="text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.mcp_version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.mcp_version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.python_version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.python_version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.tools_count }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -181,25 +238,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session Information -->
|
<!-- Session Information -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<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-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ session.user_type }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark: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>
|
<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>
|
</div>
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a href="/dashboard/logout"
|
<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">
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
||||||
{{ t.logout }}
|
{{ t.logout }}
|
||||||
@@ -208,3 +265,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function saveSetting(key) {
|
||||||
|
const input = document.getElementById('input-' + key);
|
||||||
|
const btn = document.getElementById('save-' + key);
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
btn.textContent = '...';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, value, action: 'save' }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
btn.textContent = '✓';
|
||||||
|
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
|
||||||
|
const source = document.getElementById('source-' + key);
|
||||||
|
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to save');
|
||||||
|
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSetting(key) {
|
||||||
|
if (!confirm('Reset this setting to default?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, action: 'reset' }),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.error || 'Failed to reset');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
173
core/templates/dashboard/sites/_capability_badge.html
Normal file
173
core/templates/dashboard/sites/_capability_badge.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{# F.X.fix #9 — capability-badge partial, HTMX-swappable.
|
||||||
|
|
||||||
|
Rendered both (a) inline by manage.html on first page load and (b) as
|
||||||
|
the response body of GET /api/sites/{id}/capabilities/badge when the
|
||||||
|
Re-check button is clicked. Swapping this element with hx-swap=
|
||||||
|
"outerHTML" avoids the full-page reload the legacy button triggered.
|
||||||
|
|
||||||
|
F.X.fix-pass2 — wording corrected for WordPress: application passwords
|
||||||
|
inherit ALL capabilities from the user account, so the "credential
|
||||||
|
tier" framing is misleading. WP-specific branches reference the WP
|
||||||
|
user's role instead. AI-provider availability is surfaced here too
|
||||||
|
so removing a provider key gives visible feedback on Re-check.
|
||||||
|
|
||||||
|
Required context:
|
||||||
|
capability_probe — probe payload (with .fit, .granted, .reason,
|
||||||
|
.ai_providers_configured, optionally .install_hint)
|
||||||
|
site — site dict (for .plugin_type, .tool_scope)
|
||||||
|
lang — "fa" | "en"
|
||||||
|
companion_download_url — string, may be empty
|
||||||
|
#}
|
||||||
|
{% set fit_status = capability_probe.fit.status %}
|
||||||
|
{% set is_wp = site.plugin_type == 'wordpress' %}
|
||||||
|
{% set is_wc = site.plugin_type == 'woocommerce' %}
|
||||||
|
{% set is_wp_like = is_wp or is_wc %}
|
||||||
|
{% set ai_providers = capability_probe.ai_providers_configured or [] %}
|
||||||
|
{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %}
|
||||||
|
<div id="capability-badge"
|
||||||
|
class="border rounded-lg px-4 py-3 text-sm
|
||||||
|
{% if fit_status == 'ok' %}
|
||||||
|
bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif fit_status == 'warning' %}
|
||||||
|
bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300
|
||||||
|
{% elif fit_status == 'unknown_tier' %}
|
||||||
|
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300
|
||||||
|
{% else %}
|
||||||
|
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{% if fit_status == 'ok' %}
|
||||||
|
<div class="font-semibold text-sm">
|
||||||
|
{% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخابشده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if capability_probe.granted %}
|
||||||
|
<div class="mt-1 text-xs opacity-80">
|
||||||
|
{% if lang == 'fa' %}دسترسیهای احراز شده:{% else %}Granted:{% endif %}
|
||||||
|
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif fit_status == 'warning' %}
|
||||||
|
<div class="font-semibold text-sm">
|
||||||
|
{% if is_wc %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست
|
||||||
|
{% else %}
|
||||||
|
⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier
|
||||||
|
{% endif %}
|
||||||
|
{% elif is_wp %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد
|
||||||
|
{% else %}
|
||||||
|
⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
⚠ credential ذخیرهشده برای سطح «{{ site.tool_scope }}» کافی نیست
|
||||||
|
{% else %}
|
||||||
|
⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs opacity-90">
|
||||||
|
{% if lang == 'fa' %}دسترسیهای ناموجود:{% else %}Missing:{% endif %}
|
||||||
|
<code class="font-mono text-xs">{{ capability_probe.fit.missing | join(', ') }}</code>
|
||||||
|
</div>
|
||||||
|
{% if capability_probe.granted %}
|
||||||
|
<div class="mt-1 text-xs opacity-80">
|
||||||
|
{% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
|
||||||
|
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
{% if is_wc %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
کلید ووکامرس (Consumer Key + Secret) دارای فیلد <code class="font-mono">permissions</code> با مقادیر
|
||||||
|
<code class="font-mono">read</code> یا <code class="font-mono">read_write</code> است. گزینهها:
|
||||||
|
(۱) سطح پایینتر انتخاب کنید؛
|
||||||
|
(۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
|
||||||
|
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
|
||||||
|
{% else %}
|
||||||
|
WooCommerce REST API keys carry a <code class="font-mono">permissions</code> field
|
||||||
|
(<code class="font-mono">read</code> / <code class="font-mono">read_write</code>). Options:
|
||||||
|
(1) pick a lower access level;
|
||||||
|
(2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings;
|
||||||
|
(3) continue — tools needing missing caps will return 403 at call time.
|
||||||
|
{% endif %}
|
||||||
|
{% elif is_wp %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
application password در وردپرس همیشه تمام دسترسیهای کاربر صاحب آن را میگیرد و قابل محدودکردن نیست. گزینهها:
|
||||||
|
(۱) سطح دسترسی پایینتری انتخاب کنید؛
|
||||||
|
(۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛
|
||||||
|
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
|
||||||
|
{% else %}
|
||||||
|
WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down.
|
||||||
|
Options: (1) pick a lower access level;
|
||||||
|
(2) create the application password from a higher-role user (e.g. Administrator or Editor);
|
||||||
|
(3) continue — tools needing missing caps will return 403 at call time.
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
گزینهها: (۱) انتخاب سطح پایینتر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
|
||||||
|
{% else %}
|
||||||
|
Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time.
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif fit_status == 'probe_unavailable' %}
|
||||||
|
<div class="font-semibold text-sm">
|
||||||
|
{% if lang == 'fa' %}ℹ امکان بررسی خودکار credential در این سایت وجود ندارد{% else %}ℹ Capability probe is unavailable for this site{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs opacity-80 font-mono">
|
||||||
|
{{ capability_probe.reason or 'probe_unavailable' }}
|
||||||
|
</div>
|
||||||
|
{% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
|
||||||
|
<div class="mt-2 text-xs">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای فعالسازی probe، افزونه
|
||||||
|
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
|
||||||
|
را روی وردپرس نصب کنید.
|
||||||
|
{% else %}
|
||||||
|
Install the
|
||||||
|
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
|
||||||
|
plugin on your WordPress to enable the probe.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif fit_status == 'unknown_tier' %}
|
||||||
|
<div class="font-semibold text-sm">
|
||||||
|
{% if lang == 'fa' %}ℹ probe برای این plugin + سطح پیادهسازی نشده{% else %}ℹ Probe not implemented for this plugin/tier combination{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# F.X.fix-pass2 — AI provider status line. Independent of the
|
||||||
|
tier-fit state: even a perfectly-fit credential cannot run
|
||||||
|
the AI image tool without an OpenRouter / OpenAI / … key. #}
|
||||||
|
{% if ai_provider_missing %}
|
||||||
|
<div class="mt-3 pt-2 border-t border-current/20 text-xs">
|
||||||
|
<span class="font-semibold">{% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}</span>
|
||||||
|
<span class="opacity-80">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
— ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
|
||||||
|
{% else %}
|
||||||
|
— <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled. Add a key in the "AI Image Generation" section above.
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% elif is_wp_like and ai_providers %}
|
||||||
|
<div class="mt-3 pt-2 border-t border-current/20 text-xs opacity-80">
|
||||||
|
{% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
|
||||||
|
<code class="font-mono text-xs">{{ ai_providers | join(', ') }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"
|
||||||
|
hx-target="#capability-badge"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
class="flex-shrink-0 px-2.5 py-1 rounded border border-current/30 text-xs font-medium hover:bg-white/20 dark:hover:bg-black/20 transition-colors disabled:opacity-50">
|
||||||
|
{% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
276
core/templates/dashboard/sites/add.html
Normal file
276
core/templates/dashboard/sites/add.html
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
{% 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-3xl mx-auto space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
{# When the user arrives from /dashboard-v2, link the back arrow
|
||||||
|
there so they don't dump out into the legacy list. #}
|
||||||
|
{% set _qs = request.query_params if request else None %}
|
||||||
|
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
|
||||||
|
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% 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>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Section 1: Add Site / Connect Service -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<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="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg id="addsite-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||||
|
<form id="add-site-form" class="space-y-4">
|
||||||
|
<!-- 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 }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ 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">
|
||||||
|
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
|
||||||
|
</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-3 pt-2">
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
|
||||||
|
{{ 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 text-sm">
|
||||||
|
{{ t.cancel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Section 2: What happens next (info card) -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button" onclick="toggleSection('info')" class="w-full flex items-center justify-between p-6 cursor-pointer">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<svg id="info-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
|
||||||
|
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
|
||||||
|
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
|
||||||
|
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const pluginFields = {{ plugin_fields_json| safe }};
|
||||||
|
|
||||||
|
const urlHints = {
|
||||||
|
'openpanel': 'OpenPanel API URL — Cloud: https://api.openpanel.dev | Self-hosted: your API service URL (e.g., https://analytics.example.com)',
|
||||||
|
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Section toggle ──────────────────────────────────────────────
|
||||||
|
function toggleSection(name) {
|
||||||
|
const section = document.getElementById(name + '-section');
|
||||||
|
const chevron = document.getElementById(name + '-chevron');
|
||||||
|
section.classList.toggle('hidden');
|
||||||
|
chevron.classList.toggle('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFields() {
|
||||||
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
|
const container = document.getElementById('credential-fields');
|
||||||
|
const urlHintEl = document.getElementById('url-hint');
|
||||||
|
|
||||||
|
// Update URL hint
|
||||||
|
if (urlHints[ptype]) {
|
||||||
|
urlHintEl.textContent = urlHints[ptype];
|
||||||
|
urlHintEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
urlHintEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
|
||||||
|
const advFields = pluginFields[ptype].filter(f => f.advanced);
|
||||||
|
|
||||||
|
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||||
|
|
||||||
|
function renderField(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>` : '';
|
||||||
|
return `
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFields.forEach(field => { html += renderField(field); });
|
||||||
|
|
||||||
|
if (advFields.length > 0) {
|
||||||
|
html += `
|
||||||
|
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||||
|
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||||
|
Advanced Settings
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4 pt-2">`;
|
||||||
|
advFields.forEach(field => { html += renderField(field); });
|
||||||
|
html += `</div></details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill credential fields if plugin_type is pre-selected
|
||||||
|
if (document.getElementById('plugin_type').value) {
|
||||||
|
updateFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// If the user came in from the v2 SPA shell, send them
|
||||||
|
// back there instead of the legacy Jinja list.
|
||||||
|
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
|
||||||
|
const siteId = data.site?.id || data.site_id;
|
||||||
|
if (fromV2) {
|
||||||
|
window.location.href = '/dashboard-v2/sites?msg={{ t.site_added }}';
|
||||||
|
} else if (siteId) {
|
||||||
|
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
|
||||||
|
} else {
|
||||||
|
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 %}
|
||||||
372
core/templates/dashboard/sites/edit.html
Normal file
372
core/templates/dashboard/sites/edit.html
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.edit_site }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
{% set _qs = request.query_params if request else None %}
|
||||||
|
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% 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.edit_site }}: {{ site.alias }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info notice about password fields -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{{ t.keep_existing }}
|
||||||
|
</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="edit-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 (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</div>
|
||||||
|
</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 value="{{ site.url }}"
|
||||||
|
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 (read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||||
|
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ site.alias }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credential Fields -->
|
||||||
|
<div id="credential-fields" class="space-y-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||||
|
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if not field.get('advanced', False) %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{{ field.label }}{% if field.required %} *{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||||
|
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
|
||||||
|
data-required="{{ field.required | lower }}"
|
||||||
|
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">
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
|
||||||
|
{% if has_advanced %}
|
||||||
|
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
|
||||||
|
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
|
||||||
|
Advanced Settings
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 pb-4 pt-2 space-y-4">
|
||||||
|
{% for field in fields %}
|
||||||
|
{% if field.get('advanced', False) %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||||
|
placeholder="{{ t.leave_blank_to_clear }}"
|
||||||
|
data-required="false"
|
||||||
|
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">
|
||||||
|
{% if field.hint %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</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.save }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Tool Access Card -->
|
||||||
|
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="tool-access-content" class="hidden space-y-4">
|
||||||
|
<!-- Scope selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
|
||||||
|
</label>
|
||||||
|
<select id="tool-scope-select"
|
||||||
|
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">
|
||||||
|
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
|
||||||
|
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
|
||||||
|
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
|
||||||
|
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
|
||||||
|
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
|
||||||
|
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
|
||||||
|
</select>
|
||||||
|
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message for scope save -->
|
||||||
|
<div id="scope-status" class="hidden text-xs"></div>
|
||||||
|
|
||||||
|
<!-- Advanced: per-tool overrides -->
|
||||||
|
<details id="tool-overrides-section">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
|
||||||
|
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
|
||||||
|
</summary>
|
||||||
|
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteId = "{{ site.id }}";
|
||||||
|
const pluginType = "{{ site.plugin_type }}";
|
||||||
|
const pluginFields = {{ plugin_fields_json | safe }};
|
||||||
|
|
||||||
|
document.getElementById('edit-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.updating_site }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
|
||||||
|
// Collect credentials — non-empty values always sent,
|
||||||
|
// optional fields sent as empty string to allow clearing
|
||||||
|
const creds = {};
|
||||||
|
if (pluginFields[pluginType]) {
|
||||||
|
pluginFields[pluginType].forEach(field => {
|
||||||
|
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val) {
|
||||||
|
creds[field.name] = val;
|
||||||
|
} else if (input.dataset.required === 'false') {
|
||||||
|
// Send empty string for optional fields so server can clear them
|
||||||
|
creds[field.name] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url, credentials: creds }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
|
||||||
|
if (fromV2) {
|
||||||
|
window.location.href = '/dashboard-v2/sites?msg={{ t.site_updated }}';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Failed to update site';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Network error: ' + err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.save }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tool Access ──────────────────────────────────────────────
|
||||||
|
const SCOPE_DESCS = {
|
||||||
|
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون دادههای حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
|
||||||
|
'read:sensitive': '{% if lang == "fa" %}read + لاگها، بکاپها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
|
||||||
|
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
|
||||||
|
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
|
||||||
|
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
|
||||||
|
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال میشوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
|
||||||
|
};
|
||||||
|
const CAT_LABELS = {
|
||||||
|
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
|
||||||
|
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
|
||||||
|
'lifecycle': 'Lifecycle',
|
||||||
|
'crud': 'CRUD',
|
||||||
|
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
|
||||||
|
'backup': 'Backup',
|
||||||
|
'system': 'System',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadToolAccess() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||||
|
if (!r.ok) { hideToolAccess(); return; }
|
||||||
|
const data = await r.json();
|
||||||
|
renderToolAccess(data);
|
||||||
|
} catch (_) { hideToolAccess(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideToolAccess() {
|
||||||
|
document.getElementById('tool-access-loading').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolAccess(data) {
|
||||||
|
const loading = document.getElementById('tool-access-loading');
|
||||||
|
const content = document.getElementById('tool-access-content');
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Set scope dropdown
|
||||||
|
const select = document.getElementById('tool-scope-select');
|
||||||
|
select.value = data.tool_scope || 'admin';
|
||||||
|
updateScopeDesc(data.tool_scope || 'admin');
|
||||||
|
|
||||||
|
// Scope change handler
|
||||||
|
select.onchange = async () => {
|
||||||
|
const scope = select.value;
|
||||||
|
updateScopeDesc(scope);
|
||||||
|
const statusEl = document.getElementById('scope-status');
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-gray-400';
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ scope }),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-green-500';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
statusEl.className = 'text-xs text-red-500';
|
||||||
|
}
|
||||||
|
setTimeout(() => statusEl.classList.add('hidden'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render per-tool list grouped by category
|
||||||
|
const grouped = {};
|
||||||
|
for (const tool of data.tools) {
|
||||||
|
const cat = tool.category || 'read';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(tool);
|
||||||
|
}
|
||||||
|
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
|
||||||
|
const container = document.getElementById('tool-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (const cat of catOrder) {
|
||||||
|
if (!grouped[cat]) continue;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'space-y-1';
|
||||||
|
const header = document.createElement('p');
|
||||||
|
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
|
||||||
|
header.textContent = CAT_LABELS[cat] || cat;
|
||||||
|
section.appendChild(header);
|
||||||
|
for (const tool of grouped[cat]) {
|
||||||
|
section.appendChild(renderToolRow(tool));
|
||||||
|
}
|
||||||
|
container.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScopeDesc(scope) {
|
||||||
|
const el = document.getElementById('scope-desc');
|
||||||
|
el.textContent = SCOPE_DESCS[scope] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolRow(tool) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.id = 'tool-row-' + tool.name;
|
||||||
|
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
|
||||||
|
nameEl.textContent = tool.name;
|
||||||
|
nameEl.title = tool.description || '';
|
||||||
|
left.appendChild(nameEl);
|
||||||
|
|
||||||
|
if (tool.sensitivity === 'sensitive') {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
|
||||||
|
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
|
||||||
|
left.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle switch
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'checkbox';
|
||||||
|
input.className = 'sr-only peer';
|
||||||
|
input.checked = tool.enabled !== false;
|
||||||
|
input.onchange = async () => {
|
||||||
|
const enabled = input.checked;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!r.ok) { input.checked = !enabled; }
|
||||||
|
} catch (_) { input.checked = !enabled; }
|
||||||
|
};
|
||||||
|
const slider = document.createElement('div');
|
||||||
|
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||||
|
label.appendChild(input);
|
||||||
|
label.appendChild(slider);
|
||||||
|
|
||||||
|
row.appendChild(left);
|
||||||
|
row.appendChild(label);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadToolAccess);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
197
core/templates/dashboard/sites/list.html
Normal file
197
core/templates/dashboard/sites/list.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
{% 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" id="status-cell-{{ site.id }}">
|
||||||
|
{% if site.status == 'active' %}
|
||||||
|
<span class="status-badge 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="status-badge 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="status-badge 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="status-badge 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 status-msg">{{ site.status_msg[:60] }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
|
||||||
|
{% if site.last_tested_at %}
|
||||||
|
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
|
||||||
|
{% else %}
|
||||||
|
{{ t.never_tested }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
|
||||||
|
{{ t.get('connect', 'Connect') }}
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{{ t.edit }}
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
function updateStatusBadge(siteId, status) {
|
||||||
|
const cell = document.getElementById('status-cell-' + siteId);
|
||||||
|
if (!cell) return;
|
||||||
|
const badge = cell.querySelector('.status-badge');
|
||||||
|
if (badge) {
|
||||||
|
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||||
|
if (status === 'active') {
|
||||||
|
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
|
||||||
|
badge.textContent = '{{ t.active }}';
|
||||||
|
} else {
|
||||||
|
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
|
||||||
|
badge.textContent = '{{ t.error }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msgEl = cell.querySelector('.status-msg');
|
||||||
|
if (msgEl) msgEl.remove();
|
||||||
|
const timeEl = cell.querySelector('.last-tested-time');
|
||||||
|
if (timeEl) {
|
||||||
|
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ct = resp.headers.get('content-type') || '';
|
||||||
|
if (!ct.includes('application/json')) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
btn.textContent = '{{ t.connection_ok }}';
|
||||||
|
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
updateStatusBadge(siteId, data.status || 'active');
|
||||||
|
} else {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, data.status || 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.textContent = '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
updateStatusBadge(siteId, 'error');
|
||||||
|
} finally {
|
||||||
|
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 %}
|
||||||
1340
core/templates/dashboard/sites/manage.html
Normal file
1340
core/templates/dashboard/sites/manage.html
Normal file
File diff suppressed because it is too large
Load Diff
134
core/templates/dashboard/sites/view.html
Normal file
134
core/templates/dashboard/sites/view.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl 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">
|
||||||
|
{{ site.alias }}
|
||||||
|
<span class="ml-2 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>
|
||||||
|
</h2>
|
||||||
|
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||||
|
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MCP Endpoint URL -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">
|
||||||
|
{{ mcp_url }}
|
||||||
|
</code>
|
||||||
|
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
Authenticate with an API key (Bearer token) or OAuth.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Snippets -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
|
||||||
|
</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 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-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||||
|
<button onclick="copyConfig()" id="copy-config-btn"
|
||||||
|
class="absolute top-2 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">
|
||||||
|
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code استفاده کنید.
|
||||||
|
{% else %}
|
||||||
|
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
|
||||||
|
{% else %}
|
||||||
|
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const siteAlias = "{{ site.alias }}";
|
||||||
|
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!client) return;
|
||||||
|
const isWeb = WEB_CLIENTS.includes(client);
|
||||||
|
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
|
||||||
|
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
|
||||||
|
const data = await r.json();
|
||||||
|
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
|
||||||
|
} catch (_) {
|
||||||
|
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyMcpUrl() {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
|
||||||
|
setTimeout(() => btn.textContent = orig, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
241
core/templates/dashboard/user-oauth-clients.html
Normal file
241
core/templates/dashboard/user-oauth-clients.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}کلاینتهای OAuth{% else %}My OAuth Clients{% endif %} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}کلاینتهای OAuth من{% else %}My OAuth Clients{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div>
|
||||||
|
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1 rotate-180{% else %}mr-1{% 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>
|
||||||
|
{% if lang == 'fa' %}بازگشت به اتصال{% else %}Back to Connect{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Banner -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
برای اتصال Claude.ai Connectors به MCP Hub، یک OAuth Client بسازید. Client ID و Client Secret را در Claude.ai وارد کنید.
|
||||||
|
{% else %}
|
||||||
|
Create an OAuth Client to connect Claude.ai Connectors to your MCP Hub sites. Enter the Client ID and Client Secret in Claude.ai → Settings → Connectors.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clients List -->
|
||||||
|
<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' %}کلاینتهای OAuth{% else %}OAuth Clients{% endif %}
|
||||||
|
</h3>
|
||||||
|
<button onclick="openCreateModal()"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {% if lang == 'fa' %}کلاینت جدید{% else %}New Client{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if clients %}
|
||||||
|
<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-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
Client ID
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}Redirect URIs{% else %}Redirect URIs{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for client in clients %}
|
||||||
|
<tr id="client-{{ client.client_id }}">
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-white font-medium">{{ client.client_name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-xs">{{ client.client_id }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||||
|
{% for uri in client.redirect_uris %}
|
||||||
|
<div class="truncate max-w-xs" title="{{ uri }}">{{ uri }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button onclick="deleteClient('{{ client.client_id }}')"
|
||||||
|
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||||
|
{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}هنوز کلاینت OAuth ندارید. یکی بسازید.{% else %}No OAuth clients yet. Create one to connect Claude.ai.{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Client Modal -->
|
||||||
|
<div id="create-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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">
|
||||||
|
{% if lang == 'fa' %}کلاینت OAuth جدید{% else %}New OAuth Client{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||||
|
</label>
|
||||||
|
<input type="text" id="client-name"
|
||||||
|
placeholder="{% if lang == 'fa' %}مثال: Claude.ai{% else %}e.g. Claude.ai Desktop{% endif %}"
|
||||||
|
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">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
|
||||||
|
</label>
|
||||||
|
<textarea id="redirect-uris" rows="3"
|
||||||
|
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 font-mono text-sm">https://claude.ai/api/mcp/auth_callback
|
||||||
|
https://claude.com/api/mcp/auth_callback</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg text-sm transition-colors">
|
||||||
|
{% if lang == 'fa' %}لغو{% else %}Cancel{% endif %}
|
||||||
|
</button>
|
||||||
|
<button onclick="createClient()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{% if lang == 'fa' %}ایجاد{% else %}Create{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Modal (shown once after create) -->
|
||||||
|
<div id="success-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
|
<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 gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}کلاینت ساخته شد{% else %}Client Created{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
{% if lang == 'fa' %}Client Secret فقط یکبار نمایش داده میشود. الان کپی کنید.{% else %}Client Secret is shown only once. Copy it now.{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-client-id" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyText('new-client-id')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-client-secret" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||||
|
<button onclick="copyText('new-client-secret')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
|
<button onclick="closeSuccessModal()"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{% if lang == 'fa' %}متوجه شدم{% else %}Done{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function openCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('create-modal').classList.add('flex');
|
||||||
|
document.getElementById('client-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
|
document.getElementById('client-name').value = '';
|
||||||
|
document.getElementById('redirect-uris').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSuccessModal() {
|
||||||
|
document.getElementById('success-modal').classList.add('hidden');
|
||||||
|
document.getElementById('success-modal').classList.remove('flex');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(elementId) {
|
||||||
|
const text = document.getElementById(elementId).textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const name = document.getElementById('client-name').value.trim();
|
||||||
|
const uris = document.getElementById('redirect-uris').value.trim();
|
||||||
|
if (!name || !uris) {
|
||||||
|
alert('{% if lang == "fa" %}نام و Redirect URI الزامی است{% else %}Name and Redirect URI are required{% endif %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/user-oauth-clients/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ client_name: name, redirect_uris: uris }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
closeCreateModal();
|
||||||
|
document.getElementById('new-client-id').textContent = data.client_id;
|
||||||
|
document.getElementById('new-client-secret').textContent = data.client_secret;
|
||||||
|
document.getElementById('success-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('success-modal').classList.add('flex');
|
||||||
|
} else {
|
||||||
|
alert(data.error || '{% if lang == "fa" %}خطا در ایجاد کلاینت{% else %}Failed to create client{% endif %}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(clientId) {
|
||||||
|
if (!confirm('{% if lang == "fa" %}این کلاینت حذف شود؟{% else %}Delete this OAuth client?{% endif %}')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/dashboard/user-oauth-clients/' + clientId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('client-' + clientId).remove();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.error || '{% if lang == "fa" %}خطا در حذف{% else %}Delete failed{% endif %}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,36 +2,152 @@
|
|||||||
|
|
||||||
{% block title %}{{ t.page_title }}{% endblock %}
|
{% block title %}{{ t.page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.oauth-consent-shell {
|
||||||
|
min-height: calc(100vh - 96px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.10), transparent 30%),
|
||||||
|
linear-gradient(180deg, rgba(15, 23, 42, 0.04), transparent 48%);
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .oauth-consent-shell {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.12), transparent 30%),
|
||||||
|
linear-gradient(180deg, rgba(148, 163, 184, 0.08), transparent 48%);
|
||||||
|
}
|
||||||
|
.oauth-consent-card {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dark .oauth-consent-card {
|
||||||
|
background: rgba(15, 23, 42, 0.96);
|
||||||
|
border-color: rgba(71, 85, 105, 0.55);
|
||||||
|
}
|
||||||
|
.oauth-consent-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 28px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
}
|
||||||
|
html[dir="rtl"] .oauth-consent-header { flex-direction: row-reverse; text-align: right; }
|
||||||
|
.oauth-consent-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(59, 130, 246, 0.10);
|
||||||
|
color: rgb(37, 99, 235);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.oauth-consent-body {
|
||||||
|
padding: 24px 28px 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.oauth-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(248, 250, 252, 0.82);
|
||||||
|
}
|
||||||
|
.dark .oauth-info-grid { background: rgba(30, 41, 59, 0.64); }
|
||||||
|
.oauth-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.oauth-label { color: rgb(100, 116, 139); font-weight: 600; }
|
||||||
|
.dark .oauth-label { color: rgb(148, 163, 184); }
|
||||||
|
.oauth-value {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgb(15, 23, 42);
|
||||||
|
}
|
||||||
|
.dark .oauth-value { color: rgb(226, 232, 240); }
|
||||||
|
html[dir="rtl"] .oauth-value { direction: ltr; text-align: left; }
|
||||||
|
.oauth-scope-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.oauth-scope {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||||
|
background: rgba(59, 130, 246, 0.09);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(30, 64, 175);
|
||||||
|
}
|
||||||
|
.dark .oauth-scope { color: rgb(147, 197, 253); }
|
||||||
|
.oauth-panel {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(248, 250, 252, 0.72);
|
||||||
|
}
|
||||||
|
.dark .oauth-panel { background: rgba(30, 41, 59, 0.48); }
|
||||||
|
.oauth-actions { display: flex; gap: 10px; padding-top: 4px; }
|
||||||
|
html[dir="rtl"] .oauth-actions { flex-direction: row-reverse; }
|
||||||
|
.oauth-actions button { min-height: 44px; border-radius: 10px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.oauth-consent-shell { padding: 12px; align-items: start; }
|
||||||
|
.oauth-consent-header { padding: 20px; }
|
||||||
|
.oauth-consent-body { padding: 18px 20px 22px; }
|
||||||
|
.oauth-row { grid-template-columns: 1fr; gap: 2px; }
|
||||||
|
.oauth-actions { flex-direction: column; }
|
||||||
|
html[dir="rtl"] .oauth-actions { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container px-4">
|
<div class="oauth-consent-shell" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||||
<div class="auth-card w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8">
|
<div class="oauth-consent-card">
|
||||||
<!-- Header with Icon -->
|
<!-- Header with Icon -->
|
||||||
<div class="text-center mb-6">
|
<div class="oauth-consent-header">
|
||||||
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-purple-100 dark:bg-purple-900 mb-4">
|
<div class="oauth-consent-icon">
|
||||||
<svg class="h-10 w-10 text-purple-600 dark:text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">MCP Hub OAuth</div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
<span class="font-semibold text-purple-600 dark:text-purple-400">{{ client_name }}</span>
|
<span class="font-semibold text-blue-700 dark:text-blue-300">{{ client_name }}</span>
|
||||||
{{ t.wants_access.replace('{client_name}', '') }}
|
{{ t.wants_access.replace('{client_name}', '') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth-consent-body">
|
||||||
<!-- Client Information -->
|
<!-- Client Information -->
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
|
<div class="oauth-info-grid">
|
||||||
<div class="space-y-2 text-sm" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="oauth-row">
|
||||||
<span class="text-gray-600 dark:text-gray-400">{{ t.client_id_label }}</span>
|
<span class="oauth-label">{{ t.client_id_label }}</span>
|
||||||
<span class="font-mono text-gray-900 dark:text-white text-xs">{{ client_id }}</span>
|
<span class="oauth-value">{{ client_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if redirect_uri %}
|
{% if redirect_uri %}
|
||||||
<div class="flex justify-between">
|
<div class="oauth-row">
|
||||||
<span class="text-gray-600 dark:text-gray-400">{{ t.redirect_uri_label }}</span>
|
<span class="oauth-label">{{ t.redirect_uri_label }}</span>
|
||||||
<span class="font-mono text-xs text-gray-900 dark:text-white truncate ml-2" title="{{ redirect_uri }}">
|
<span class="oauth-value" title="{{ redirect_uri }}">{{ redirect_uri }}</span>
|
||||||
{{ redirect_uri[:40] }}...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -39,15 +155,15 @@
|
|||||||
|
|
||||||
<!-- Requested Permissions -->
|
<!-- Requested Permissions -->
|
||||||
{% if scopes %}
|
{% if scopes %}
|
||||||
<div class="mb-6" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3>
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3>
|
||||||
<div class="space-y-2">
|
<div class="oauth-scope-list">
|
||||||
{% for scope in scopes %}
|
{% for scope in scopes %}
|
||||||
<div class="permission-badge flex items-center p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="oauth-scope">
|
||||||
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" 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"/>
|
<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>
|
</svg>
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ scope }}</span>
|
<span>{{ scope }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +184,59 @@
|
|||||||
{% if state %}
|
{% if state %}
|
||||||
<input type="hidden" name="state" value="{{ state }}">
|
<input type="hidden" name="state" value="{{ state }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if resource %}
|
||||||
|
<input type="hidden" name="resource" value="{{ resource }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="lang" value="{{ lang }}" id="oauth_lang">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<!-- API Key Input -->
|
<!-- Authentication -->
|
||||||
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
{% if session_user %}
|
||||||
|
<!-- Session-based consent (user already logged in) -->
|
||||||
|
<div>
|
||||||
|
<div class="oauth-panel mb-4">
|
||||||
|
<div class="flex items-center gap-3 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
|
||||||
|
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-green-800 dark:text-green-300">
|
||||||
|
{% if lang == 'fa' %}وارد شده به عنوان{% else %}Logged in as{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{{ session_user.name or session_user.email or 'User' }}
|
||||||
|
{% if session_user.email %}
|
||||||
|
<span class="text-green-600 dark:text-green-500">({{ session_user.email }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="api_key" value="__session__" id="session_marker">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">با کلید API وارد شوید</a>
|
||||||
|
{% else %}
|
||||||
|
Or <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">enter an API key instead</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
|
||||||
|
<div id="api-key-section" class="hidden oauth-panel">
|
||||||
|
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{{ t.enter_api_key }}
|
||||||
|
</label>
|
||||||
|
<input type="password" id="api_key_input" name="api_key_manual"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
||||||
|
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
|
||||||
|
dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- API key mode (no active session) -->
|
||||||
|
<div class="oauth-panel">
|
||||||
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{{ t.enter_api_key }}
|
{{ t.enter_api_key }}
|
||||||
</label>
|
</label>
|
||||||
@@ -83,15 +248,41 @@
|
|||||||
placeholder="{{ t.api_key_placeholder }}"
|
placeholder="{{ t.api_key_placeholder }}"
|
||||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
|
dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
{{ t.api_key_note }}
|
{{ t.api_key_note }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if return_url %}
|
||||||
|
<!-- Social login link -->
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
یا با حساب کاربری وارد شوید:
|
||||||
|
{% else %}
|
||||||
|
Or log in with your account:
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mt-2 {% if lang == 'fa' %}flex-row-reverse{% endif %}">
|
||||||
|
<a href="/auth/github?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 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.23A11.509 11.509 0 0112 5.803c1.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.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a href="/auth/google?next={{ return_url | urlencode }}"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 text-gray-700 text-sm rounded-lg border border-gray-300 transition-colors">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" 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.1z"/><path fill="#34A853" 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"/><path fill="#FBBC05" 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"/><path fill="#EA4335" 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"/></svg>
|
||||||
|
Google
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="oauth-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="action"
|
name="action"
|
||||||
@@ -112,9 +303,9 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<!-- Security Notice -->
|
||||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="oauth-panel">
|
||||||
<div class="flex {% if lang == 'fa' %}flex-row-reverse{% endif %}">
|
<div class="flex gap-2 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
|
||||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 {% if lang == 'fa' %}mr-2{% else %}mr-2{% endif %} flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 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"/>
|
<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>
|
</svg>
|
||||||
<p class="text-xs text-blue-800 dark:text-blue-300">
|
<p class="text-xs text-blue-800 dark:text-blue-300">
|
||||||
@@ -124,33 +315,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
function showApiKeyInput() {
|
||||||
|
// Switch from session mode to API key input mode
|
||||||
|
document.getElementById('api-key-section').classList.remove('hidden');
|
||||||
|
// Remove the __session__ hidden input
|
||||||
|
var hiddenInput = document.getElementById('session_marker');
|
||||||
|
if (hiddenInput) hiddenInput.remove();
|
||||||
|
// Make the manual input the actual api_key field
|
||||||
|
var manualInput = document.getElementById('api_key_input');
|
||||||
|
if (manualInput) {
|
||||||
|
manualInput.name = 'api_key';
|
||||||
|
manualInput.required = true;
|
||||||
|
manualInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side validation
|
// Client-side validation
|
||||||
document.getElementById('authForm').addEventListener('submit', function(e) {
|
document.getElementById('authForm').addEventListener('submit', function(e) {
|
||||||
const apiKey = document.getElementById('api_key').value;
|
var action = e.submitter.value;
|
||||||
const action = e.submitter.value;
|
|
||||||
|
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
|
// Session-based consent — no API key validation needed
|
||||||
|
var sessionInput = document.getElementById('session_marker');
|
||||||
|
if (sessionInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKeyEl = document.getElementById('api_key') || document.getElementById('api_key_input');
|
||||||
|
var apiKey = apiKeyEl ? apiKeyEl.value : '';
|
||||||
|
|
||||||
if (!apiKey || apiKey.length < 10) {
|
if (!apiKey || apiKey.length < 10) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lang = '{{ lang }}';
|
var lang = '{{ lang }}';
|
||||||
const message = lang === 'fa'
|
var message = lang === 'fa'
|
||||||
? 'لطفاً یک API Key معتبر وارد کنید'
|
? 'لطفاً یک API Key معتبر وارد کنید'
|
||||||
: 'Please enter a valid API key';
|
: 'Please enter a valid API key';
|
||||||
alert(message);
|
alert(message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_')) {
|
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
|
||||||
const lang = '{{ lang }}';
|
var lang2 = '{{ lang }}';
|
||||||
const message = lang === 'fa'
|
var msg = lang2 === 'fa'
|
||||||
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
||||||
: 'The API key format looks unusual. Continue anyway?';
|
: 'The API key format looks unusual. Continue anyway?';
|
||||||
const confirm = window.confirm(message);
|
if (!window.confirm(msg)) {
|
||||||
if (!confirm) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -158,13 +372,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus effect
|
// Focus effect for API key input (if present)
|
||||||
const apiKeyInput = document.getElementById('api_key');
|
var apiKeyInput = document.getElementById('api_key');
|
||||||
|
if (apiKeyInput) {
|
||||||
apiKeyInput.addEventListener('focus', function() {
|
apiKeyInput.addEventListener('focus', function() {
|
||||||
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
||||||
});
|
});
|
||||||
apiKeyInput.addEventListener('blur', function() {
|
apiKeyInput.addEventListener('blur', function() {
|
||||||
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
0
core/templates/static/dist/.gitkeep
vendored
Normal file
0
core/templates/static/dist/.gitkeep
vendored
Normal file
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
Binary file not shown.
3
core/templates/static/src/input.css
Normal file
3
core/templates/static/src/input.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
1
core/templates/static/tailwind.min.css
vendored
Normal file
1
core/templates/static/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
883
core/tool_access.py
Normal file
883
core/tool_access.py
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
|
||||||
|
|
||||||
|
Provides a central pipeline that filters the set of MCP tools presented for
|
||||||
|
a user endpoint based on:
|
||||||
|
|
||||||
|
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
|
||||||
|
``admin``) that works across ALL plugins using the tool's
|
||||||
|
``required_scope`` field. For Coolify (which has fine-grained
|
||||||
|
``category`` annotations) the legacy category mapping is kept as an
|
||||||
|
overlay for the ``custom`` preset.
|
||||||
|
2. **Per-site tool toggles.** Site owners may explicitly disable specific
|
||||||
|
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||||
|
tools without an entry are enabled by default.
|
||||||
|
|
||||||
|
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
||||||
|
site-level preset filter" — in that case only the per-tool toggles and the
|
||||||
|
key scope are considered.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
mgr = get_tool_access_manager()
|
||||||
|
visible = await mgr.get_visible_tools(
|
||||||
|
site_id=site["id"],
|
||||||
|
key_scopes=["read"],
|
||||||
|
plugin_type="coolify",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Universal scope tier system (F.7c → F.19.2.0) ───────────────────
|
||||||
|
# Maps a scope tier to the set of ``required_scope`` values it may access.
|
||||||
|
# Works for ALL plugins because every tool has ``required_scope``.
|
||||||
|
#
|
||||||
|
# Ladder (each tier is a strict superset of the one above):
|
||||||
|
#
|
||||||
|
# read │ inventory + diagnostics only
|
||||||
|
# editor │ + content / theme-file edits (F.19.5 + F.19.7)
|
||||||
|
# settings │ + options / cron / maintenance / site-identity / permalinks (F.19.6)
|
||||||
|
# install │ + plugin & theme install / activate / update from wp.org (F.19.2)
|
||||||
|
# write │ catch-all "non-destructive write" tier (legacy WP/WC/Gitea)
|
||||||
|
# admin │ destructive ops (URL/zip install, delete user/plugin/theme,
|
||||||
|
# │ user CRUD, db_query, anything that can lose data)
|
||||||
|
#
|
||||||
|
# Why ``write`` is between ``install`` and ``admin``: keeps existing
|
||||||
|
# WordPress/WooCommerce/Gitea/etc. keys (which use plain ``write``)
|
||||||
|
# working. They retain the same effective access they had pre-F.19.2,
|
||||||
|
# minus the destructive ops which now require ``admin``.
|
||||||
|
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
|
||||||
|
"read": {"read"},
|
||||||
|
# F.19.5 — ``editor``: page-content editing without unlocking
|
||||||
|
# destructive ops. wordpress_specialist uses it; other plugins fall
|
||||||
|
# through to the existing read/write/admin ladder.
|
||||||
|
"editor": {"read", "editor"},
|
||||||
|
# F.19.6 — ``settings``: options / cron / maintenance toggle /
|
||||||
|
# site identity / reading + permalinks. Fits between editor and
|
||||||
|
# install — settings changes are recoverable, plugin installs can
|
||||||
|
# reshape the site.
|
||||||
|
"settings": {"read", "editor", "settings"},
|
||||||
|
# F.19.2 — ``install``: plugin/theme install + activate + update
|
||||||
|
# from wp.org slugs. URL/zip install routes belong to ``admin``
|
||||||
|
# (sees more attack surface). delete_plugin / delete_theme also
|
||||||
|
# belong to ``admin`` because they can drop data with no undo.
|
||||||
|
"install": {"read", "editor", "settings", "install"},
|
||||||
|
# ``write`` is the historical catch-all for non-destructive writes
|
||||||
|
# (WordPress create_post, WooCommerce update_order, etc.).
|
||||||
|
"write": {"read", "editor", "settings", "install", "write"},
|
||||||
|
"admin": {"read", "editor", "settings", "install", "write", "admin"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-tier descriptions for dashboard tooltips + tier toggles. EN + FA
|
||||||
|
# in lockstep with the rest of the dashboard's i18n.
|
||||||
|
TIER_DESCRIPTIONS: dict[str, dict[str, str]] = {
|
||||||
|
"read": {
|
||||||
|
"label_en": "Read Only",
|
||||||
|
"label_fa": "فقط خواندن",
|
||||||
|
"hint_en": "Inventory and diagnostics — no changes.",
|
||||||
|
"hint_fa": "مشاهده و عیبیابی — بدون تغییر.",
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"label_en": "Editor",
|
||||||
|
"label_fa": "ویرایشگر",
|
||||||
|
"hint_en": "Read + page / theme content edits.",
|
||||||
|
"hint_fa": "خواندن + ویرایش محتوا و فایل قالب.",
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"label_en": "Settings",
|
||||||
|
"label_fa": "تنظیمات",
|
||||||
|
"hint_en": "Editor + site options, cron, identity, permalinks.",
|
||||||
|
"hint_fa": "ویرایشگر + تنظیمات سایت، کرون، هویت، پرمالینک.",
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"label_en": "Installer",
|
||||||
|
"label_fa": "نصبکننده",
|
||||||
|
"hint_en": "Settings + install / activate plugins & themes from wp.org.",
|
||||||
|
"hint_fa": "تنظیمات + نصب و فعالسازی پلاگین/قالب از wp.org.",
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"label_en": "Write",
|
||||||
|
"label_fa": "نوشتن",
|
||||||
|
"hint_en": "Non-destructive write tier (post/product CRUD, etc.).",
|
||||||
|
"hint_fa": "تیر نوشتن غیرتخریبی (ایجاد/ویرایش پست، محصول و …).",
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"label_en": "Admin",
|
||||||
|
"label_fa": "مدیر",
|
||||||
|
"hint_en": "Includes destructive ops (delete, URL/zip install, user CRUD).",
|
||||||
|
"hint_fa": "شامل عملیات تخریبی (حذف، نصب از URL/zip، CRUD کاربر).",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
|
||||||
|
# Mapping from scope → set of tool categories that scope may see.
|
||||||
|
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||||
|
"read": {"read"},
|
||||||
|
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||||
|
"deploy": {"read", "lifecycle"},
|
||||||
|
"write": {"read", "lifecycle", "crud", "env"},
|
||||||
|
"admin": {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# All known Coolify categories.
|
||||||
|
KNOWN_CATEGORIES: set[str] = {
|
||||||
|
"read",
|
||||||
|
"read_sensitive",
|
||||||
|
"lifecycle",
|
||||||
|
"crud",
|
||||||
|
"env",
|
||||||
|
"backup",
|
||||||
|
"system",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
||||||
|
SCOPE_CUSTOM = "custom"
|
||||||
|
|
||||||
|
# Plugins that have fine-grained category annotations.
|
||||||
|
_CATEGORY_PLUGINS: set[str] = {"coolify"}
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||||
|
"""Return the union of categories allowed by the given scope list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scopes: List of scope strings as presented on the API key / token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of category names the scopes collectively allow.
|
||||||
|
"""
|
||||||
|
allowed: set[str] = set()
|
||||||
|
for scope in scopes:
|
||||||
|
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _scopes_to_required(scopes: list[str]) -> set[str]:
|
||||||
|
"""Return the union of ``required_scope`` values allowed by universal tiers."""
|
||||||
|
allowed: set[str] = set()
|
||||||
|
for scope in scopes:
|
||||||
|
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
|
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
|
||||||
|
"""Return the appropriate scope presets for a plugin type (F.7d).
|
||||||
|
|
||||||
|
Each preset is a dict with ``value`` (the canonical scope key persisted
|
||||||
|
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
|
||||||
|
``hint`` / ``hint_fa`` (one-line description shown under the title).
|
||||||
|
|
||||||
|
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
|
||||||
|
``core.dashboard.routes`` — currently:
|
||||||
|
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
|
||||||
|
"""
|
||||||
|
custom = {
|
||||||
|
"value": "custom",
|
||||||
|
"label": "Custom",
|
||||||
|
"label_fa": "سفارشی",
|
||||||
|
"hint": "Per-tool toggles",
|
||||||
|
"hint_fa": "هر ابزار جداگانه",
|
||||||
|
}
|
||||||
|
|
||||||
|
if plugin_type == "coolify":
|
||||||
|
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read",
|
||||||
|
"label_fa": "خواندن",
|
||||||
|
"hint": "List/inspect resources",
|
||||||
|
"hint_fa": "مشاهده و فهرست منابع",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "read:sensitive",
|
||||||
|
"label": "Read + Secrets",
|
||||||
|
"label_fa": "خواندن + اسرار",
|
||||||
|
"hint": "Includes env vars and backups",
|
||||||
|
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "deploy",
|
||||||
|
"label": "Deploy",
|
||||||
|
"label_fa": "استقرار",
|
||||||
|
"hint": "Read + lifecycle (start/stop/restart)",
|
||||||
|
"hint_fa": "مشاهده + راهاندازی/توقف/ریستارت",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "write",
|
||||||
|
"label": "Write",
|
||||||
|
"label_fa": "نوشتن",
|
||||||
|
"hint": "Read + lifecycle + CRUD + env",
|
||||||
|
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Root",
|
||||||
|
"label_fa": "روت",
|
||||||
|
"hint": "Everything including system commands",
|
||||||
|
"hint_fa": "همه چیز شامل دستورات سیستم",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
if plugin_type == "openpanel":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read",
|
||||||
|
"label_fa": "خواندن",
|
||||||
|
"hint": "Export current project only",
|
||||||
|
"hint_fa": "خروجی پروژه فعلی",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "write",
|
||||||
|
"label": "Write",
|
||||||
|
"label_fa": "نوشتن",
|
||||||
|
"hint": "Default ingestion (track events)",
|
||||||
|
"hint_fa": "ارسال رویداد (ingestion)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Root",
|
||||||
|
"label_fa": "روت",
|
||||||
|
"hint": "Export any project",
|
||||||
|
"hint_fa": "خروجی هر پروژه",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
if plugin_type == "woocommerce":
|
||||||
|
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
|
||||||
|
# so Write and "Read + Write" tiers are identical. Present a single
|
||||||
|
# full-access tier instead of two duplicates.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read Only",
|
||||||
|
"label_fa": "فقط خواندن",
|
||||||
|
"hint": "Browse products, orders, customers",
|
||||||
|
"hint_fa": "مشاهده محصولات، سفارشها و مشتریان",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Read + Write",
|
||||||
|
"label_fa": "خواندن + نوشتن",
|
||||||
|
"hint": "Full store management (all 28 tools)",
|
||||||
|
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
if plugin_type == "wordpress":
|
||||||
|
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
|
||||||
|
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
|
||||||
|
# to be installed on the WP site itself. Present 2 tiers + custom.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read Only",
|
||||||
|
"label_fa": "فقط خواندن",
|
||||||
|
"hint": "View posts, pages, media",
|
||||||
|
"hint_fa": "مشاهده نوشتهها، صفحات و رسانه",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Full Access",
|
||||||
|
"label_fa": "دسترسی کامل",
|
||||||
|
"hint": "All tools (CRUD + SEO via add-on)",
|
||||||
|
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
if plugin_type == "wordpress_specialist":
|
||||||
|
# F.19.1 ships READ-only tools (inventory + diagnostics).
|
||||||
|
# F.19.5 adds the EDITOR tier — page editing (Gutenberg + Elementor
|
||||||
|
# + Classic) on top of read.
|
||||||
|
# F.19.7 adds the THEME DEV surface on the editor tier (file CRUD)
|
||||||
|
# and the install/admin tiers for theme install/activate/delete.
|
||||||
|
# F.19.2.1 adds plugin install/activate/deactivate/update (install
|
||||||
|
# tier) and plugin install-from-zip + delete (admin tier).
|
||||||
|
# F.19.6 will surface a SETTINGS tier between editor and install.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read Only",
|
||||||
|
"label_fa": "فقط خواندن",
|
||||||
|
"hint": "Inventory plugins/themes/users/options/cron — no changes",
|
||||||
|
"hint_fa": "مشاهده پلاگینها/قالبها/کاربران/تنظیمات/کرون — بدون تغییر",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "editor",
|
||||||
|
"label": "Editor",
|
||||||
|
"label_fa": "ویرایشگر",
|
||||||
|
"hint": "Read + page editing (Gutenberg blocks, Elementor, Classic) + theme file CRUD",
|
||||||
|
"hint_fa": "خواندن + ویرایش صفحه (بلوکها، المنتور، کلاسیک) + ویرایش فایل قالب",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "install",
|
||||||
|
"label": "Installer",
|
||||||
|
"label_fa": "نصبکننده",
|
||||||
|
"hint": "Editor + install/activate plugins & themes from wp.org",
|
||||||
|
"hint_fa": "ویرایشگر + نصب و فعالسازی پلاگین/قالب از wp.org",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Admin",
|
||||||
|
"label_fa": "مدیر",
|
||||||
|
"hint": "Installer + URL/zip install, plugin/theme delete (destructive)",
|
||||||
|
"hint_fa": "نصبکننده + نصب از URL/zip، حذف پلاگین/قالب (تخریبی)",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
if plugin_type == "gitea":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read",
|
||||||
|
"label_fa": "خواندن",
|
||||||
|
"hint": "Browse repos, issues, users",
|
||||||
|
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "write",
|
||||||
|
"label": "Read + Write",
|
||||||
|
"label_fa": "خواندن + نوشتن",
|
||||||
|
"hint": "Create issues, PRs, branches",
|
||||||
|
"hint_fa": "ایجاد ایشو، PR و شاخه",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Admin",
|
||||||
|
"label_fa": "مدیر",
|
||||||
|
"hint": "Repo + org + user admin",
|
||||||
|
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Universal default for all other plugins.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": "read",
|
||||||
|
"label": "Read",
|
||||||
|
"label_fa": "فقط خواندن",
|
||||||
|
"hint": "View only",
|
||||||
|
"hint_fa": "فقط مشاهده",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "write",
|
||||||
|
"label": "Read + Write",
|
||||||
|
"label_fa": "خواندن + نوشتن",
|
||||||
|
"hint": "CRUD ops",
|
||||||
|
"hint_fa": "عملیات CRUD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admin",
|
||||||
|
"label": "Full Access",
|
||||||
|
"label_fa": "دسترسی کامل",
|
||||||
|
"hint": "All tools",
|
||||||
|
"hint_fa": "همه ابزارها",
|
||||||
|
},
|
||||||
|
custom,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolAccessManager:
|
||||||
|
"""Central manager for scope-based visibility and per-site tool toggles."""
|
||||||
|
|
||||||
|
def apply_scope_filter(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
scopes: list[str],
|
||||||
|
plugin_type: str | None = None,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools not allowed by the presented scopes.
|
||||||
|
|
||||||
|
For plugins with category annotations (Coolify) the legacy
|
||||||
|
category-based filter is used. For all other plugins the universal
|
||||||
|
3-tier filter based on ``required_scope`` is applied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Candidate tool list.
|
||||||
|
scopes: Scopes presented on the API key (or a single-element list
|
||||||
|
containing a site's ``tool_scope`` preset).
|
||||||
|
plugin_type: Plugin type hint. When provided and the plugin is
|
||||||
|
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
|
||||||
|
used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered tool list.
|
||||||
|
"""
|
||||||
|
# Try universal tiers first (works for all plugins)
|
||||||
|
allowed_scopes = _scopes_to_required(scopes)
|
||||||
|
|
||||||
|
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
|
||||||
|
# Universal filter: match tool.required_scope against allowed tiers
|
||||||
|
return [t for t in tools if t.required_scope in allowed_scopes]
|
||||||
|
|
||||||
|
# Fallback: legacy category-based filter for Coolify / custom scopes
|
||||||
|
allowed = scopes_to_categories(scopes)
|
||||||
|
if not allowed:
|
||||||
|
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||||
|
|
||||||
|
result: list[ToolDefinition] = []
|
||||||
|
for tool in tools:
|
||||||
|
if tool.category not in KNOWN_CATEGORIES:
|
||||||
|
result.append(tool)
|
||||||
|
continue
|
||||||
|
if tool.category in allowed:
|
||||||
|
result.append(tool)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def apply_site_toggles(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
site_id: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools the site owner has explicitly disabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Candidate tool list.
|
||||||
|
site_id: Site UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered tool list.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
except RuntimeError:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
if not toggles:
|
||||||
|
return tools
|
||||||
|
return [t for t in tools if toggles.get(t.name, True)]
|
||||||
|
|
||||||
|
async def get_visible_tools(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Return the visible tool list for a site on a given plugin.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. ``ToolRegistry.get_by_plugin_type``
|
||||||
|
2. Key-scope filter (API key's declared scopes)
|
||||||
|
3. Site-scope filter (site's stored ``tool_scope`` preset,
|
||||||
|
skipped when it is ``custom``)
|
||||||
|
4. Per-site toggle filter (``site_tool_toggles``)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID (the MCP endpoint alias resolves to this).
|
||||||
|
key_scopes: Scopes presented on the API key / token.
|
||||||
|
plugin_type: Plugin type (e.g. ``coolify``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of visible ``ToolDefinition`` objects.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
|
||||||
|
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
site_scope = await db.get_site_tool_scope(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
site_scope = "admin"
|
||||||
|
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||||
|
|
||||||
|
tools = await self.apply_site_toggles(tools, site_id)
|
||||||
|
# F.X.fix-pass3: filter out tools whose central prerequisites
|
||||||
|
# are unmet (no AI provider key, missing companion route,
|
||||||
|
# missing SEO plugin) so the live endpoint never advertises a
|
||||||
|
# tool that would 100% fail at call time.
|
||||||
|
tools = await self.apply_prerequisites_filter(tools, site_id)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
async def toggle_tool(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enable or disable a single tool for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
tool_name: Fully-qualified tool name.
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
reason: Optional note.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
|
||||||
|
logger.info(
|
||||||
|
"site %s toggled %s → %s",
|
||||||
|
site_id,
|
||||||
|
tool_name,
|
||||||
|
"enabled" if enabled else "disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def bulk_toggle_by_scope(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
scope_name: str,
|
||||||
|
enabled: bool,
|
||||||
|
plugin_type: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Toggle every tool whose category belongs to the given scope.
|
||||||
|
|
||||||
|
Only the *exclusive* category set of the scope is affected — i.e.
|
||||||
|
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
|
||||||
|
Tools outside those categories are left unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
|
||||||
|
enabled: True to enable, False to disable.
|
||||||
|
plugin_type: Optional filter — only affect tools from this plugin.
|
||||||
|
When ``None`` every plugin's tools in that category are touched.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tools affected.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
categories = SCOPE_TO_CATEGORIES.get(scope_name)
|
||||||
|
if categories is None:
|
||||||
|
raise ValueError(f"Unknown scope '{scope_name}'")
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
candidates = registry.get_all()
|
||||||
|
if plugin_type is not None:
|
||||||
|
candidates = [t for t in candidates if t.plugin_type == plugin_type]
|
||||||
|
affected = [t.name for t in candidates if t.category in categories]
|
||||||
|
|
||||||
|
if not affected:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
await db.bulk_set_site_tool_toggles(
|
||||||
|
site_id,
|
||||||
|
[(name, enabled) for name in affected],
|
||||||
|
reason=f"bulk:{scope_name}",
|
||||||
|
)
|
||||||
|
return len(affected)
|
||||||
|
|
||||||
|
async def list_tools_for_site(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return every tool for a plugin, annotated with per-site toggle state.
|
||||||
|
|
||||||
|
Used by the dashboard API to present the per-site management view.
|
||||||
|
Does not apply scope filters — the UI decides what to show.
|
||||||
|
|
||||||
|
F.X.fix #8: each row also carries ``provider_key_required`` and
|
||||||
|
``provider_key_configured`` so the Tool Access template can
|
||||||
|
render AI tools greyed + "Configure key" CTA when the site has
|
||||||
|
no keys for the required provider, instead of letting the user
|
||||||
|
enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
|
||||||
|
|
||||||
|
F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
|
||||||
|
derived from a central :data:`_TOOL_PREREQUISITES` resolver so
|
||||||
|
the UI can auto-grey/disable tools whose requirements (companion
|
||||||
|
route, SEO plugin, AI key) the site doesn't satisfy. This is the
|
||||||
|
same data the live MCP endpoint uses to silently filter the
|
||||||
|
catalog (see :meth:`get_visible_tools`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
plugin_type: Plugin type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with tool metadata + ``enabled`` flag.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
toggles = await db.get_site_tool_toggles(site_id)
|
||||||
|
except RuntimeError:
|
||||||
|
toggles = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.site_api import list_site_providers_set
|
||||||
|
|
||||||
|
configured_providers = await list_site_providers_set(site_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
configured_providers = set()
|
||||||
|
|
||||||
|
# F.X.fix-pass3: pull the cached probe payload so we can
|
||||||
|
# evaluate prerequisites for SEO / companion-route tools.
|
||||||
|
# ``probe_site_capabilities`` reuses its 10-min cache so the
|
||||||
|
# cost is one SQLite query in the common path.
|
||||||
|
probe_payload: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
from core.capability_probe import get_probe_cache
|
||||||
|
|
||||||
|
cached = get_probe_cache().get(site_id)
|
||||||
|
if cached is not None:
|
||||||
|
probe_payload = cached
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
probe_payload = None
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for t in tools:
|
||||||
|
available, reason = check_tool_prerequisites(
|
||||||
|
t.name,
|
||||||
|
probe_payload=probe_payload,
|
||||||
|
configured_providers=configured_providers,
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"plugin_type": t.plugin_type,
|
||||||
|
"category": t.category,
|
||||||
|
"sensitivity": t.sensitivity,
|
||||||
|
"required_scope": t.required_scope,
|
||||||
|
"enabled": toggles.get(t.name, True),
|
||||||
|
"provider_key_required": _tool_requires_provider_key(t.name),
|
||||||
|
"provider_key_configured": _tool_has_configured_provider(
|
||||||
|
t.name, configured_providers
|
||||||
|
),
|
||||||
|
"available": available,
|
||||||
|
"unavailable_reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def apply_prerequisites_filter(
|
||||||
|
self,
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
site_id: str,
|
||||||
|
) -> list[ToolDefinition]:
|
||||||
|
"""Drop tools whose central prerequisites are not satisfied.
|
||||||
|
|
||||||
|
Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
|
||||||
|
objects so the live MCP endpoint pipeline (``get_visible_tools``)
|
||||||
|
can call it inline. Reads the same cached probe + provider-key
|
||||||
|
set so a fresh probe never gets triggered from this hot path.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.site_api import list_site_providers_set
|
||||||
|
|
||||||
|
configured_providers = await list_site_providers_set(site_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
configured_providers = set()
|
||||||
|
|
||||||
|
probe_payload: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
from core.capability_probe import get_probe_cache
|
||||||
|
|
||||||
|
cached = get_probe_cache().get(site_id)
|
||||||
|
if cached is not None:
|
||||||
|
probe_payload = cached
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
probe_payload = None
|
||||||
|
|
||||||
|
kept: list[ToolDefinition] = []
|
||||||
|
for tool in tools:
|
||||||
|
available, _ = check_tool_prerequisites(
|
||||||
|
tool.name,
|
||||||
|
probe_payload=probe_payload,
|
||||||
|
configured_providers=configured_providers,
|
||||||
|
)
|
||||||
|
if available:
|
||||||
|
kept.append(tool)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
|
||||||
|
# Today only the AI image generator; expand as more AI tools land.
|
||||||
|
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"wordpress_generate_and_upload_image",
|
||||||
|
"woocommerce_generate_and_upload_image",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_requires_provider_key(tool_name: str) -> bool:
|
||||||
|
return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
|
||||||
|
"""``True`` iff the site has at least one provider key for this tool.
|
||||||
|
|
||||||
|
The AI image tool is happy with ANY supported provider key (the
|
||||||
|
caller picks which one at call time via the ``provider`` arg) so
|
||||||
|
any non-empty configured set counts.
|
||||||
|
"""
|
||||||
|
if not _tool_requires_provider_key(tool_name):
|
||||||
|
return True
|
||||||
|
return bool(configured)
|
||||||
|
|
||||||
|
|
||||||
|
# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
|
||||||
|
# Single source of truth for "this tool needs X to work". The resolver
|
||||||
|
# below decides per-call whether a tool is *available* on a given site
|
||||||
|
# given the cached probe payload + the site's configured provider keys.
|
||||||
|
# Three predicate kinds:
|
||||||
|
#
|
||||||
|
# provider_key — site needs at least one of the listed AI provider
|
||||||
|
# keys.
|
||||||
|
# companion_route — the companion plugin must advertise the named
|
||||||
|
# route in probe.routes.
|
||||||
|
# feature_any — the WP probe features must include at least one of
|
||||||
|
# the listed feature names (e.g. rank_math / yoast).
|
||||||
|
#
|
||||||
|
# The resolver's output (``available`` + ``unavailable_reason``) is
|
||||||
|
# attached to every tool row in ``list_tools_for_site`` AND used by
|
||||||
|
# ``apply_prerequisites_filter`` to drop unavailable tools from the
|
||||||
|
# live MCP endpoint, so models calling the endpoint never see a tool
|
||||||
|
# that is guaranteed to fail at call time.
|
||||||
|
|
||||||
|
_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
|
||||||
|
# AI image — any provider key is enough; the caller picks at call time.
|
||||||
|
"wordpress_generate_and_upload_image": [
|
||||||
|
{
|
||||||
|
"kind": "provider_key",
|
||||||
|
"any_of": ["openai", "stability", "replicate", "openrouter"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
# Companion-route-backed WP tools.
|
||||||
|
"wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
|
||||||
|
"wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||||
|
"wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
|
||||||
|
"wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
|
||||||
|
"wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
|
||||||
|
"wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||||
|
"wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||||
|
"wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
|
||||||
|
"wordpress_regenerate_thumbnails": [
|
||||||
|
{"kind": "companion_route", "name": "regenerate_thumbnails"}
|
||||||
|
],
|
||||||
|
"wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||||
|
"wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
|
||||||
|
# SEO — needs Rank Math (or Yoast in the future). Probe surfaces
|
||||||
|
# both flags; we accept either.
|
||||||
|
"wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||||
|
"wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||||
|
"wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||||
|
"wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||||
|
"wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
|
||||||
|
# F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
|
||||||
|
# WC consumer_key + secret can't authenticate. Need a WP App
|
||||||
|
# Password (wp_username + wp_app_password fields, or legacy
|
||||||
|
# username + app_password single-credential mode).
|
||||||
|
"woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
|
||||||
|
"woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
|
||||||
|
"woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
|
||||||
|
# AI image, exposed on both WP and WC plugins. The WC variant
|
||||||
|
# needs both an AI provider key AND WP credentials (to upload
|
||||||
|
# /wp/v2/media); the WP variant only needs the provider key
|
||||||
|
# since its primary client already uses Application Password.
|
||||||
|
"woocommerce_generate_and_upload_image": [
|
||||||
|
{"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
|
||||||
|
{"kind": "wp_credentials"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_tool_prerequisites(
|
||||||
|
tool_name: str,
|
||||||
|
*,
|
||||||
|
probe_payload: dict[str, Any] | None,
|
||||||
|
configured_providers: set[str],
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Decide whether a tool's prerequisites are satisfied.
|
||||||
|
|
||||||
|
Returns ``(available, unavailable_reason)``. ``unavailable_reason``
|
||||||
|
is one of ``provider_key`` | ``companion_route`` | ``feature``
|
||||||
|
| ``probe_unknown`` when the tool is unavailable, else ``None``.
|
||||||
|
|
||||||
|
A tool with no prerequisites declared is always available — most
|
||||||
|
of the catalog (plain CRUD over WP REST) needs no companion / no
|
||||||
|
AI key, so the default of "no entry → True" keeps the rule list
|
||||||
|
short.
|
||||||
|
"""
|
||||||
|
rules = _TOOL_PREREQUISITES.get(tool_name)
|
||||||
|
if not rules:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
routes = ((probe_payload or {}).get("routes")) or {}
|
||||||
|
features = ((probe_payload or {}).get("features")) or {}
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
kind = rule.get("kind")
|
||||||
|
if kind == "provider_key":
|
||||||
|
allowed = set(rule.get("any_of") or [])
|
||||||
|
if allowed and not (allowed & configured_providers):
|
||||||
|
return False, "provider_key"
|
||||||
|
elif kind == "companion_route":
|
||||||
|
name = rule.get("name") or ""
|
||||||
|
# Probe is authoritative when present; absence of probe data
|
||||||
|
# (e.g. companion not installed → companion_available=False)
|
||||||
|
# is also treated as "unavailable".
|
||||||
|
if not routes.get(name):
|
||||||
|
return False, "companion_route"
|
||||||
|
elif kind == "feature_any":
|
||||||
|
names = list(rule.get("names") or [])
|
||||||
|
# WP companion's features dict carries booleans like
|
||||||
|
# rank_math: true / yoast: false. WC has no analogous gate.
|
||||||
|
if not any(features.get(n) for n in names):
|
||||||
|
return False, "feature"
|
||||||
|
elif kind == "wp_credentials":
|
||||||
|
# F.X.fix-pass5 — WC media tools and the WC AI image tool
|
||||||
|
# need a WP Application Password to authenticate
|
||||||
|
# /wp/v2/media. The WC plugin probe surfaces a
|
||||||
|
# ``wp_credentials_present`` flag; absence is a hard fail.
|
||||||
|
if not (probe_payload or {}).get("wp_credentials_present"):
|
||||||
|
return False, "wp_credentials"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: ToolAccessManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_access_manager() -> ToolAccessManager:
|
||||||
|
"""Return the singleton :class:`ToolAccessManager`."""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = ToolAccessManager()
|
||||||
|
return _manager
|
||||||
@@ -181,6 +181,9 @@ class ToolGenerator:
|
|||||||
description = spec["description"]
|
description = spec["description"]
|
||||||
schema = spec["schema"]
|
schema = spec["schema"]
|
||||||
scope = spec.get("scope", "read")
|
scope = spec.get("scope", "read")
|
||||||
|
# F.7: optional category + sensitivity for scope-based visibility
|
||||||
|
category = spec.get("category", "read")
|
||||||
|
sensitivity = spec.get("sensitivity", "normal")
|
||||||
|
|
||||||
# Create full tool name
|
# Create full tool name
|
||||||
tool_name = f"{plugin_type}_{action_name}"
|
tool_name = f"{plugin_type}_{action_name}"
|
||||||
@@ -202,6 +205,8 @@ class ToolGenerator:
|
|||||||
handler=handler,
|
handler=handler,
|
||||||
required_scope=scope,
|
required_scope=scope,
|
||||||
plugin_type=plugin_type,
|
plugin_type=plugin_type,
|
||||||
|
category=category,
|
||||||
|
sensitivity=sensitivity,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_site_parameter(
|
def _add_site_parameter(
|
||||||
@@ -448,12 +453,15 @@ class ToolGenerator:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Import custom exceptions for better error handling
|
# 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__
|
error_type = type(e).__name__
|
||||||
|
|
||||||
if isinstance(e, ConfigurationError):
|
if isinstance(e, ConfigurationError):
|
||||||
# Configuration error - likely missing env vars
|
|
||||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||||
return (
|
return (
|
||||||
f"Configuration Error: {str(e)}\n\n"
|
f"Configuration Error: {str(e)}\n\n"
|
||||||
@@ -464,12 +472,14 @@ class ToolGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(e, AuthenticationError):
|
elif isinstance(e, AuthenticationError):
|
||||||
# Authentication error - 401/403
|
|
||||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||||
return f"Authentication Error: {str(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:
|
else:
|
||||||
# Unexpected error
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|||||||
122
core/tool_rate_limiter.py
Normal file
122
core/tool_rate_limiter.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Per-tool, per-user rate limiting (F.5a.6).
|
||||||
|
|
||||||
|
Provides a small token-bucket limiter keyed by (user_id, tool_name) for
|
||||||
|
expensive tools such as AI image generation and chunked-upload finish.
|
||||||
|
Admin / env-fallback callers (``user_id is None``) are exempt.
|
||||||
|
|
||||||
|
Intentionally minimal — separate from :mod:`core.rate_limiter` (which is a
|
||||||
|
global per-client limiter). This one is scoped to specific tools with
|
||||||
|
small hourly caps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from core.rate_limiter import TokenBucket
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Default caps (per hour per user). Documented in ROADMAP F.5a.6.
|
||||||
|
DEFAULT_LIMITS: dict[str, int] = {
|
||||||
|
"wordpress_generate_and_upload_image": 10,
|
||||||
|
"wordpress_upload_media_chunked_finish": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolRateLimitError(Exception):
|
||||||
|
"""Raised when a per-tool limit is exceeded."""
|
||||||
|
|
||||||
|
tool_name: str
|
||||||
|
limit_per_hour: int
|
||||||
|
retry_after_seconds: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"Per-tool rate limit exceeded for '{self.tool_name}' "
|
||||||
|
f"({self.limit_per_hour}/hour per user). "
|
||||||
|
f"Retry in {self.retry_after_seconds:.0f}s."
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"error_code": "TOOL_RATE_LIMITED",
|
||||||
|
"message": str(self),
|
||||||
|
"details": {
|
||||||
|
"tool": self.tool_name,
|
||||||
|
"limit_per_hour": self.limit_per_hour,
|
||||||
|
"retry_after_seconds": round(self.retry_after_seconds, 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PerToolRateLimiter:
|
||||||
|
"""Token-bucket limiter keyed by (user_id, tool_name).
|
||||||
|
|
||||||
|
``check(tool, user_id)`` consumes one token and raises
|
||||||
|
:class:`ToolRateLimitError` when the user is over quota. Admin / env
|
||||||
|
callers (``user_id`` is None or empty) are exempt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, limits: dict[str, int] | None = None) -> None:
|
||||||
|
self._limits = dict(limits if limits is not None else DEFAULT_LIMITS)
|
||||||
|
self._buckets: dict[tuple[str, str], TokenBucket] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def configure(self, tool_name: str, per_hour: int) -> None:
|
||||||
|
"""Override the per-hour cap for a tool."""
|
||||||
|
self._limits[tool_name] = per_hour
|
||||||
|
# Existing buckets keep their old capacity until reset — tests can
|
||||||
|
# reset() to re-read the new limit.
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._buckets.clear()
|
||||||
|
|
||||||
|
def check(self, tool_name: str, user_id: str | None) -> None:
|
||||||
|
"""Consume one token for (user_id, tool_name). Exempt when user_id is falsy."""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
limit = self._limits.get(tool_name)
|
||||||
|
if limit is None or limit <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = (user_id, tool_name)
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._buckets.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = TokenBucket(capacity=limit, refill_rate=limit / 3600.0)
|
||||||
|
self._buckets[key] = bucket
|
||||||
|
|
||||||
|
if not bucket.consume(1):
|
||||||
|
wait = bucket.get_wait_time(1)
|
||||||
|
logger.warning(
|
||||||
|
"Per-tool rate limit hit: user=%s tool=%s limit=%d/h retry_after=%.1fs",
|
||||||
|
user_id,
|
||||||
|
tool_name,
|
||||||
|
limit,
|
||||||
|
wait,
|
||||||
|
)
|
||||||
|
raise ToolRateLimitError(
|
||||||
|
tool_name=tool_name, limit_per_hour=limit, retry_after_seconds=wait
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_limiter: PerToolRateLimiter | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_rate_limiter() -> PerToolRateLimiter:
|
||||||
|
global _limiter
|
||||||
|
if _limiter is None:
|
||||||
|
_limiter = PerToolRateLimiter()
|
||||||
|
return _limiter
|
||||||
|
|
||||||
|
|
||||||
|
def set_tool_rate_limiter(limiter: PerToolRateLimiter | None) -> None:
|
||||||
|
"""Override the singleton (used by tests)."""
|
||||||
|
global _limiter
|
||||||
|
_limiter = limiter
|
||||||
@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
|
|||||||
handler: Async function that executes the tool
|
handler: Async function that executes the tool
|
||||||
required_scope: Required API key scope ("read", "write", "admin")
|
required_scope: Required API key scope ("read", "write", "admin")
|
||||||
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
|
||||||
|
category: Tool category for scope-based visibility filtering (F.7)
|
||||||
|
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
|
||||||
|
"backup", "system". Defaults to "read" for backward compatibility.
|
||||||
|
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = Field(..., description="Unique tool identifier")
|
name: str = Field(..., description="Unique tool identifier")
|
||||||
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
|
|||||||
default="read", description="Required API key scope (read/write/admin)"
|
default="read", description="Required API key scope (read/write/admin)"
|
||||||
)
|
)
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
|
category: str = Field(
|
||||||
|
default="read",
|
||||||
|
description="Tool category for scope-based visibility (F.7)",
|
||||||
|
)
|
||||||
|
sensitivity: str = Field(
|
||||||
|
default="normal",
|
||||||
|
description="Data sensitivity: normal or sensitive (F.7)",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
474
core/upload_sessions.py
Normal file
474
core/upload_sessions.py
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"""Chunked upload session store (F.5a.5).
|
||||||
|
|
||||||
|
Server-side buffering for large media uploads: SQLite metadata + disk spill.
|
||||||
|
Sessions are deterministic (same user+file metadata yields the same
|
||||||
|
session_id), enabling resumable uploads. Enforces per-user concurrency
|
||||||
|
quota, per-session byte cap, and a 1h TTL reaped by a background task.
|
||||||
|
|
||||||
|
Chunks are appended sequentially; out-of-order or duplicate indexes raise
|
||||||
|
a typed error. Optional full-payload sha256 (supplied at `start`) is
|
||||||
|
verified when the session is finalized.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.database import Database, get_database
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Config ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DEFAULT_SPILL_DIR = Path(os.environ.get("MCPHUB_UPLOAD_SPILL_DIR", "/tmp/mcphub-uploads"))
|
||||||
|
SESSION_TTL = timedelta(seconds=int(os.environ.get("MCPHUB_UPLOAD_TTL_SEC", "3600")))
|
||||||
|
MAX_SESSION_BYTES = int(os.environ.get("MCPHUB_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
|
||||||
|
MAX_CONCURRENT_PER_USER = int(os.environ.get("MCPHUB_UPLOAD_MAX_CONCURRENT", "10"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Errors ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class UploadSessionError(Exception):
|
||||||
|
"""Typed session error with stable JSON code."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, details: dict | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"error_code": self.code, "message": self.message, "details": self.details}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(dt: datetime) -> str:
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(value: str) -> datetime:
|
||||||
|
dt = datetime.fromisoformat(value)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=UTC)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_id(
|
||||||
|
user_id: str,
|
||||||
|
filename: str,
|
||||||
|
total_bytes: int,
|
||||||
|
mime: str | None,
|
||||||
|
sha256: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Deterministic session id — same tuple → same id (enables resume)."""
|
||||||
|
payload = f"{user_id}|{filename}|{total_bytes}|{mime or ''}|{sha256 or ''}"
|
||||||
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_spill_dir(spill_dir: Path) -> None:
|
||||||
|
spill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(spill_dir, 0o700)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- Store -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class UploadSession:
|
||||||
|
"""Runtime view of an upload session row."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"filename",
|
||||||
|
"total_bytes",
|
||||||
|
"mime",
|
||||||
|
"sha256",
|
||||||
|
"received_bytes",
|
||||||
|
"next_chunk",
|
||||||
|
"spill_path",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
for name in self.__slots__:
|
||||||
|
setattr(self, name, kwargs.get(name))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: dict[str, Any]) -> UploadSession:
|
||||||
|
return cls(
|
||||||
|
id=row["id"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
filename=row["filename"],
|
||||||
|
total_bytes=int(row["total_bytes"]),
|
||||||
|
mime=row["mime"],
|
||||||
|
sha256=row["sha256"],
|
||||||
|
received_bytes=int(row["received_bytes"]),
|
||||||
|
next_chunk=int(row["next_chunk"]),
|
||||||
|
spill_path=Path(row["spill_path"]),
|
||||||
|
status=row["status"],
|
||||||
|
created_at=_parse_iso(row["created_at"]),
|
||||||
|
expires_at=_parse_iso(row["expires_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_public_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_id": self.id,
|
||||||
|
"filename": self.filename,
|
||||||
|
"total_bytes": self.total_bytes,
|
||||||
|
"received_bytes": self.received_bytes,
|
||||||
|
"next_chunk": self.next_chunk,
|
||||||
|
"status": self.status,
|
||||||
|
"expires_at": _iso(self.expires_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UploadSessionStore:
|
||||||
|
"""Persistent chunk-upload session store with disk spill."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
db: Database | None = None,
|
||||||
|
spill_dir: Path | None = None,
|
||||||
|
ttl: timedelta = SESSION_TTL,
|
||||||
|
max_session_bytes: int = MAX_SESSION_BYTES,
|
||||||
|
max_concurrent_per_user: int = MAX_CONCURRENT_PER_USER,
|
||||||
|
) -> None:
|
||||||
|
self._db = db
|
||||||
|
self.spill_dir = Path(spill_dir or DEFAULT_SPILL_DIR)
|
||||||
|
self.ttl = ttl
|
||||||
|
self.max_session_bytes = max_session_bytes
|
||||||
|
self.max_concurrent_per_user = max_concurrent_per_user
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
_ensure_spill_dir(self.spill_dir)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self) -> Database:
|
||||||
|
return self._db or get_database()
|
||||||
|
|
||||||
|
# -- start -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
filename: str,
|
||||||
|
total_bytes: int,
|
||||||
|
mime: str | None = None,
|
||||||
|
sha256: str | None = None,
|
||||||
|
) -> UploadSession:
|
||||||
|
if total_bytes <= 0:
|
||||||
|
raise UploadSessionError("BAD_SIZE", "total_bytes must be positive.")
|
||||||
|
if total_bytes > self.max_session_bytes:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"SESSION_TOO_LARGE",
|
||||||
|
f"total_bytes {total_bytes} exceeds limit {self.max_session_bytes}.",
|
||||||
|
{"max": self.max_session_bytes},
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = make_session_id(user_id, filename, total_bytes, mime, sha256)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
existing = await self._get_row(session_id)
|
||||||
|
if existing is not None:
|
||||||
|
sess = UploadSession.from_row(existing)
|
||||||
|
if sess.status == "open" and sess.expires_at > _utc_now():
|
||||||
|
return sess
|
||||||
|
# Stale/finished — replace
|
||||||
|
await self._delete_row(session_id)
|
||||||
|
_unlink_silent(sess.spill_path)
|
||||||
|
|
||||||
|
open_count = await self._count_open_for_user(user_id)
|
||||||
|
if open_count >= self.max_concurrent_per_user:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"QUOTA_EXCEEDED",
|
||||||
|
f"User has {open_count} open upload sessions "
|
||||||
|
f"(max {self.max_concurrent_per_user}).",
|
||||||
|
{"open": open_count, "max": self.max_concurrent_per_user},
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
expires = now + self.ttl
|
||||||
|
spill_path = self.spill_dir / f"{session_id}.part"
|
||||||
|
# Touch an empty spill file with 0600 perms
|
||||||
|
_ensure_spill_dir(self.spill_dir)
|
||||||
|
with open(spill_path, "wb") as f:
|
||||||
|
f.truncate(0)
|
||||||
|
try:
|
||||||
|
os.chmod(spill_path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.db.execute(
|
||||||
|
"INSERT INTO upload_sessions "
|
||||||
|
"(id, user_id, filename, total_bytes, mime, sha256, "
|
||||||
|
" received_bytes, next_chunk, spill_path, status, "
|
||||||
|
" created_at, expires_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open', ?, ?)",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
filename,
|
||||||
|
total_bytes,
|
||||||
|
mime,
|
||||||
|
sha256,
|
||||||
|
str(spill_path),
|
||||||
|
_iso(now),
|
||||||
|
_iso(expires),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return UploadSession(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
filename=filename,
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
mime=mime,
|
||||||
|
sha256=sha256,
|
||||||
|
received_bytes=0,
|
||||||
|
next_chunk=0,
|
||||||
|
spill_path=spill_path,
|
||||||
|
status="open",
|
||||||
|
created_at=now,
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- append chunk ------------------------------------------------------
|
||||||
|
|
||||||
|
async def append_chunk(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
index: int,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
chunk_sha256: str | None = None,
|
||||||
|
) -> UploadSession:
|
||||||
|
async with self._lock:
|
||||||
|
sess = await self._require_open(session_id)
|
||||||
|
if index != sess.next_chunk:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"CHUNK_ORDER",
|
||||||
|
f"Expected chunk index {sess.next_chunk}, got {index}.",
|
||||||
|
{"expected": sess.next_chunk, "got": index},
|
||||||
|
)
|
||||||
|
new_size = sess.received_bytes + len(data)
|
||||||
|
if new_size > sess.total_bytes:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"CHUNK_OVERFLOW",
|
||||||
|
f"Chunk would exceed declared total_bytes "
|
||||||
|
f"({new_size} > {sess.total_bytes}).",
|
||||||
|
{"declared": sess.total_bytes, "would_be": new_size},
|
||||||
|
)
|
||||||
|
if chunk_sha256 is not None:
|
||||||
|
actual = hashlib.sha256(data).hexdigest()
|
||||||
|
if actual.lower() != chunk_sha256.lower():
|
||||||
|
raise UploadSessionError(
|
||||||
|
"CHUNK_CHECKSUM",
|
||||||
|
"Chunk sha256 does not match supplied value.",
|
||||||
|
{"expected": chunk_sha256, "actual": actual, "index": index},
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(sess.spill_path, "ab") as f:
|
||||||
|
f.write(data)
|
||||||
|
sess.received_bytes = new_size
|
||||||
|
sess.next_chunk = index + 1
|
||||||
|
await self.db.execute(
|
||||||
|
"UPDATE upload_sessions SET received_bytes = ?, next_chunk = ? WHERE id = ?",
|
||||||
|
(sess.received_bytes, sess.next_chunk, session_id),
|
||||||
|
)
|
||||||
|
return sess
|
||||||
|
|
||||||
|
# -- finalize ----------------------------------------------------------
|
||||||
|
|
||||||
|
async def finalize(self, session_id: str) -> tuple[UploadSession, bytes]:
|
||||||
|
"""Read the full spill file and verify. Returns (session, bytes).
|
||||||
|
|
||||||
|
On checksum mismatch the session is kept (status remains 'open') so
|
||||||
|
the caller can retry; on success, the row and spill file are removed.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
sess = await self._require_open(session_id)
|
||||||
|
if sess.received_bytes != sess.total_bytes:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"INCOMPLETE",
|
||||||
|
f"Received {sess.received_bytes}/{sess.total_bytes} bytes.",
|
||||||
|
{
|
||||||
|
"received": sess.received_bytes,
|
||||||
|
"total": sess.total_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with open(sess.spill_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
if sess.sha256:
|
||||||
|
actual = hashlib.sha256(data).hexdigest()
|
||||||
|
if actual.lower() != sess.sha256.lower():
|
||||||
|
raise UploadSessionError(
|
||||||
|
"CHECKSUM_MISMATCH",
|
||||||
|
"Assembled sha256 does not match value supplied at start.",
|
||||||
|
{"expected": sess.sha256, "actual": actual},
|
||||||
|
)
|
||||||
|
# Success — drop the session
|
||||||
|
await self._delete_row(session_id)
|
||||||
|
_unlink_silent(sess.spill_path)
|
||||||
|
return sess, data
|
||||||
|
|
||||||
|
# -- abort -------------------------------------------------------------
|
||||||
|
|
||||||
|
async def abort(self, session_id: str) -> bool:
|
||||||
|
async with self._lock:
|
||||||
|
row = await self._get_row(session_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
sess = UploadSession.from_row(row)
|
||||||
|
await self._delete_row(session_id)
|
||||||
|
_unlink_silent(sess.spill_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- get ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get(self, session_id: str) -> UploadSession | None:
|
||||||
|
row = await self._get_row(session_id)
|
||||||
|
return UploadSession.from_row(row) if row else None
|
||||||
|
|
||||||
|
# -- cleanup -----------------------------------------------------------
|
||||||
|
|
||||||
|
async def cleanup_expired(self, *, now: datetime | None = None) -> int:
|
||||||
|
now = now or _utc_now()
|
||||||
|
rows = await self.db.fetchall(
|
||||||
|
"SELECT * FROM upload_sessions WHERE expires_at < ?", (_iso(now),)
|
||||||
|
)
|
||||||
|
reaped = 0
|
||||||
|
for row in rows:
|
||||||
|
sess = UploadSession.from_row(row)
|
||||||
|
_unlink_silent(sess.spill_path)
|
||||||
|
await self._delete_row(sess.id)
|
||||||
|
reaped += 1
|
||||||
|
if reaped:
|
||||||
|
logger.info("Reaped %d expired upload session(s)", reaped)
|
||||||
|
return reaped
|
||||||
|
|
||||||
|
# -- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
async def _get_row(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
return await self.db.fetchone("SELECT * FROM upload_sessions WHERE id = ?", (session_id,))
|
||||||
|
|
||||||
|
async def _delete_row(self, session_id: str) -> None:
|
||||||
|
await self.db.execute("DELETE FROM upload_sessions WHERE id = ?", (session_id,))
|
||||||
|
|
||||||
|
async def _count_open_for_user(self, user_id: str) -> int:
|
||||||
|
row = await self.db.fetchone(
|
||||||
|
"SELECT COUNT(*) AS c FROM upload_sessions "
|
||||||
|
"WHERE user_id = ? AND status = 'open' AND expires_at > ?",
|
||||||
|
(user_id, _iso(_utc_now())),
|
||||||
|
)
|
||||||
|
return int(row["c"]) if row else 0
|
||||||
|
|
||||||
|
async def _require_open(self, session_id: str) -> UploadSession:
|
||||||
|
row = await self._get_row(session_id)
|
||||||
|
if row is None:
|
||||||
|
raise UploadSessionError(
|
||||||
|
"NO_SESSION", f"Session {session_id} not found.", {"session_id": session_id}
|
||||||
|
)
|
||||||
|
sess = UploadSession.from_row(row)
|
||||||
|
if sess.status != "open":
|
||||||
|
raise UploadSessionError(
|
||||||
|
"BAD_STATE",
|
||||||
|
f"Session {session_id} is in state '{sess.status}'.",
|
||||||
|
{"state": sess.status},
|
||||||
|
)
|
||||||
|
if sess.expires_at <= _utc_now():
|
||||||
|
raise UploadSessionError(
|
||||||
|
"EXPIRED", f"Session {session_id} has expired.", {"session_id": session_id}
|
||||||
|
)
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_silent(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug("Could not remove spill file %s: %s", path, e)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Singleton + background cleanup ---------------------------------------
|
||||||
|
|
||||||
|
_store: UploadSessionStore | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_session_store() -> UploadSessionStore:
|
||||||
|
global _store
|
||||||
|
if _store is None:
|
||||||
|
_store = UploadSessionStore()
|
||||||
|
return _store
|
||||||
|
|
||||||
|
|
||||||
|
def set_upload_session_store(store: UploadSessionStore | None) -> None:
|
||||||
|
"""Override the singleton (used by tests)."""
|
||||||
|
global _store
|
||||||
|
_store = store
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupTask:
|
||||||
|
"""Periodically reaps expired sessions. Register in server lifespan."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: UploadSessionStore | None = None,
|
||||||
|
interval_seconds: int = 300,
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self.interval = interval_seconds
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._stop = asyncio.Event()
|
||||||
|
self.id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store(self) -> UploadSessionStore:
|
||||||
|
return self._store or get_upload_session_store()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._task = asyncio.create_task(self._run(), name=f"upload-cleanup-{self.id}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
self._stop.set()
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
await self.store.cleanup_expired()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("Upload-session cleanup error: %s", e)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
499
core/user_auth.py
Normal file
499
core/user_auth.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
# api.github.com rejects requests without a User-Agent header and treats
|
||||||
|
# requests that omit Accept/X-GitHub-Api-Version as forward-compatibility
|
||||||
|
# violations on some endpoints (observed as 403 on /user and /user/emails
|
||||||
|
# after httpx upgraded its default UA). Keep these explicit.
|
||||||
|
_GITHUB_API_HEADERS = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"User-Agent": "mcphub-oauth-client",
|
||||||
|
}
|
||||||
|
|
||||||
|
_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}")
|
||||||
|
|
||||||
|
auth_headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
**_GITHUB_API_HEADERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch user info
|
||||||
|
user_resp = await client.get(_GITHUB_USER_URL, headers=auth_headers)
|
||||||
|
if user_resp.status_code != 200:
|
||||||
|
raise ValueError(f"GitHub /user returned {user_resp.status_code}: {user_resp.text}")
|
||||||
|
user_data = user_resp.json()
|
||||||
|
if "id" not in user_data:
|
||||||
|
raise ValueError(f"GitHub /user response missing 'id' field: {user_data}")
|
||||||
|
|
||||||
|
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=auth_headers,
|
||||||
|
)
|
||||||
|
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
|
||||||
610
core/user_endpoints.py
Normal file
610
core/user_endpoints.py
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
"""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 time
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from core.tool_registry import ToolDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# In-memory rate limit tracking: user_id -> list of timestamps
|
||||||
|
_rate_limits: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Check per-user rate limits using the live cached settings (DB > ENV > default).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, error_message). error_message is empty if allowed.
|
||||||
|
"""
|
||||||
|
from core.settings import get_cached_rate_per_hr, get_cached_rate_per_min
|
||||||
|
|
||||||
|
per_min = get_cached_rate_per_min()
|
||||||
|
per_hr = get_cached_rate_per_hr()
|
||||||
|
|
||||||
|
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 >= per_min:
|
||||||
|
return False, f"Rate limit exceeded: {per_min} requests/minute"
|
||||||
|
|
||||||
|
# Check per-hour limit
|
||||||
|
if len(timestamps) >= per_hr:
|
||||||
|
return False, f"Rate limit exceeded: {per_hr} requests/hour"
|
||||||
|
|
||||||
|
timestamps.append(now)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_to_mcp_schema(
|
||||||
|
tools: list[ToolDefinition],
|
||||||
|
*,
|
||||||
|
configured_providers: list[str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
|
||||||
|
|
||||||
|
Strips the auto-injected ``site`` parameter, since user endpoints bind a
|
||||||
|
single site per alias.
|
||||||
|
|
||||||
|
F.X.fix-pass6 — when ``configured_providers`` is given (i.e. the
|
||||||
|
site has at least one AI provider key configured), narrow the
|
||||||
|
``provider`` enum on the AI image tool to that subset. The model
|
||||||
|
only sees providers that will actually succeed, instead of trying
|
||||||
|
OpenAI / Stability / Replicate and getting NO_PROVIDER_KEY when
|
||||||
|
only OpenRouter is configured.
|
||||||
|
"""
|
||||||
|
ai_image_tools = {
|
||||||
|
"wordpress_generate_and_upload_image",
|
||||||
|
"woocommerce_generate_and_upload_image",
|
||||||
|
}
|
||||||
|
result = []
|
||||||
|
for tool_def in tools:
|
||||||
|
schema = deepcopy(tool_def.input_schema)
|
||||||
|
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"]
|
||||||
|
|
||||||
|
if (
|
||||||
|
configured_providers
|
||||||
|
and tool_def.name in ai_image_tools
|
||||||
|
and "properties" in schema
|
||||||
|
and "provider" in schema["properties"]
|
||||||
|
):
|
||||||
|
schema["properties"]["provider"] = {
|
||||||
|
**schema["properties"]["provider"],
|
||||||
|
"enum": list(configured_providers),
|
||||||
|
"description": (
|
||||||
|
"AI provider to use. This site has the following providers "
|
||||||
|
f"configured: {', '.join(configured_providers)}. Add more "
|
||||||
|
"in Connection Settings → AI Image Generation."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": tool_def.name,
|
||||||
|
"description": tool_def.description,
|
||||||
|
"inputSchema": schema,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_visible_tools_for_site(
|
||||||
|
site_id: str,
|
||||||
|
key_scopes: list[str],
|
||||||
|
plugin_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b).
|
||||||
|
|
||||||
|
F.5a.9.x: additionally hide ``wordpress_generate_and_upload_image`` when
|
||||||
|
the site has no provider API key configured — the tool would fail at
|
||||||
|
call-time with ``NO_PROVIDER_KEY`` anyway, so hiding it keeps the
|
||||||
|
surface honest for AI clients.
|
||||||
|
"""
|
||||||
|
from core.tool_access import get_tool_access_manager
|
||||||
|
|
||||||
|
access = get_tool_access_manager()
|
||||||
|
tools = await access.get_visible_tools(
|
||||||
|
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
|
||||||
|
)
|
||||||
|
|
||||||
|
configured_providers: list[str] = []
|
||||||
|
if plugin_type in {"wordpress", "woocommerce"}:
|
||||||
|
from core.site_api import list_site_providers_set
|
||||||
|
|
||||||
|
configured = await list_site_providers_set(site_id)
|
||||||
|
if not configured:
|
||||||
|
tools = [
|
||||||
|
t
|
||||||
|
for t in tools
|
||||||
|
if t.name
|
||||||
|
not in {
|
||||||
|
"wordpress_generate_and_upload_image",
|
||||||
|
"woocommerce_generate_and_upload_image",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# F.X.fix-pass6 — pass the configured set so the AI image
|
||||||
|
# tool's `provider` enum can be narrowed at /tools/list time.
|
||||||
|
configured_providers = sorted(configured)
|
||||||
|
|
||||||
|
return _tools_to_mcp_schema(tools, configured_providers=configured_providers)
|
||||||
|
|
||||||
|
|
||||||
|
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 "
|
||||||
|
|
||||||
|
# Shared scope tracking — set by whichever auth path succeeds
|
||||||
|
key_scopes: list[str] = []
|
||||||
|
|
||||||
|
# Try mhu_ API key first, then fall back to OAuth JWT token
|
||||||
|
if api_key.startswith("mhu_"):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key_info["user_id"] != user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check site-scoped key: if key is scoped to a site, it can only
|
||||||
|
# be used for that specific site's alias.
|
||||||
|
key_site_id = key_info.get("site_id")
|
||||||
|
if key_site_id:
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(key_site_id, user_id)
|
||||||
|
if site is None or site.get("alias") != alias:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
None,
|
||||||
|
-32600,
|
||||||
|
"API key is scoped to a different site",
|
||||||
|
),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # DB unavailable — allow through
|
||||||
|
|
||||||
|
key_scopes = key_info.get("scopes", "read").split()
|
||||||
|
else:
|
||||||
|
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||||
|
try:
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
|
from core.oauth import get_token_manager
|
||||||
|
|
||||||
|
token_manager = get_token_manager()
|
||||||
|
jwt_payload = token_manager.validate_access_token(api_key)
|
||||||
|
|
||||||
|
# sub = "user:{uuid}" — extract actual user_id
|
||||||
|
sub = jwt_payload.get("sub", "")
|
||||||
|
if not sub.startswith("user:"):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token not authorized for user endpoint"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
jwt_user_id = sub[len("user:") :]
|
||||||
|
|
||||||
|
if jwt_user_id != user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token user mismatch"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
key_scopes = jwt_payload.get("scope", "read").split()
|
||||||
|
except pyjwt.ExpiredSignatureError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Token expired"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid token"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Rate Limiting (admin users are exempt) ---
|
||||||
|
# Fetch the user record early so we can check the role; it's also needed
|
||||||
|
# for the plugin-visibility check below, so we hoist it here.
|
||||||
|
_db_early = None
|
||||||
|
_user_record_early: dict | None = None
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
_db_early = get_database()
|
||||||
|
_user_record_early = await _db_early.get_user_by_id(user_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_is_admin_user = False
|
||||||
|
if _user_record_early:
|
||||||
|
from core.admin_utils import is_admin_email
|
||||||
|
|
||||||
|
_is_admin_user = (_user_record_early.get("role") == "admin") or is_admin_email(
|
||||||
|
_user_record_early.get("email")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _is_admin_user:
|
||||||
|
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 = _db_early if _db_early is not None else 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Plugin visibility check (admins bypass entirely) ---
|
||||||
|
from core.plugin_visibility import is_plugin_public
|
||||||
|
|
||||||
|
if not _is_admin_user and not is_plugin_public(site["plugin_type"]):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
None,
|
||||||
|
-32600,
|
||||||
|
f"Plugin '{site['plugin_type']}' is not currently available",
|
||||||
|
),
|
||||||
|
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 = await _get_visible_tools_for_site(site["id"], key_scopes, 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 is set during authentication (both mhu_ and JWT paths)
|
||||||
|
|
||||||
|
# F.7b: enforce category-based scope allowlist in addition to the
|
||||||
|
# universal scope tier. A tool is allowed only if BOTH
|
||||||
|
# (a) the universal scope tier grants it (works for every plugin), AND
|
||||||
|
# (b) for plugins with fine-grained categories (Coolify), the
|
||||||
|
# tool's category is in BOTH the key-scope set AND the site's
|
||||||
|
# stored tool_scope set (the narrower layer wins).
|
||||||
|
from core.tool_access import (
|
||||||
|
KNOWN_CATEGORIES,
|
||||||
|
SCOPE_CUSTOM,
|
||||||
|
UNIVERSAL_SCOPE_TIERS,
|
||||||
|
scopes_to_categories,
|
||||||
|
)
|
||||||
|
|
||||||
|
# F.19.5 introduced the ``editor`` tier (between ``read`` and
|
||||||
|
# ``write``). The legacy 3-level hierarchy here ({read:1, write:2,
|
||||||
|
# admin:3}) silently dropped ``editor`` to level 0, which made
|
||||||
|
# every tool call from an editor-scope key fail "Insufficient
|
||||||
|
# scope". Use the canonical UNIVERSAL_SCOPE_TIERS map from
|
||||||
|
# tool_access so this list stays single-sourced as new tiers
|
||||||
|
# land (F.19.2 introduces ``manage`` / ``install`` next).
|
||||||
|
allowed_scopes: set[str] = set()
|
||||||
|
for s in key_scopes:
|
||||||
|
allowed_scopes |= UNIVERSAL_SCOPE_TIERS.get(s.strip(), set())
|
||||||
|
legacy_ok = required_scope in allowed_scopes
|
||||||
|
|
||||||
|
plugin_type = tool_def.plugin_type
|
||||||
|
# Category check is for plugins with fine-grained categories
|
||||||
|
# (currently Coolify only). For other plugins ``tool_def.category``
|
||||||
|
# defaults to ``"read"`` which collides with one of Coolify's
|
||||||
|
# KNOWN_CATEGORIES — running the check against non-Coolify tools
|
||||||
|
# mis-rejected wordpress_specialist + wordpress + gitea tools when
|
||||||
|
# the key scope didn't happen to map to category "read".
|
||||||
|
if plugin_type == "coolify":
|
||||||
|
key_cats = scopes_to_categories(key_scopes)
|
||||||
|
key_category_ok = (
|
||||||
|
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key_category_ok = True
|
||||||
|
|
||||||
|
# Site-level scope check (skipped for "custom" preset).
|
||||||
|
site_scope = site.get("tool_scope") or "admin"
|
||||||
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||||
|
if plugin_type == "coolify":
|
||||||
|
site_cats = scopes_to_categories([site_scope])
|
||||||
|
site_category_ok = (
|
||||||
|
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Non-Coolify plugins use the universal tier at site level
|
||||||
|
# too — site_scope is one of ``read`` / ``editor`` / ``write``
|
||||||
|
# / ``admin`` / ``custom``, same vocabulary as the key.
|
||||||
|
site_allowed = UNIVERSAL_SCOPE_TIERS.get(site_scope, set())
|
||||||
|
site_category_ok = required_scope in site_allowed
|
||||||
|
else:
|
||||||
|
site_category_ok = True
|
||||||
|
|
||||||
|
if not (legacy_ok and key_category_ok and site_category_ok):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Insufficient scope. Tool '{tool_name}' requires "
|
||||||
|
f"scope '{required_scope}' (category '{tool_def.category}').",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
|
||||||
|
# even if scopes would otherwise allow it.
|
||||||
|
try:
|
||||||
|
toggles = await db.get_site_tool_toggles(site["id"])
|
||||||
|
if not toggles.get(tool_name, True):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Tool '{tool_name}' is disabled for this site.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # non-fatal — fall through on DB errors
|
||||||
|
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# F.5a.4: pass user_id so plugins can look up per-user secrets.
|
||||||
|
# F.5a.9.x: pass site_id so the WP AI-media handler can resolve
|
||||||
|
# per-site provider API keys (replaces per-user keys for
|
||||||
|
# wordpress_generate_and_upload_image).
|
||||||
|
config_dict = {
|
||||||
|
"site_url": site["url"],
|
||||||
|
"url": site["url"],
|
||||||
|
"alias": alias,
|
||||||
|
"user_id": user_id,
|
||||||
|
"site_id": site["id"],
|
||||||
|
**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"))
|
||||||
260
core/user_keys.py
Normal file
260
core/user_keys.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""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, site_id, cached_at)
|
||||||
|
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
|
||||||
|
|
||||||
|
async def create_key(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
scopes: str = "read editor settings install write admin",
|
||||||
|
expires_in_days: int | None = None,
|
||||||
|
site_id: str | 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: Access scopes. Default enumerates every tier explicitly
|
||||||
|
(read / editor / settings / install / write / admin) so the
|
||||||
|
key matches the per-site Tool Access dropdown 1:1, even if
|
||||||
|
``UNIVERSAL_SCOPE_TIERS`` is reorganised in a later phase.
|
||||||
|
The binding access filter for any tool call is the
|
||||||
|
**intersection** of these scopes with the per-site
|
||||||
|
``tool_scope`` preset — narrowing happens per-site, not
|
||||||
|
per-key, so a single key works for every site the user owns.
|
||||||
|
expires_in_days: Optional expiry in days from now. None = never.
|
||||||
|
site_id: Optional site UUID to scope key to a single site.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||||
|
``scopes``, ``created_at``, ``expires_at``, ``site_id``.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
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,
|
||||||
|
site_id=site_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_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"],
|
||||||
|
"site_id": row.get("site_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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, site_id, 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, "site_id": site_id}
|
||||||
|
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
|
||||||
|
site_id = row.get("site_id")
|
||||||
|
self._cache[api_key] = (
|
||||||
|
row["id"],
|
||||||
|
row["user_id"],
|
||||||
|
row["scopes"],
|
||||||
|
site_id,
|
||||||
|
time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key_id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"scopes": row["scopes"],
|
||||||
|
"site_id": site_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
94
docker-compose.coolify.slim.yaml
Normal file
94
docker-compose.coolify.slim.yaml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# ===================================
|
||||||
|
# MCP Hub — Docker Compose (Coolify, Debian-slim Plan-B variant)
|
||||||
|
# ===================================
|
||||||
|
#
|
||||||
|
# Plan B for restrictive networks. Use this when even
|
||||||
|
# docker-compose.coolify.mirror.yaml hangs at `apk add` because the build
|
||||||
|
# host cannot reach Alpine's package CDN (dl-cdn.alpinelinux.org). This
|
||||||
|
# variant uses Debian slim, whose apt mirrors are CDN-fronted and usually
|
||||||
|
# reachable from networks that block Alpine's CDN.
|
||||||
|
#
|
||||||
|
# WHEN TO USE:
|
||||||
|
# - registry-1.docker.io fails AND
|
||||||
|
# - Alpine apk repos (even via a Yandex mirror swap) hang or fail
|
||||||
|
#
|
||||||
|
# WHEN TO STOP USING:
|
||||||
|
# - Once one of the smaller images works reliably (Alpine = smaller)
|
||||||
|
# - Image bytes change (slim ≠ alpine), so the persistent volumes
|
||||||
|
# (mcp-data, mcp-logs) carry over but the build cache resets.
|
||||||
|
#
|
||||||
|
# OPTIONAL HTTP PROXY (Coolify build-args): same as the .mirror variant —
|
||||||
|
# BUILD_HTTP_PROXY=http://proxy.host:port
|
||||||
|
# BUILD_HTTPS_PROXY=http://proxy.host:port
|
||||||
|
# BUILD_NO_PROXY=localhost,127.0.0.1,mirror.gcr.io
|
||||||
|
# Used only inside RUN (apt + pip), never baked into the runtime image.
|
||||||
|
#
|
||||||
|
# Source mirror note (2026-04-16):
|
||||||
|
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
|
||||||
|
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
|
||||||
|
#
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.slim
|
||||||
|
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}
|
||||||
|
|
||||||
|
# === ADMIN SYSTEM (F.4) ===
|
||||||
|
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
|
||||||
|
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
|
||||||
|
|
||||||
|
# === 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}
|
||||||
|
|
||||||
|
# === SITES ===
|
||||||
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
|
# After deployment, open the dashboard to add sites.
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
|
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
|
||||||
93
docker-compose.coolify.yaml
Normal file
93
docker-compose.coolify.yaml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ===================================
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# Source mirror note (2026-04-16):
|
||||||
|
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
|
||||||
|
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
|
||||||
|
#
|
||||||
|
# If Coolify cannot reach api.github.com (e.g. cURL error 28 on the
|
||||||
|
# api.github.com/zen health probe), point the Coolify Source at the
|
||||||
|
# Gitea mirror instead. Revert the source back to GitHub once the
|
||||||
|
# GitHub App connection is restored.
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
# === ADMIN SYSTEM (F.4) ===
|
||||||
|
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
|
||||||
|
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
|
||||||
|
|
||||||
|
# === 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}
|
||||||
|
|
||||||
|
# === ANALYTICS (dashboard SPA) ===
|
||||||
|
# All four are optional — leave unset to disable that provider's tag.
|
||||||
|
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID:-}
|
||||||
|
- UMAMI_URL=${UMAMI_URL:-}
|
||||||
|
- OPENPANEL_API_URL=${OPENPANEL_API_URL:-}
|
||||||
|
- OPENPANEL_CLIENT_ID=${OPENPANEL_CLIENT_ID:-}
|
||||||
|
|
||||||
|
# === SITES ===
|
||||||
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
|
# After deployment, open the dashboard to add sites.
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
|
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,16 +1,14 @@
|
|||||||
# ===================================
|
# ===================================
|
||||||
# MCP Hub — Docker Compose Configuration
|
# MCP Hub — Docker Compose (Standalone)
|
||||||
# ===================================
|
# ===================================
|
||||||
#
|
#
|
||||||
# After starting:
|
# Usage:
|
||||||
|
# cp env.example .env # edit with your values
|
||||||
# docker compose up -d
|
# docker compose up -d
|
||||||
# curl http://localhost:8000/health # verify server is running
|
# curl http://localhost:8000/health
|
||||||
# open http://localhost:8000/dashboard # web dashboard
|
# open http://localhost:8000/dashboard
|
||||||
#
|
#
|
||||||
# Port mapping:
|
# For Coolify deployment, use docker-compose.coolify.yaml instead.
|
||||||
# - Standalone Docker: ports "8000:8000" works out-of-the-box.
|
|
||||||
# - Coolify: Comment out or remove the 'ports' section below.
|
|
||||||
# Coolify's reverse proxy handles routing — 'expose' is sufficient.
|
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -18,58 +16,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: airano/mcphub:latest
|
||||||
container_name: mcphub
|
container_name: mcphub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
# Coolify users: Remove the 'ports' section above.
|
env_file:
|
||||||
# Coolify's reverse proxy routes traffic to the container's exposed port.
|
- .env
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
environment:
|
environment:
|
||||||
# Master API key
|
|
||||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# === OAuth 2.1 (OPTIONAL) ===
|
|
||||||
# Only needed for ChatGPT auto-registration or third-party OAuth clients.
|
|
||||||
# For Claude Desktop/Code/Cursor with Bearer token auth, skip these entirely.
|
|
||||||
# - OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
|
|
||||||
# - OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
|
||||||
# - 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}
|
|
||||||
|
|
||||||
# === 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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -77,31 +36,20 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Docker socket access for WP-CLI tools
|
|
||||||
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
|
|
||||||
volumes:
|
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
|
- /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
|
# Docker socket GID — adjust to match your host's docker group
|
||||||
# The GID varies by host (988, 999, 133 are common)
|
# Check with: getent group docker | cut -d: -f3
|
||||||
# Coolify typically uses 988
|
|
||||||
group_add:
|
group_add:
|
||||||
- "988" # Docker group GID (adjust if needed)
|
- "999"
|
||||||
|
|
||||||
# Network - Coolify will handle this
|
|
||||||
# networks:
|
|
||||||
# - coolify
|
|
||||||
|
|
||||||
# Named volumes for persistent data
|
|
||||||
volumes:
|
volumes:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
driver: local
|
driver: local
|
||||||
mcp-logs:
|
mcp-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# Networks managed by Coolify
|
|
||||||
# networks:
|
|
||||||
# coolify:
|
|
||||||
# external: true
|
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ For each WordPress site you want to manage:
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Option 0: Use Hosted Version (No Setup Required)
|
||||||
|
|
||||||
|
The fastest way to try MCP Hub — no installation needed:
|
||||||
|
|
||||||
|
1. Visit **[mcp.example.com](https://mcp.example.com)**
|
||||||
|
2. Log in with **GitHub** or **Google**
|
||||||
|
3. Add your sites via the dashboard (**My Sites → Add Service**)
|
||||||
|
4. Go to **Connect** page to generate your AI client config
|
||||||
|
5. Copy-paste into Claude Desktop, VS Code, or Claude Code
|
||||||
|
|
||||||
|
Your personal MCP endpoint:
|
||||||
|
```
|
||||||
|
https://mcp.example.com/u/{your-user-id}/{alias}/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.
|
||||||
|
|
||||||
|
> If you prefer self-hosting, continue with the options below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Option 1: Docker (Recommended)
|
### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,6 +183,21 @@ GITEA_REPO1_ALIAS=mygitea
|
|||||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
OAUTH_BASE_URL=https://your-server:8000
|
OAUTH_BASE_URL=https://your-server:8000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Social Login (GitHub/Google OAuth — for hosted/multi-user)
|
||||||
|
# ============================================
|
||||||
|
GITHUB_CLIENT_ID=your-github-oauth-app-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-oauth-app-secret
|
||||||
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
PUBLIC_URL=https://your-server:8000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Encryption (required for storing user site credentials)
|
||||||
|
# ============================================
|
||||||
|
# Generate: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
ENCRYPTION_KEY=your-base64-encoded-32-byte-key
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Optional
|
# Optional
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -179,12 +215,13 @@ Some tools require specific WordPress plugins or infrastructure:
|
|||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `wordpress_get_post_seo`, `wordpress_update_post_seo`, `wordpress_get_product_seo`, `wordpress_update_product_seo` | **Yoast SEO** or **RankMath** |
|
| `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_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 |
|
| `wordpress_specialist_*` (51 tools — plugins, themes, users, options, cron, maintenance, page editing, site config + layout, db inspection, bulk fan-out) | **Airano MCP Bridge v2.18.0+** companion plugin (no Docker socket) |
|
||||||
| `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) |
|
| `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) |
|
||||||
|
|
||||||
### Docker Socket for WP-CLI / WordPress Advanced
|
### Docker Socket for WP-CLI
|
||||||
|
|
||||||
WP-CLI and WordPress Advanced system tools require Docker socket access:
|
A subset of legacy `wordpress_*` tools (15 WP-CLI helpers under
|
||||||
|
`wordpress_wp_*`) require Docker socket access:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -200,24 +237,23 @@ services:
|
|||||||
WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
|
WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
|
||||||
WORDPRESS_SITE1_CONTAINER: wordpress-container-name
|
WORDPRESS_SITE1_CONTAINER: wordpress-container-name
|
||||||
WORDPRESS_SITE1_ALIAS: mysite
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`wordpress_specialist_*` tools do **not** need Docker — they're served
|
||||||
|
through the Airano MCP Bridge companion plugin via REST. Install
|
||||||
|
`wordpress-plugin/airano-mcp-bridge.zip` on the WordPress site and use
|
||||||
|
an Application Password for a `manage_options` user.
|
||||||
|
|
||||||
Without Docker socket:
|
Without Docker socket:
|
||||||
- WP-CLI tools return "not available" errors
|
- WP-CLI helpers (`wordpress_wp_*`) return "not available" errors
|
||||||
- WordPress Advanced database/system tools are unavailable
|
- All REST API tools (bulk operations, content management,
|
||||||
- All REST API tools (bulk operations, content management) work normally
|
`wordpress_specialist_*`) work normally
|
||||||
|
|
||||||
### Environment Variable Reference
|
### Site Configuration
|
||||||
|
|
||||||
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
|
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
|
||||||
|
|
||||||
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
|
Each plugin requires specific credentials (see below for reference).
|
||||||
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
|
|
||||||
|
|
||||||
#### WordPress
|
#### WordPress
|
||||||
```env
|
```env
|
||||||
@@ -274,21 +310,6 @@ OPENPANEL_SITE1_CLIENT_SECRET=your-client-secret # Required
|
|||||||
OPENPANEL_SITE1_ALIAS=my-analytics # Recommended
|
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`.
|
> **Note:** Use `APP_PASSWORD` (WordPress Application Password), not `PASSWORD`.
|
||||||
|
|
||||||
### Configuration Tips
|
### Configuration Tips
|
||||||
@@ -322,18 +343,20 @@ After starting (via Docker or locally), wait ~30 seconds for the server to initi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
# Expected: {"status": "ok", "tools_loaded": 596, ...}
|
# Expected: {"status": "ok", "tools_loaded": 565, ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Open the web dashboard:**
|
**2. Open the web dashboard:**
|
||||||
|
|
||||||
Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY`.
|
Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
|
||||||
|
|
||||||
The dashboard lets you:
|
The dashboard lets you:
|
||||||
- View all connected sites and their health status
|
- View all connected sites and their health status
|
||||||
|
- **Add, edit, delete, and test sites** via the My Sites page (OAuth users)
|
||||||
- Create and manage per-project API keys
|
- Create and manage per-project API keys
|
||||||
- View audit logs
|
- **Generate AI client configs** on the Connect page (with correct transport type per client)
|
||||||
- Monitor rate limits
|
- View audit logs and monitor rate limits
|
||||||
|
- **Per-user MCP endpoints**: OAuth users get personal endpoints at `/u/{user_id}/{alias}/mcp` with `mhu_` API keys
|
||||||
|
|
||||||
**3. Check container status (Docker only):**
|
**3. Check container status (Docker only):**
|
||||||
|
|
||||||
@@ -354,7 +377,7 @@ docker compose logs -f mcphub
|
|||||||
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
|
| 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 |
|
| 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` |
|
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
|
||||||
| Sites not showing up | Restart after adding new env vars: `docker compose restart` |
|
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -444,7 +467,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
|
|
||||||
## Using MCP Tools
|
## Using MCP Tools
|
||||||
|
|
||||||
### 596 Tools Across 9 Plugins
|
### 565 Tools Across 9 Plugins
|
||||||
|
|
||||||
| Plugin | Tools | Env Prefix |
|
| Plugin | Tools | Env Prefix |
|
||||||
|--------|-------|------------|
|
|--------|-------|------------|
|
||||||
@@ -454,7 +477,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
| Gitea | 56 | `GITEA_` |
|
| Gitea | 56 | `GITEA_` |
|
||||||
| n8n | 56 | `N8N_` |
|
| n8n | 56 | `N8N_` |
|
||||||
| Supabase | 70 | `SUPABASE_` |
|
| Supabase | 70 | `SUPABASE_` |
|
||||||
| OpenPanel | 73 | `OPENPANEL_` |
|
| OpenPanel | 42 | `OPENPANEL_` |
|
||||||
| Appwrite | 100 | `APPWRITE_` |
|
| Appwrite | 100 | `APPWRITE_` |
|
||||||
| Directus | 100 | `DIRECTUS_` |
|
| Directus | 100 | `DIRECTUS_` |
|
||||||
| System | 24 | (no config needed) |
|
| System | 24 | (no config needed) |
|
||||||
@@ -481,9 +504,9 @@ Use the most specific endpoint for your use case to minimize token usage:
|
|||||||
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
||||||
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
||||||
| `/system/mcp` | System administration | 24 | N/A |
|
| `/system/mcp` | System administration | 24 | N/A |
|
||||||
| `/mcp` | Admin & discovery only | 596 | Yes |
|
| `/mcp` | Admin & discovery only | 565 | Yes |
|
||||||
|
|
||||||
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (596 tools) wastes context tokens and degrades AI response quality.
|
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (565 tools) wastes context tokens and degrades AI response quality.
|
||||||
|
|
||||||
**Available plugin endpoints:**
|
**Available plugin endpoints:**
|
||||||
|
|
||||||
@@ -491,13 +514,12 @@ Use the most specific endpoint for your use case to minimize token usage:
|
|||||||
|----------|--------|------:|
|
|----------|--------|------:|
|
||||||
| `/wordpress/mcp` | WordPress | 67 |
|
| `/wordpress/mcp` | WordPress | 67 |
|
||||||
| `/woocommerce/mcp` | WooCommerce | 28 |
|
| `/woocommerce/mcp` | WooCommerce | 28 |
|
||||||
| `/wordpress-advanced/mcp` | WordPress Advanced | 22 |
|
| `/wordpress-specialist/mcp` | WordPress Specialist | 51 |
|
||||||
| `/gitea/mcp` | Gitea | 56 |
|
| `/gitea/mcp` | Gitea | 56 |
|
||||||
| `/n8n/mcp` | n8n | 56 |
|
| `/n8n/mcp` | n8n | 56 |
|
||||||
| `/supabase/mcp` | Supabase | 70 |
|
| `/supabase/mcp` | Supabase | 70 |
|
||||||
| `/openpanel/mcp` | OpenPanel | 73 |
|
| `/openpanel/mcp` | OpenPanel | 42 |
|
||||||
| `/appwrite/mcp` | Appwrite | 100 |
|
| `/coolify/mcp` | Coolify | ~65 |
|
||||||
| `/directus/mcp` | Directus | 100 |
|
|
||||||
| `/system/mcp` | System Management | 24 |
|
| `/system/mcp` | System Management | 24 |
|
||||||
|
|
||||||
**Plugin endpoint vs Project endpoint:**
|
**Plugin endpoint vs Project endpoint:**
|
||||||
@@ -572,18 +594,16 @@ docker compose up --build -d
|
|||||||
|
|
||||||
### Step 3: Configure Environment Variables
|
### Step 3: Configure Environment Variables
|
||||||
|
|
||||||
Add all required environment variables in Coolify's environment variable UI:
|
Add the required environment variables in Coolify's environment variable UI:
|
||||||
|
|
||||||
```
|
```
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
OAUTH_BASE_URL=https://your-domain.com
|
OAUTH_BASE_URL=https://your-domain.com
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
ENCRYPTION_KEY=your-base64-encryption-key
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
|
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
|
||||||
|
|
||||||
### Step 4: Configure Health Check
|
### Step 4: Configure Health Check
|
||||||
|
|
||||||
@@ -603,7 +623,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
1. **Explore the full tool list**: See the [README](../README.md) for all 596 tools
|
1. **Explore the full tool list**: See the [README](../README.md) for all 565 tools
|
||||||
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
|
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
|
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
|
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
||||||
|
|||||||
97
docs/media-error-codes.md
Normal file
97
docs/media-error-codes.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Media Upload Error Codes (F.5a.6.2)
|
||||||
|
|
||||||
|
All media-upload tools return structured JSON errors shaped as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error_code": "TOO_LARGE",
|
||||||
|
"message": "File is 12345678 bytes; limit is 10485760 bytes ...",
|
||||||
|
"details": { "size": 12345678, "max": 10485760 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `error_code` values below are **stable**: renaming or removing one is
|
||||||
|
a breaking change. The source of truth is
|
||||||
|
[`core/media_error_codes.py`](../core/media_error_codes.py) (the set
|
||||||
|
`MEDIA_ERROR_CODES`) and the stability test in
|
||||||
|
[`tests/plugins/wordpress/test_media_error_taxonomy.py`](../tests/plugins/wordpress/test_media_error_taxonomy.py).
|
||||||
|
|
||||||
|
## Input / validation
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ----------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| `BAD_BASE64` | The supplied base64 payload (full upload or single chunk) fails to decode. |
|
||||||
|
| `BAD_MODE` | `mode` argument to attach tools is not `append` or `replace`. |
|
||||||
|
| `BAD_ROLE` | `role` argument to attach tools is not `main` or `gallery`. |
|
||||||
|
| `BAD_SIZE` | Chunked `total_bytes` is `<= 0`. |
|
||||||
|
| `BAD_SOURCE` | Attach-upload helper given a `source` other than `base64` / `url`. |
|
||||||
|
| `EMPTY_FILE` | Decoded payload is zero bytes. |
|
||||||
|
| `MEDIA_NOT_FOUND` | A supplied `media_id` does not exist in the WP media library. |
|
||||||
|
| `MIME_REJECTED` | Sniffed MIME is not in the allow-list (`ALLOWED_MIMES`). |
|
||||||
|
| `MISSING_FIELD` | A required field is missing (e.g. `data` for base64, `url` for URL sideload). |
|
||||||
|
| `SSRF` | URL resolves to private/loopback/link-local/metadata IP or is on the host blocklist. |
|
||||||
|
| `TOO_LARGE` | Payload exceeds `WP_MEDIA_MAX_MB` / streamed download exceeds the byte cap. |
|
||||||
|
| `URL_FETCH_FAILED`| Remote URL returned `>= 400` while downloading. |
|
||||||
|
|
||||||
|
## WordPress REST upstream
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ----------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| `WP_413` | WordPress rejected the upload with HTTP 413 (server `upload_max_filesize` too low). |
|
||||||
|
| `WP_AUTH` | WordPress rejected auth (401/403). Application Password likely invalid/expired. |
|
||||||
|
| `WP_BAD_RESPONSE` | Upload accepted but WP returned a non-JSON body. |
|
||||||
|
| `WP_CREDENTIALS_MISSING` | (WC sites only) Tool needs a WP Application Password to hit `/wp/v2/media`. WC Consumer Key + Secret do not authenticate the WP core REST. Add `wp_username` + `wp_app_password` in Connection Settings → advanced. |
|
||||||
|
| `WP_<status>` | Any other non-2xx status from WP — e.g. `WP_400`, `WP_500`. Dynamic. |
|
||||||
|
|
||||||
|
## Companion plugin `upload-chunk` route (F.5a.7)
|
||||||
|
|
||||||
|
These only fire when MCPHub chose the companion `/airano-mcp/v1/upload-chunk` route
|
||||||
|
(because the probe advertises the helper and the payload exceeds `upload_max_filesize`).
|
||||||
|
A companion failure is **non-fatal**: MCPHub falls back to the standard `/wp/v2/media`
|
||||||
|
route on any error here, so these codes usually surface in logs, not to end users.
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ------------------------ | --------------------------------------------------------------------- |
|
||||||
|
| `COMPANION_BAD_RESPONSE` | Companion route returned 2xx but the body was not parseable JSON. |
|
||||||
|
| `COMPANION_<status>` | Any non-2xx status from the companion route — e.g. `COMPANION_500`. |
|
||||||
|
|
||||||
|
## Chunked upload session
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `BAD_STATE` | Session exists but is not in `open` state (already finalized/aborted). |
|
||||||
|
| `CHECKSUM_MISMATCH` | Assembled sha256 does not match value supplied at `start`. |
|
||||||
|
| `CHUNK_CHECKSUM` | Per-chunk sha256 does not match the value supplied with the chunk. |
|
||||||
|
| `CHUNK_ORDER` | Chunk index does not match `next_chunk`. |
|
||||||
|
| `CHUNK_OVERFLOW` | Appending this chunk would exceed declared `total_bytes`. |
|
||||||
|
| `EXPIRED` | Session's TTL has elapsed. |
|
||||||
|
| `INCOMPLETE` | Finalize called before all declared bytes arrived. |
|
||||||
|
| `NO_SESSION` | `session_id` is unknown. |
|
||||||
|
| `QUOTA_EXCEEDED` | User already has `MCPHUB_UPLOAD_MAX_CONCURRENT` open sessions. |
|
||||||
|
| `SESSION_TOO_LARGE` | Declared `total_bytes` exceeds the hard session cap (default 500 MB). |
|
||||||
|
|
||||||
|
## AI generation providers
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `GENERATION_FAILED` | Generic provider failure not covered by a more specific code. |
|
||||||
|
| `NO_PROVIDER_KEY` | No per-user key stored and no env fallback for the selected provider. |
|
||||||
|
| `PROVIDER_AUTH` | Provider rejected the supplied API key. |
|
||||||
|
| `PROVIDER_BAD_REQUEST` | Provider returned 4xx — prompt or size was invalid. |
|
||||||
|
| `PROVIDER_BAD_RESPONSE` | Provider returned a 2xx with an unexpected shape. |
|
||||||
|
| `PROVIDER_QUOTA` | Provider returned 429 / quota error. |
|
||||||
|
| `PROVIDER_TIMEOUT` | Provider timed out. |
|
||||||
|
| `PROVIDER_UNAVAILABLE` | Provider returned 5xx / circuit open. |
|
||||||
|
| `PROVIDER_UNKNOWN` | `provider` argument is not one of the registered providers. |
|
||||||
|
|
||||||
|
## Rate / policy
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| -------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `TOOL_RATE_LIMITED` | Per-tool, per-user cap exceeded (see `core/tool_rate_limiter.py`). |
|
||||||
|
|
||||||
|
## Catchall
|
||||||
|
|
||||||
|
| Code | When it fires |
|
||||||
|
| ---------- | ---------------------------------------------------------------------------------- |
|
||||||
|
| `INTERNAL` | Unexpected exception reached the top of a tool handler. Details in `message`. |
|
||||||
@@ -75,12 +75,11 @@ SUPABASE_SITE1_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
SUPABASE_SITE1_ALIAS=mysupabase
|
SUPABASE_SITE1_ALIAS=mysupabase
|
||||||
|
|
||||||
# Optional: Direct database access for postgres-meta
|
# Optional: Direct postgres-meta URL (when /pg/ is not exposed via Kong)
|
||||||
SUPABASE_SITE1_DB_HOST=db.supabase.example.com
|
# SUPABASE_SITE1_META_URL=http://supabase-meta:8080
|
||||||
SUPABASE_SITE1_DB_PORT=5432
|
#
|
||||||
SUPABASE_SITE1_DB_NAME=postgres
|
# Optional: Basic Auth for postgres-meta (when exposed via public URL)
|
||||||
SUPABASE_SITE1_DB_USER=postgres
|
# SUPABASE_SITE1_META_AUTH=admin:secretpassword
|
||||||
SUPABASE_SITE1_DB_PASSWORD=your-db-password
|
|
||||||
|
|
||||||
# Multiple Instances
|
# Multiple Instances
|
||||||
SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
||||||
@@ -704,6 +703,40 @@ EndpointType.SUPABASE: EndpointConfig(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security: postgres-meta
|
||||||
|
|
||||||
|
**postgres-meta has NO built-in authentication.** It is designed as an internal service
|
||||||
|
behind Kong API Gateway. When accessed through Kong's `/pg/` route, Kong validates JWT tokens.
|
||||||
|
|
||||||
|
### Risk: Public Exposure
|
||||||
|
|
||||||
|
If postgres-meta is exposed via a public URL (e.g., on Coolify with a public domain),
|
||||||
|
**anyone with the URL can execute SQL as `supabase_admin`**.
|
||||||
|
|
||||||
|
### Deployment Scenarios
|
||||||
|
|
||||||
|
| Deployment | meta_url needed? | meta_auth needed? | Notes |
|
||||||
|
|-----------|:---:|:---:|-------|
|
||||||
|
| supabase.com cloud | N/A | N/A | postgres-meta tools disabled |
|
||||||
|
| Docker Compose (standard) | No | No | Kong handles JWT via `/pg/` |
|
||||||
|
| Docker Compose (no /pg/) | Yes (internal URL) | No | Internal network is secure |
|
||||||
|
| Coolify (internal URL) | Yes | No | Internal Docker network |
|
||||||
|
| Coolify (public domain) | Yes | **Yes** | Exposed without auth |
|
||||||
|
| MCP Hub hosted users | Yes (public URL) | **Yes** | Must use public URL |
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
|
||||||
|
1. **Prefer internal Docker network URLs** (`http://supabase-meta:8080`) when possible
|
||||||
|
2. **If public URL required**: Configure Basic Auth via reverse proxy and set `META_AUTH`
|
||||||
|
3. **Never expose postgres-meta publicly without authentication**
|
||||||
|
|
||||||
|
### MCP Hub Support
|
||||||
|
|
||||||
|
MCP Hub supports `META_AUTH` (format: `username:password`) for Basic Auth with postgres-meta.
|
||||||
|
When configured, requests to postgres-meta use `Authorization: Basic ...` instead of JWT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
||||||
|
|||||||
161
env.example
161
env.example
@@ -2,7 +2,7 @@
|
|||||||
# MCP Hub — Environment Configuration
|
# MCP Hub — Environment Configuration
|
||||||
# ===================================
|
# ===================================
|
||||||
#
|
#
|
||||||
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
|
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
|
||||||
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||||
#
|
#
|
||||||
# After editing, run:
|
# After editing, run:
|
||||||
@@ -19,99 +19,53 @@
|
|||||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
MASTER_API_KEY=your-secure-key-here
|
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
|
# PLUGIN VISIBILITY (Track F.1)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
# Comma-separated list of plugins visible to public (OAuth) users.
|
||||||
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
|
# Admin users always see all plugins regardless of this setting.
|
||||||
|
# Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea
|
||||||
|
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea
|
||||||
#
|
#
|
||||||
# How to create a WordPress Application Password:
|
# Plugin notes:
|
||||||
# 1. Log into WordPress admin → Users → Your Profile
|
# wordpress_specialist — F.19 specialist tool surface (51 tools across
|
||||||
# 2. Scroll to "Application Passwords" section
|
# read / editor / settings / install / admin tiers). Companion-
|
||||||
# 3. Enter name "MCP Hub" → click "Add New"
|
# backed (Airano MCP Bridge v2.18.0+ on the WP side). No Docker
|
||||||
# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx)
|
# socket needed. Currently admin-only; will become public with
|
||||||
|
# a tier system in F.19.4 (companion v3.0 wp.org republish).
|
||||||
WORDPRESS_SITE1_URL=https://your-site.com
|
# Replaced the deprecated wordpress_advanced plugin (sunset
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
# 2026-05-04 in F.19.3.2-.3).
|
||||||
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)
|
# ADMIN SYSTEM (Track F.4)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY}
|
# Comma-separated list of emails that get admin role on OAuth login.
|
||||||
# Provides database operations, bulk operations, and system management.
|
# These users see the full admin dashboard (projects, API keys, health, etc.)
|
||||||
# REQUIRES Docker socket AND container name — all tools use WP-CLI.
|
# If not set, OAuth users only get the standard user dashboard.
|
||||||
|
# ADMIN_EMAILS=admin@example.com,boss@company.com
|
||||||
|
|
||||||
|
# Disable Master Key login to the web dashboard (default: false).
|
||||||
|
# When true, only OAuth users (with admin email) can access admin dashboard.
|
||||||
|
# Master Key still works for MCP endpoints — only dashboard login is affected.
|
||||||
|
# DISABLE_MASTER_KEY_LOGIN=false
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SITES
|
||||||
|
# ============================================
|
||||||
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
|
# After starting the server, open the dashboard to add sites:
|
||||||
|
# http://localhost:8000/dashboard
|
||||||
#
|
#
|
||||||
# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED.
|
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
|
||||||
# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com
|
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
|
||||||
# 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)
|
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||||
@@ -122,6 +76,36 @@ WORDPRESS_SITE1_ALIAS=mysite
|
|||||||
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
# OAUTH_BASE_URL=https://your-public-domain.com
|
# 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
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# COOLIFY (admin-only — F.17)
|
||||||
|
# ============================================
|
||||||
|
# Coolify instance for AI-driven deployment management.
|
||||||
|
# Create an API token in Coolify: Keys & Tokens → API tokens
|
||||||
|
# COOLIFY_URL=https://coolify.example.com
|
||||||
|
# COOLIFY_TOKEN=your-api-token-here
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ADVANCED (optional — defaults are fine)
|
# ADVANCED (optional — defaults are fine)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -129,6 +113,9 @@ WORDPRESS_SITE1_ALIAS=mysite
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Database path (default: data/mcphub.db)
|
||||||
|
# DATABASE_PATH=/app/data/mcphub.db
|
||||||
|
|
||||||
# Rate limits (per client)
|
# Rate limits (per client)
|
||||||
# RATE_LIMIT_PER_MINUTE=60
|
# RATE_LIMIT_PER_MINUTE=60
|
||||||
# RATE_LIMIT_PER_HOUR=1000
|
# RATE_LIMIT_PER_HOUR=1000
|
||||||
|
|||||||
36
glama.json
Normal file
36
glama.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||||
|
"maintainers": ["airano-ir"],
|
||||||
|
"name": "mcphub",
|
||||||
|
"display_name": "MCP Hub",
|
||||||
|
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 567 tools, multi-site support, and OAuth 2.1 authentication.",
|
||||||
|
"repository": "https://github.com/airano-ir/mcphub",
|
||||||
|
"homepage": "https://mcp.palebluedot.live",
|
||||||
|
"version": "3.6.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"runtime": "python",
|
||||||
|
"transport": ["stdio", "streamable-http"],
|
||||||
|
"categories": ["developer-tools", "cms", "databases", "automation"],
|
||||||
|
"keywords": ["mcp", "wordpress", "woocommerce", "supabase", "gitea", "n8n", "openpanel", "appwrite", "directus", "self-hosted", "multi-site"],
|
||||||
|
"author": {
|
||||||
|
"name": "airano-ir",
|
||||||
|
"url": "https://github.com/airano-ir"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"tools": true,
|
||||||
|
"prompts": false,
|
||||||
|
"resources": false
|
||||||
|
},
|
||||||
|
"relatedServers": [
|
||||||
|
{
|
||||||
|
"name": "WordPress MCP",
|
||||||
|
"url": "https://glama.ai/mcp/servers/developer-in-world/wordpress-mcp",
|
||||||
|
"description": "WordPress REST API MCP server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Supabase MCP",
|
||||||
|
"url": "https://glama.ai/mcp/servers/supabase-community/supabase-mcp",
|
||||||
|
"description": "Official Supabase MCP server"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,36 +3,45 @@ Plugins package
|
|||||||
|
|
||||||
All project type plugins are here.
|
All project type plugins are here.
|
||||||
|
|
||||||
|
F.19.3.2-.3 (2026-05-04): wordpress_advanced sunset — the deprecated
|
||||||
|
Docker-socket / WP-CLI plugin was removed once
|
||||||
|
wordpress_specialist absorbed db inspection +
|
||||||
|
bulk fan-out (companion v2.18.0).
|
||||||
|
F.19.1 (2026-05-01): WordPress Specialist Plugin added (companion-backed,
|
||||||
|
no Docker socket).
|
||||||
v2.8.0 (Phase J): Directus CMS Plugin added
|
v2.8.0 (Phase J): Directus CMS Plugin added
|
||||||
v2.6.0 (Phase I): Appwrite Backend Plugin added
|
v2.6.0 (Phase I): Appwrite Backend Plugin added
|
||||||
v2.5.0 (Phase H): OpenPanel Analytics Plugin added
|
v2.5.0 (Phase H): OpenPanel Analytics Plugin added
|
||||||
v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
|
v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
|
||||||
|
|
||||||
|
Note: Appwrite and Directus plugins are retained in the codebase but are
|
||||||
|
no longer registered by default. They require review and testing before
|
||||||
|
re-enabling. To restore them, add their imports and registry.register()
|
||||||
|
calls below.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from plugins.appwrite.plugin import AppwritePlugin
|
|
||||||
from plugins.base import BasePlugin, PluginRegistry
|
from plugins.base import BasePlugin, PluginRegistry
|
||||||
from plugins.directus.plugin import DirectusPlugin
|
from plugins.coolify.plugin import CoolifyPlugin
|
||||||
from plugins.gitea.plugin import GiteaPlugin
|
from plugins.gitea.plugin import GiteaPlugin
|
||||||
from plugins.n8n.plugin import N8nPlugin
|
from plugins.n8n.plugin import N8nPlugin
|
||||||
from plugins.openpanel.plugin import OpenPanelPlugin
|
from plugins.openpanel.plugin import OpenPanelPlugin
|
||||||
from plugins.supabase.plugin import SupabasePlugin
|
from plugins.supabase.plugin import SupabasePlugin
|
||||||
from plugins.woocommerce.plugin import WooCommercePlugin
|
from plugins.woocommerce.plugin import WooCommercePlugin
|
||||||
from plugins.wordpress.plugin import WordPressPlugin
|
from plugins.wordpress.plugin import WordPressPlugin
|
||||||
from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin
|
from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
|
||||||
|
|
||||||
# Create global registry
|
# Create global registry
|
||||||
registry = PluginRegistry()
|
registry = PluginRegistry()
|
||||||
|
|
||||||
# Register available plugins
|
# Register available plugins (8 active plugins)
|
||||||
registry.register("wordpress", WordPressPlugin)
|
registry.register("wordpress", WordPressPlugin)
|
||||||
registry.register("woocommerce", WooCommercePlugin)
|
registry.register("woocommerce", WooCommercePlugin)
|
||||||
registry.register("wordpress_advanced", WordPressAdvancedPlugin)
|
registry.register("wordpress_specialist", WordPressSpecialistPlugin)
|
||||||
registry.register("gitea", GiteaPlugin)
|
registry.register("gitea", GiteaPlugin)
|
||||||
registry.register("n8n", N8nPlugin)
|
registry.register("n8n", N8nPlugin)
|
||||||
registry.register("supabase", SupabasePlugin)
|
registry.register("supabase", SupabasePlugin)
|
||||||
registry.register("openpanel", OpenPanelPlugin)
|
registry.register("openpanel", OpenPanelPlugin)
|
||||||
registry.register("appwrite", AppwritePlugin)
|
registry.register("coolify", CoolifyPlugin)
|
||||||
registry.register("directus", DirectusPlugin)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BasePlugin",
|
"BasePlugin",
|
||||||
@@ -40,11 +49,10 @@ __all__ = [
|
|||||||
"registry",
|
"registry",
|
||||||
"WordPressPlugin",
|
"WordPressPlugin",
|
||||||
"WooCommercePlugin",
|
"WooCommercePlugin",
|
||||||
"WordPressAdvancedPlugin",
|
"WordPressSpecialistPlugin",
|
||||||
"GiteaPlugin",
|
"GiteaPlugin",
|
||||||
"N8nPlugin",
|
"N8nPlugin",
|
||||||
"SupabasePlugin",
|
"SupabasePlugin",
|
||||||
"OpenPanelPlugin",
|
"OpenPanelPlugin",
|
||||||
"AppwritePlugin",
|
"CoolifyPlugin",
|
||||||
"DirectusPlugin",
|
|
||||||
]
|
]
|
||||||
|
|||||||
33
plugins/ai_image/__init__.py
Normal file
33
plugins/ai_image/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""AI image generation provider library (F.5a.4).
|
||||||
|
|
||||||
|
Not a registered MCP plugin — this package exposes provider
|
||||||
|
implementations and a lookup registry consumed by media-upload tools in
|
||||||
|
other plugins (currently ``wordpress_generate_and_upload_image``).
|
||||||
|
|
||||||
|
Typical usage::
|
||||||
|
|
||||||
|
from plugins.ai_image.registry import get_provider
|
||||||
|
|
||||||
|
provider = get_provider("openai")
|
||||||
|
result = await provider.generate(
|
||||||
|
api_key=key, prompt="a red cube", size="1024x1024"
|
||||||
|
)
|
||||||
|
image_bytes, mime, meta = result.bytes, result.mime, result.meta
|
||||||
|
"""
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import (
|
||||||
|
BaseImageProvider,
|
||||||
|
GenerationRequest,
|
||||||
|
GenerationResult,
|
||||||
|
ProviderError,
|
||||||
|
)
|
||||||
|
from plugins.ai_image.registry import get_provider, list_providers
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseImageProvider",
|
||||||
|
"GenerationRequest",
|
||||||
|
"GenerationResult",
|
||||||
|
"ProviderError",
|
||||||
|
"get_provider",
|
||||||
|
"list_providers",
|
||||||
|
]
|
||||||
1
plugins/ai_image/providers/__init__.py
Normal file
1
plugins/ai_image/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""AI image provider implementations (F.5a.4)."""
|
||||||
72
plugins/ai_image/providers/base.py
Normal file
72
plugins/ai_image/providers/base.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Base types for AI image providers (F.5a.4).
|
||||||
|
|
||||||
|
Each concrete provider subclasses :class:`BaseImageProvider` and implements
|
||||||
|
``generate()``. Providers return raw image bytes + MIME so the caller (e.g.
|
||||||
|
the WordPress upload pipeline) can reuse the existing raw-upload path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""Stable-coded error for provider failures.
|
||||||
|
|
||||||
|
Codes follow the F.5a error taxonomy (``PROVIDER_QUOTA``,
|
||||||
|
``PROVIDER_AUTH``, ``PROVIDER_BAD_REQUEST``, ``PROVIDER_UNAVAILABLE``,
|
||||||
|
``PROVIDER_TIMEOUT``). ``details`` is JSON-serialisable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"error_code": self.code, "message": self.message, "details": self.details}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenerationRequest:
|
||||||
|
"""Normalised generation parameters.
|
||||||
|
|
||||||
|
Not every provider uses every field — unknown fields are ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt: str
|
||||||
|
size: str = "1024x1024"
|
||||||
|
quality: str = "standard"
|
||||||
|
model: str | None = None
|
||||||
|
negative_prompt: str | None = None
|
||||||
|
extras: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenerationResult:
|
||||||
|
"""Raw bytes + metadata returned by a provider."""
|
||||||
|
|
||||||
|
data: bytes
|
||||||
|
mime: str
|
||||||
|
filename: str
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
cost_usd: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseImageProvider(ABC):
|
||||||
|
"""Abstract provider: turns ``(api_key, request)`` into image bytes."""
|
||||||
|
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||||
|
"""Call the provider API and return image bytes + metadata.
|
||||||
|
|
||||||
|
Implementations should raise :class:`ProviderError` with a stable
|
||||||
|
code for predictable client handling. Network retries for transient
|
||||||
|
failures (429 / 5xx) should happen inside the implementation.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
184
plugins/ai_image/providers/openai.py
Normal file
184
plugins/ai_image/providers/openai.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""OpenAI image-generation provider (DALL-E 3 + gpt-image-1).
|
||||||
|
|
||||||
|
DALL-E 3 returns a time-limited URL (~1h TTL). gpt-image-1 can return
|
||||||
|
``b64_json`` directly. In both cases we return the raw bytes immediately
|
||||||
|
to the caller so the 1h URL expiry is never a concern downstream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import (
|
||||||
|
BaseImageProvider,
|
||||||
|
GenerationRequest,
|
||||||
|
GenerationResult,
|
||||||
|
ProviderError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("mcphub.ai_image.openai")
|
||||||
|
|
||||||
|
_API_URL = "https://api.openai.com/v1/images/generations"
|
||||||
|
_DEFAULT_MODEL = "dall-e-3"
|
||||||
|
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_REQUEST_TIMEOUT = 90
|
||||||
|
|
||||||
|
# Rough per-image pricing (USD) for audit logging. These are documented
|
||||||
|
# public list prices at the time of writing and may drift — treat as
|
||||||
|
# ballpark for cost dashboards, not billing.
|
||||||
|
_COST_TABLE: dict[tuple[str, str, str], float] = {
|
||||||
|
("dall-e-3", "1024x1024", "standard"): 0.040,
|
||||||
|
("dall-e-3", "1024x1024", "hd"): 0.080,
|
||||||
|
("dall-e-3", "1792x1024", "standard"): 0.080,
|
||||||
|
("dall-e-3", "1024x1792", "standard"): 0.080,
|
||||||
|
("dall-e-3", "1792x1024", "hd"): 0.120,
|
||||||
|
("dall-e-3", "1024x1792", "hd"): 0.120,
|
||||||
|
("gpt-image-1", "1024x1024", "low"): 0.011,
|
||||||
|
("gpt-image-1", "1024x1024", "medium"): 0.042,
|
||||||
|
("gpt-image-1", "1024x1024", "high"): 0.167,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_cost(model: str, size: str, quality: str) -> float | None:
|
||||||
|
return _COST_TABLE.get((model, size, quality))
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIProvider(BaseImageProvider):
|
||||||
|
name = "openai"
|
||||||
|
|
||||||
|
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("PROVIDER_AUTH", "OpenAI API key is missing.")
|
||||||
|
|
||||||
|
model = request.model or _DEFAULT_MODEL
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": request.prompt,
|
||||||
|
"size": request.size,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
if model == "dall-e-3":
|
||||||
|
payload["quality"] = request.quality or "standard"
|
||||||
|
payload["response_format"] = "url"
|
||||||
|
else:
|
||||||
|
payload["response_format"] = "b64_json"
|
||||||
|
if request.quality:
|
||||||
|
payload["quality"] = request.quality
|
||||||
|
|
||||||
|
data, meta = await self._post_with_retry(api_key, payload)
|
||||||
|
|
||||||
|
items = data.get("data") or []
|
||||||
|
if not items:
|
||||||
|
raise ProviderError("PROVIDER_BAD_RESPONSE", "OpenAI returned no images.")
|
||||||
|
first = items[0]
|
||||||
|
|
||||||
|
if first.get("b64_json"):
|
||||||
|
image_bytes = base64.b64decode(first["b64_json"])
|
||||||
|
elif first.get("url"):
|
||||||
|
image_bytes = await _fetch_url(first["url"])
|
||||||
|
else:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
"OpenAI response contained no b64_json or url.",
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = dict(meta)
|
||||||
|
if first.get("revised_prompt"):
|
||||||
|
meta["revised_prompt"] = first["revised_prompt"]
|
||||||
|
meta["model"] = model
|
||||||
|
meta["size"] = request.size
|
||||||
|
|
||||||
|
return GenerationResult(
|
||||||
|
data=image_bytes,
|
||||||
|
mime="image/png",
|
||||||
|
filename=f"openai-{model}.png",
|
||||||
|
meta=meta,
|
||||||
|
cost_usd=_estimate_cost(model, request.size, request.quality or "standard"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_with_retry(
|
||||||
|
self, api_key: str, payload: dict[str, Any]
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
last_error: str = ""
|
||||||
|
delay = 1.0
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with session.post(_API_URL, json=payload, headers=headers) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
body = await resp.json()
|
||||||
|
return body, {"attempt": attempt, "status": 200}
|
||||||
|
text = await resp.text()
|
||||||
|
last_error = text[:500]
|
||||||
|
if resp.status == 401:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_AUTH",
|
||||||
|
"OpenAI rejected the API key (401).",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status == 400:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
f"OpenAI rejected request (400): {last_error}",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status == 429 and attempt < _MAX_RETRIES:
|
||||||
|
_logger.warning("OpenAI 429, retry %d", attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||||
|
_logger.warning("OpenAI %d, retry %d", resp.status, attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
if resp.status == 429:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_QUOTA",
|
||||||
|
"OpenAI quota/rate-limit hit after retries.",
|
||||||
|
{"status": 429, "body": last_error},
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"OpenAI upstream error HTTP {resp.status}.",
|
||||||
|
{"status": resp.status, "body": last_error},
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
last_error = f"timeout: {exc}"
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"OpenAI request timed out after retries.",
|
||||||
|
) from exc
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"OpenAI call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_url(url: str) -> bytes:
|
||||||
|
"""Fetch a DALL-E URL immediately. URLs expire in ~1h, so no caching."""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
f"Failed to fetch OpenAI image URL (HTTP {resp.status}).",
|
||||||
|
{"status": resp.status},
|
||||||
|
)
|
||||||
|
return await resp.read()
|
||||||
477
plugins/ai_image/providers/openrouter.py
Normal file
477
plugins/ai_image/providers/openrouter.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
"""OpenRouter image-generation provider.
|
||||||
|
|
||||||
|
OpenRouter is an aggregator that routes requests to many model vendors.
|
||||||
|
For image generation, the usable path is their chat-completions endpoint
|
||||||
|
with ``modalities=["image","text"]`` — models like
|
||||||
|
``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the
|
||||||
|
generated image as a ``data:``-prefixed base64 URL inside the message
|
||||||
|
``images`` array; other models may return ``image_url``-style pointers.
|
||||||
|
|
||||||
|
Why this provider is worth adding:
|
||||||
|
|
||||||
|
* Unlocks Gemini image models without needing a Google Cloud project.
|
||||||
|
* Lets users concentrate AI spend on a single OpenRouter key rather
|
||||||
|
than managing separate OpenAI / Stability / Replicate accounts.
|
||||||
|
* Supported models (as of F.X.fix 2026-04-18):
|
||||||
|
- ``google/gemini-2.5-flash-image`` (default, GA)
|
||||||
|
- ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on
|
||||||
|
fresh OpenRouter accounts — kept as a recognised alias so we can
|
||||||
|
emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint)
|
||||||
|
- Any other OpenRouter model that returns image parts in
|
||||||
|
``message.images[]`` — the parser is tolerant of newer entries.
|
||||||
|
|
||||||
|
The default model is picked to cover the most common use case (hero
|
||||||
|
images for WordPress / WooCommerce posts); callers can override via
|
||||||
|
``GenerationRequest.model``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import (
|
||||||
|
BaseImageProvider,
|
||||||
|
GenerationRequest,
|
||||||
|
GenerationResult,
|
||||||
|
ProviderError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("mcphub.ai_image.openrouter")
|
||||||
|
|
||||||
|
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
# GA endpoint. The preview alias (...-image-preview) was deprecated after
|
||||||
|
# Google promoted the model to GA — new OpenRouter accounts get 404 on
|
||||||
|
# the preview ID.
|
||||||
|
_DEFAULT_MODEL = "google/gemini-2.5-flash-image"
|
||||||
|
_DEPRECATED_MODELS: dict[str, str] = {
|
||||||
|
"google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image",
|
||||||
|
}
|
||||||
|
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_REQUEST_TIMEOUT = 120
|
||||||
|
|
||||||
|
# USD-per-image price table. Figures are conservative upper bounds from
|
||||||
|
# public OpenRouter pricing (2026-04); operators who negotiate custom
|
||||||
|
# rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of
|
||||||
|
# model_id -> price USD).
|
||||||
|
_MODEL_PRICING: dict[str, float] = {
|
||||||
|
"google/gemini-2.5-flash-image": 0.00039,
|
||||||
|
"google/gemini-2.5-flash-image-preview": 0.00039,
|
||||||
|
"google/imagen-3.0-generate": 0.04,
|
||||||
|
"google/imagen-3.0-fast": 0.02,
|
||||||
|
"openai/dall-e-3": 0.04,
|
||||||
|
"openai/dall-e-3-hd": 0.08,
|
||||||
|
"openai/gpt-image-1": 0.017,
|
||||||
|
"black-forest-labs/flux-1.1-pro": 0.04,
|
||||||
|
"black-forest-labs/flux-pro": 0.055,
|
||||||
|
"black-forest-labs/flux-schnell": 0.003,
|
||||||
|
"stability-ai/stable-diffusion-3.5-large": 0.065,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pricing_table() -> dict[str, float]:
|
||||||
|
"""Return the effective price table, merging env override if present."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip()
|
||||||
|
if not override:
|
||||||
|
return _MODEL_PRICING
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(override)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc)
|
||||||
|
return _MODEL_PRICING
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return _MODEL_PRICING
|
||||||
|
merged = dict(_MODEL_PRICING)
|
||||||
|
for k, v in parsed.items():
|
||||||
|
try:
|
||||||
|
merged[str(k)] = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _cost_for(model: str) -> float | None:
|
||||||
|
"""Return USD cost for a known model, else None (logs at debug)."""
|
||||||
|
price = _pricing_table().get(model)
|
||||||
|
if price is None:
|
||||||
|
_logger.debug("openrouter: no pricing entry for model %r", model)
|
||||||
|
return price
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level cache for list_image_models() — 1h TTL is plenty; model
|
||||||
|
# catalog drift is measured in days, not minutes.
|
||||||
|
_MODELS_CACHE_TTL_SECONDS = 3600
|
||||||
|
_models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterProvider(BaseImageProvider):
|
||||||
|
name = "openrouter"
|
||||||
|
|
||||||
|
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.")
|
||||||
|
|
||||||
|
requested_model = request.model or _DEFAULT_MODEL
|
||||||
|
replacement = _DEPRECATED_MODELS.get(requested_model)
|
||||||
|
if replacement is not None:
|
||||||
|
# Auto-substitute deprecated → GA so per-site defaults pinned
|
||||||
|
# before the GA promotion don't break the generate-and-upload
|
||||||
|
# flow. Logged at WARNING so operators notice and can
|
||||||
|
# repin the site default in the dashboard.
|
||||||
|
_logger.warning(
|
||||||
|
"openrouter: model %r is deprecated; using %r instead",
|
||||||
|
requested_model,
|
||||||
|
replacement,
|
||||||
|
)
|
||||||
|
model = replacement
|
||||||
|
else:
|
||||||
|
model = requested_model
|
||||||
|
|
||||||
|
# Chat-completions shape with image modality declared. The
|
||||||
|
# prompt is sent as the single user message; size hints are
|
||||||
|
# advisory — Gemini picks an output size internally.
|
||||||
|
user_content: str = request.prompt
|
||||||
|
if request.negative_prompt:
|
||||||
|
user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}"
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": user_content}],
|
||||||
|
"modalities": ["image", "text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
body = await self._post_with_retry(api_key, payload)
|
||||||
|
|
||||||
|
image_url = _extract_image_url(body)
|
||||||
|
if not image_url:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
"OpenRouter response contained no image data. Is the "
|
||||||
|
f"model '{model}' configured for image output?",
|
||||||
|
{"model": model},
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes, mime = await _materialise_image(image_url)
|
||||||
|
if not image_bytes:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
"OpenRouter image URL could not be fetched / decoded.",
|
||||||
|
{"model": model},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Derive a useful filename suffix from the mime type.
|
||||||
|
ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png")
|
||||||
|
meta: dict[str, Any] = {"model": model, "size": request.size}
|
||||||
|
# Attach OpenRouter usage if present so audit logs can correlate.
|
||||||
|
usage = body.get("usage") or {}
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
||||||
|
if k in usage:
|
||||||
|
meta[k] = usage[k]
|
||||||
|
|
||||||
|
return GenerationResult(
|
||||||
|
data=image_bytes,
|
||||||
|
mime=mime,
|
||||||
|
filename=f"openrouter-{model.replace('/', '-')}.{ext}",
|
||||||
|
meta=meta,
|
||||||
|
cost_usd=_cost_for(model),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
# OpenRouter recommends identifying the calling app for
|
||||||
|
# observability. Using a stable string so admins can spot
|
||||||
|
# traffic from MCPHub in their OpenRouter dashboard.
|
||||||
|
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
|
||||||
|
"X-Title": "MCPHub",
|
||||||
|
}
|
||||||
|
last_error: str = ""
|
||||||
|
delay = 1.0
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with session.post(_API_URL, json=payload, headers=headers) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.json()
|
||||||
|
text = await resp.text()
|
||||||
|
last_error = text[:500]
|
||||||
|
if resp.status == 401:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_AUTH",
|
||||||
|
"OpenRouter rejected the API key (401).",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status == 400:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
f"OpenRouter rejected request (400): {last_error}",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||||
|
_logger.warning(
|
||||||
|
"OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
if resp.status == 429:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_QUOTA",
|
||||||
|
"OpenRouter quota/rate-limit hit after retries.",
|
||||||
|
{"status": 429, "body": last_error},
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"OpenRouter upstream error HTTP {resp.status}.",
|
||||||
|
{"status": resp.status, "body": last_error},
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
last_error = f"timeout: {exc}"
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"OpenRouter request timed out after retries.",
|
||||||
|
) from exc
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_image_models(
|
||||||
|
self, api_key: str | None = None, *, force: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return OpenRouter catalog entries whose modality includes image.
|
||||||
|
|
||||||
|
The catalog drifts slowly (days) so we cache the whole filtered
|
||||||
|
list at module scope for 1h. ``api_key`` is optional — the
|
||||||
|
``/v1/models`` endpoint is public, but authenticated callers see
|
||||||
|
per-account availability flags. On any upstream error the
|
||||||
|
previous cache (if any) is returned; otherwise an empty list.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
payload = _models_cache.get("payload")
|
||||||
|
fetched_at = _models_cache.get("fetched_at", 0.0)
|
||||||
|
if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS:
|
||||||
|
return list(payload)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
|
||||||
|
"X-Title": "MCPHub",
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(_MODELS_URL, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
_logger.warning("openrouter /v1/models HTTP %s", resp.status)
|
||||||
|
return list(payload or [])
|
||||||
|
body = await resp.json()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_logger.warning("openrouter /v1/models fetch failed: %s", exc)
|
||||||
|
return list(payload or [])
|
||||||
|
|
||||||
|
raw_models = body.get("data") or []
|
||||||
|
pricing = _pricing_table()
|
||||||
|
filtered: list[dict[str, Any]] = []
|
||||||
|
for item in raw_models:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if not _model_is_image(item):
|
||||||
|
continue
|
||||||
|
model_id = str(item.get("id") or "")
|
||||||
|
if not model_id or model_id in _DEPRECATED_MODELS:
|
||||||
|
continue
|
||||||
|
filtered.append(
|
||||||
|
{
|
||||||
|
"id": model_id,
|
||||||
|
"name": item.get("name") or model_id,
|
||||||
|
"description": (item.get("description") or "")[:400],
|
||||||
|
"context_length": item.get("context_length"),
|
||||||
|
"input_modalities": _modalities_of(item, "input"),
|
||||||
|
"output_modalities": _modalities_of(item, "output"),
|
||||||
|
"price_per_image_usd": pricing.get(model_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered.sort(key=lambda m: m["id"])
|
||||||
|
_models_cache["fetched_at"] = now
|
||||||
|
_models_cache["payload"] = filtered
|
||||||
|
return list(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def _modalities_of(item: dict[str, Any], side: str) -> list[str]:
|
||||||
|
"""Extract input/output modality list from an OpenRouter model entry."""
|
||||||
|
arch = item.get("architecture") or {}
|
||||||
|
if isinstance(arch, dict):
|
||||||
|
key = "input_modalities" if side == "input" else "output_modalities"
|
||||||
|
mods = arch.get(key)
|
||||||
|
if isinstance(mods, list):
|
||||||
|
return [str(m) for m in mods if isinstance(m, str)]
|
||||||
|
modality = arch.get("modality")
|
||||||
|
if isinstance(modality, str):
|
||||||
|
parts = modality.split("->")
|
||||||
|
if len(parts) == 2:
|
||||||
|
idx = 0 if side == "input" else 1
|
||||||
|
return [p.strip() for p in parts[idx].split("+") if p.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _model_is_image(item: dict[str, Any]) -> bool:
|
||||||
|
"""True when an OpenRouter model entry emits image output."""
|
||||||
|
out = _modalities_of(item, "output")
|
||||||
|
if any("image" in m.lower() for m in out):
|
||||||
|
return True
|
||||||
|
# Legacy catalog rows without structured modalities — fall back to a
|
||||||
|
# name/description heuristic so we don't silently drop valid models.
|
||||||
|
blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower()
|
||||||
|
return "image" in blob and "gen" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_image_url(body: dict[str, Any]) -> str | None:
|
||||||
|
"""Pull the first image URL out of an OpenRouter chat response.
|
||||||
|
|
||||||
|
The wire format varies by model vendor. We recognise:
|
||||||
|
|
||||||
|
* ``choices[0].message.images[i].image_url.url`` — a string that
|
||||||
|
may be a ``data:image/...;base64,...`` URL or an https URL.
|
||||||
|
* ``choices[0].message.content`` when it is a list of parts, each
|
||||||
|
of shape ``{type: "image_url", image_url: {url: ...}}`` —
|
||||||
|
OpenAI-compatible multimodal reply shape.
|
||||||
|
|
||||||
|
Returns None if no usable reference was found.
|
||||||
|
"""
|
||||||
|
choices = body.get("choices") or []
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
return None
|
||||||
|
message = (choices[0] or {}).get("message") or {}
|
||||||
|
|
||||||
|
# Shape 1: message.images (Gemini via OpenRouter).
|
||||||
|
for entry in message.get("images") or []:
|
||||||
|
url = _image_url_from_entry(entry)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Shape 2: message.content as list of parts.
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
if part.get("type") == "image_url":
|
||||||
|
url = _image_url_from_entry(part)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _image_url_from_entry(entry: Any) -> str | None:
|
||||||
|
"""Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``."""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
iu = entry.get("image_url")
|
||||||
|
if isinstance(iu, str):
|
||||||
|
return iu
|
||||||
|
if isinstance(iu, dict):
|
||||||
|
u = iu.get("url")
|
||||||
|
return u if isinstance(u, str) else None
|
||||||
|
# Some providers emit a bare "url" key.
|
||||||
|
u = entry.get("url")
|
||||||
|
return u if isinstance(u, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _materialise_image(url: str) -> tuple[bytes, str]:
|
||||||
|
"""Turn an image URL (data: or https) into raw bytes + MIME.
|
||||||
|
|
||||||
|
Returns ``(b"", "")`` on any failure so the caller can emit a
|
||||||
|
uniform ``PROVIDER_BAD_RESPONSE`` error.
|
||||||
|
"""
|
||||||
|
if url.startswith("data:"):
|
||||||
|
try:
|
||||||
|
header, payload = url.split(",", 1)
|
||||||
|
except ValueError:
|
||||||
|
return b"", ""
|
||||||
|
mime = "image/png"
|
||||||
|
rest = header[len("data:") :] if header.startswith("data:") else header
|
||||||
|
if ";" in rest:
|
||||||
|
mime = rest.split(";", 1)[0] or mime
|
||||||
|
elif rest:
|
||||||
|
mime = rest
|
||||||
|
if "base64" in header:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(payload), mime
|
||||||
|
except Exception:
|
||||||
|
return b"", mime
|
||||||
|
return payload.encode("latin-1", errors="ignore"), mime
|
||||||
|
|
||||||
|
if url.startswith("http://") or url.startswith("https://"):
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return b"", ""
|
||||||
|
mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip()
|
||||||
|
return await resp.read(), mime or "image/png"
|
||||||
|
|
||||||
|
return b"", ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Starlette handler — GET /api/providers/openrouter/models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def api_openrouter_models(request: Any) -> Any:
|
||||||
|
"""Return the cached OpenRouter image-model catalog.
|
||||||
|
|
||||||
|
Auth: same OAuth user session guard as the other /api/ endpoints.
|
||||||
|
Query params:
|
||||||
|
* ``force=1`` — bypass the 1h module cache.
|
||||||
|
* ``site_id=<uuid>`` — if present, the user's OpenRouter key for
|
||||||
|
that site is used as bearer so availability flags reflect the
|
||||||
|
operator's account; otherwise the endpoint is fetched unauthed.
|
||||||
|
"""
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from core.dashboard.routes import _require_user_session
|
||||||
|
|
||||||
|
user_session, redirect = _require_user_session(request)
|
||||||
|
if redirect or user_session is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||||
|
|
||||||
|
force = request.query_params.get("force") in {"1", "true", "True"}
|
||||||
|
api_key: str | None = None
|
||||||
|
site_id = (request.query_params.get("site_id") or "").strip()
|
||||||
|
if site_id:
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
from core.site_api import get_site_provider_key
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_session["user_id"])
|
||||||
|
if site is not None:
|
||||||
|
api_key = await get_site_provider_key(site_id, "openrouter")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_logger.debug("openrouter models: site key lookup skipped: %s", exc)
|
||||||
|
|
||||||
|
provider = OpenRouterProvider()
|
||||||
|
models = await provider.list_image_models(api_key=api_key, force=force)
|
||||||
|
return JSONResponse({"ok": True, "provider": "openrouter", "models": models})
|
||||||
204
plugins/ai_image/providers/replicate.py
Normal file
204
plugins/ai_image/providers/replicate.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Replicate provider (Flux family + other image models).
|
||||||
|
|
||||||
|
Replicate runs predictions asynchronously: POST to ``/v1/predictions``
|
||||||
|
returns a job id, and we poll ``/v1/predictions/{id}`` until status is
|
||||||
|
``succeeded`` or ``failed``. The final output is a URL (or list of URLs)
|
||||||
|
that must be fetched to get bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import (
|
||||||
|
BaseImageProvider,
|
||||||
|
GenerationRequest,
|
||||||
|
GenerationResult,
|
||||||
|
ProviderError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("mcphub.ai_image.replicate")
|
||||||
|
|
||||||
|
_PREDICTIONS_URL = "https://api.replicate.com/v1/predictions"
|
||||||
|
_DEFAULT_MODEL = "black-forest-labs/flux-schnell"
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_POLL_INTERVAL = 2.0
|
||||||
|
_POLL_TIMEOUT = 180
|
||||||
|
_REQUEST_TIMEOUT = 60
|
||||||
|
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
_COST_TABLE: dict[str, float] = {
|
||||||
|
"black-forest-labs/flux-schnell": 0.003,
|
||||||
|
"black-forest-labs/flux-dev": 0.025,
|
||||||
|
"black-forest-labs/flux-pro": 0.055,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aspect_from_size(size: str) -> str:
|
||||||
|
"""Replicate's Flux models accept an aspect_ratio enum, not WxH."""
|
||||||
|
mapping = {
|
||||||
|
"1024x1024": "1:1",
|
||||||
|
"1344x768": "16:9",
|
||||||
|
"768x1344": "9:16",
|
||||||
|
"1152x896": "3:2",
|
||||||
|
"896x1152": "2:3",
|
||||||
|
}
|
||||||
|
return mapping.get(size, "1:1")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplicateProvider(BaseImageProvider):
|
||||||
|
name = "replicate"
|
||||||
|
|
||||||
|
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("PROVIDER_AUTH", "Replicate API token is missing.")
|
||||||
|
|
||||||
|
model = request.model or _DEFAULT_MODEL
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"input": {
|
||||||
|
"prompt": request.prompt,
|
||||||
|
"aspect_ratio": _aspect_from_size(request.size),
|
||||||
|
**(request.extras or {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if request.negative_prompt:
|
||||||
|
payload["input"]["negative_prompt"] = request.negative_prompt
|
||||||
|
|
||||||
|
prediction = await self._create_prediction(api_key, payload)
|
||||||
|
final = await self._poll_until_done(api_key, prediction)
|
||||||
|
|
||||||
|
output = final.get("output")
|
||||||
|
url = _first_url(output)
|
||||||
|
if not url:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
"Replicate prediction finished without an image URL.",
|
||||||
|
{"prediction": final},
|
||||||
|
)
|
||||||
|
image_bytes = await _fetch_url(url)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"model": model,
|
||||||
|
"prediction_id": final.get("id"),
|
||||||
|
"status": final.get("status"),
|
||||||
|
"metrics": final.get("metrics", {}),
|
||||||
|
}
|
||||||
|
return GenerationResult(
|
||||||
|
data=image_bytes,
|
||||||
|
mime="image/webp" if url.endswith(".webp") else "image/png",
|
||||||
|
filename=f"replicate-{model.split('/')[-1]}.png",
|
||||||
|
meta=meta,
|
||||||
|
cost_usd=_COST_TABLE.get(model),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _create_prediction(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "wait=0",
|
||||||
|
}
|
||||||
|
delay = 1.0
|
||||||
|
last_error = ""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
async with session.post(_PREDICTIONS_URL, json=payload, headers=headers) as resp:
|
||||||
|
if resp.status in (200, 201):
|
||||||
|
return await resp.json()
|
||||||
|
text = await resp.text()
|
||||||
|
last_error = text[:500]
|
||||||
|
if resp.status in (401, 403):
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_AUTH",
|
||||||
|
f"Replicate rejected auth ({resp.status}).",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status == 422:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
f"Replicate rejected request: {last_error}",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||||
|
_logger.warning("Replicate %d, retry %d", resp.status, attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
if resp.status == 429:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_QUOTA",
|
||||||
|
"Replicate rate-limit hit.",
|
||||||
|
{"status": 429, "body": last_error},
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"Replicate HTTP {resp.status}.",
|
||||||
|
{"status": resp.status, "body": last_error},
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"Replicate create_prediction failed after retries: {last_error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _poll_until_done(self, api_key: str, prediction: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
poll_url = (prediction.get("urls") or {}).get("get") or (
|
||||||
|
f"{_PREDICTIONS_URL}/{prediction.get('id')}"
|
||||||
|
)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
deadline = asyncio.get_event_loop().time() + _POLL_TIMEOUT
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
while True:
|
||||||
|
if asyncio.get_event_loop().time() > deadline:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"Replicate prediction did not finish within timeout.",
|
||||||
|
{"prediction_id": prediction.get("id")},
|
||||||
|
)
|
||||||
|
async with session.get(poll_url, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"Replicate poll HTTP {resp.status}.",
|
||||||
|
{"body": text[:500]},
|
||||||
|
)
|
||||||
|
body = await resp.json()
|
||||||
|
status = body.get("status")
|
||||||
|
if status in ("succeeded",):
|
||||||
|
return body
|
||||||
|
if status in ("failed", "canceled"):
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
f"Replicate prediction {status}: {body.get('error')}",
|
||||||
|
{"prediction": body},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_url(output: Any) -> str | None:
|
||||||
|
if isinstance(output, str):
|
||||||
|
return output
|
||||||
|
if isinstance(output, list) and output:
|
||||||
|
first = output[0]
|
||||||
|
if isinstance(first, str):
|
||||||
|
return first
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_url(url: str) -> bytes:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_RESPONSE",
|
||||||
|
f"Failed to fetch Replicate output (HTTP {resp.status}).",
|
||||||
|
{"status": resp.status},
|
||||||
|
)
|
||||||
|
return await resp.read()
|
||||||
154
plugins/ai_image/providers/stability.py
Normal file
154
plugins/ai_image/providers/stability.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Stability AI image generation provider (Stable Image Core / Ultra).
|
||||||
|
|
||||||
|
Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
|
||||||
|
bytes back directly (no JSON indirection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import (
|
||||||
|
BaseImageProvider,
|
||||||
|
GenerationRequest,
|
||||||
|
GenerationResult,
|
||||||
|
ProviderError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger("mcphub.ai_image.stability")
|
||||||
|
|
||||||
|
_MODEL_ENDPOINTS = {
|
||||||
|
"core": "https://api.stability.ai/v2beta/stable-image/generate/core",
|
||||||
|
"ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
|
||||||
|
"sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
|
||||||
|
}
|
||||||
|
_DEFAULT_MODEL = "core"
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_REQUEST_TIMEOUT = 120
|
||||||
|
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
_COST_TABLE: dict[str, float] = {
|
||||||
|
"core": 0.03,
|
||||||
|
"ultra": 0.08,
|
||||||
|
"sd3": 0.065,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _size_to_aspect(size: str) -> str:
|
||||||
|
"""Map a WxH size string to the Stability aspect_ratio enum."""
|
||||||
|
mapping = {
|
||||||
|
"1024x1024": "1:1",
|
||||||
|
"1152x896": "21:9",
|
||||||
|
"1216x832": "3:2",
|
||||||
|
"1344x768": "16:9",
|
||||||
|
"768x1344": "9:16",
|
||||||
|
"832x1216": "2:3",
|
||||||
|
"1024x576": "16:9",
|
||||||
|
"512x512": "1:1",
|
||||||
|
}
|
||||||
|
return mapping.get(size, "1:1")
|
||||||
|
|
||||||
|
|
||||||
|
class StabilityProvider(BaseImageProvider):
|
||||||
|
name = "stability"
|
||||||
|
|
||||||
|
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
|
||||||
|
|
||||||
|
model = (request.model or _DEFAULT_MODEL).lower()
|
||||||
|
endpoint = _MODEL_ENDPOINTS.get(model)
|
||||||
|
if endpoint is None:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
form = aiohttp.FormData()
|
||||||
|
form.add_field("prompt", request.prompt)
|
||||||
|
form.add_field("output_format", "png")
|
||||||
|
form.add_field("aspect_ratio", _size_to_aspect(request.size))
|
||||||
|
if request.negative_prompt:
|
||||||
|
form.add_field("negative_prompt", request.negative_prompt)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "image/*",
|
||||||
|
}
|
||||||
|
data, meta = await self._post_with_retry(endpoint, form, headers)
|
||||||
|
|
||||||
|
meta = dict(meta)
|
||||||
|
meta["model"] = model
|
||||||
|
meta["aspect_ratio"] = _size_to_aspect(request.size)
|
||||||
|
return GenerationResult(
|
||||||
|
data=data,
|
||||||
|
mime="image/png",
|
||||||
|
filename=f"stability-{model}.png",
|
||||||
|
meta=meta,
|
||||||
|
cost_usd=_COST_TABLE.get(model),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_with_retry(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
form: aiohttp.FormData,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> tuple[bytes, dict[str, Any]]:
|
||||||
|
last_error: str = ""
|
||||||
|
delay = 1.0
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
for attempt in range(1, _MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
async with session.post(endpoint, data=form, headers=headers) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
body = await resp.read()
|
||||||
|
return body, {"attempt": attempt, "status": 200}
|
||||||
|
text = await resp.text()
|
||||||
|
last_error = text[:500]
|
||||||
|
if resp.status == 401 or resp.status == 403:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_AUTH",
|
||||||
|
f"Stability rejected auth ({resp.status}).",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status == 400:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_BAD_REQUEST",
|
||||||
|
f"Stability rejected request: {last_error}",
|
||||||
|
{"body": last_error},
|
||||||
|
)
|
||||||
|
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
|
||||||
|
_logger.warning("Stability %d, retry %d", resp.status, attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
if resp.status == 429:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_QUOTA",
|
||||||
|
"Stability quota/rate-limit hit.",
|
||||||
|
{"status": 429, "body": last_error},
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"Stability upstream error HTTP {resp.status}.",
|
||||||
|
{"status": resp.status, "body": last_error},
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
last_error = f"timeout: {exc}"
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
continue
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_TIMEOUT",
|
||||||
|
"Stability request timed out after retries.",
|
||||||
|
) from exc
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNAVAILABLE",
|
||||||
|
f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
|
||||||
|
)
|
||||||
36
plugins/ai_image/registry.py
Normal file
36
plugins/ai_image/registry.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Provider registry for AI image generation (F.5a.4).
|
||||||
|
|
||||||
|
Lookup by name; raises :class:`ProviderError` with code
|
||||||
|
``PROVIDER_UNKNOWN`` if the caller supplies a non-existent provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plugins.ai_image.providers.base import BaseImageProvider, ProviderError
|
||||||
|
from plugins.ai_image.providers.openai import OpenAIProvider
|
||||||
|
from plugins.ai_image.providers.openrouter import OpenRouterProvider
|
||||||
|
from plugins.ai_image.providers.replicate import ReplicateProvider
|
||||||
|
from plugins.ai_image.providers.stability import StabilityProvider
|
||||||
|
|
||||||
|
_PROVIDERS: dict[str, BaseImageProvider] = {
|
||||||
|
"openai": OpenAIProvider(),
|
||||||
|
"stability": StabilityProvider(),
|
||||||
|
"replicate": ReplicateProvider(),
|
||||||
|
"openrouter": OpenRouterProvider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(name: str) -> BaseImageProvider:
|
||||||
|
"""Return the registered provider singleton by name."""
|
||||||
|
try:
|
||||||
|
return _PROVIDERS[name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_UNKNOWN",
|
||||||
|
f"Unknown provider '{name}'. Allowed: {', '.join(_PROVIDERS)}.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_providers() -> list[str]:
|
||||||
|
"""Return all registered provider names in registration order."""
|
||||||
|
return list(_PROVIDERS.keys())
|
||||||
@@ -165,6 +165,40 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return {"healthy": True, "message": "Health check not implemented for this plugin"}
|
return {"healthy": True, "message": "Health check not implemented for this plugin"}
|
||||||
|
|
||||||
|
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||||
|
"""F.7e — report what the saved credential can actually do.
|
||||||
|
|
||||||
|
Plugins should override this to call the upstream service
|
||||||
|
(WordPress companion ``/capabilities``, WooCommerce
|
||||||
|
``system_status``, Gitea ``/user`` header scopes, Coolify
|
||||||
|
``/api/v1/teams/0``, etc.) and return the subset of capability
|
||||||
|
names the caller's token actually grants.
|
||||||
|
|
||||||
|
The base implementation returns a well-shaped "probe not
|
||||||
|
available" payload so callers can handle all plugin types
|
||||||
|
uniformly without special-casing the ones that haven't
|
||||||
|
implemented a probe yet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
probe_available: bool — True if the plugin knows how to
|
||||||
|
query its upstream for capabilities.
|
||||||
|
granted: list[str] — the capabilities the credential
|
||||||
|
actually grants (subset of the universe the plugin
|
||||||
|
understands). Empty when ``probe_available=False``.
|
||||||
|
source: str — which endpoint / header the list came from;
|
||||||
|
``"unavailable"`` when no probe is wired.
|
||||||
|
reason: str | None — optional human-readable diagnostic
|
||||||
|
when ``probe_available=False`` or when the call
|
||||||
|
failed in a way that still produced a usable answer.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"probe_available": False,
|
||||||
|
"granted": [],
|
||||||
|
"source": "unavailable",
|
||||||
|
"reason": "probe_not_implemented",
|
||||||
|
}
|
||||||
|
|
||||||
def get_project_info(self) -> dict[str, Any]:
|
def get_project_info(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Return basic information about this project instance.
|
Return basic information about this project instance.
|
||||||
|
|||||||
5
plugins/coolify/__init__.py
Normal file
5
plugins/coolify/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Coolify Plugin — AI-driven deployment management for Coolify instances.
|
||||||
|
|
||||||
|
F.17: Phase 1 MVP — Applications, Deployments, Servers (~30 tools)
|
||||||
|
"""
|
||||||
429
plugins/coolify/client.py
Normal file
429
plugins/coolify/client.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Coolify REST API Client
|
||||||
|
|
||||||
|
Handles all HTTP communication with Coolify REST API.
|
||||||
|
Separates API communication from business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
class CoolifyClient:
|
||||||
|
"""
|
||||||
|
Coolify REST API client for HTTP communication.
|
||||||
|
|
||||||
|
Handles Bearer token authentication, request formatting,
|
||||||
|
and error handling for all Coolify API v1 endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, site_url: str, token: str):
|
||||||
|
"""
|
||||||
|
Initialize Coolify API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_url: Coolify instance URL (e.g., https://coolify.example.com)
|
||||||
|
token: API token for Bearer authentication
|
||||||
|
"""
|
||||||
|
self.site_url = site_url.rstrip("/")
|
||||||
|
self.api_base = f"{self.site_url}/api/v1"
|
||||||
|
self.token = token
|
||||||
|
self.logger = logging.getLogger(f"CoolifyClient.{site_url}")
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""Get request headers with Bearer authentication."""
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
json_data: dict | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Make authenticated request to Coolify REST API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, PATCH, DELETE)
|
||||||
|
endpoint: API endpoint (without base URL)
|
||||||
|
params: Query parameters
|
||||||
|
json_data: JSON body data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response (dict, list, or None)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: On API errors with status code and message
|
||||||
|
"""
|
||||||
|
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
||||||
|
|
||||||
|
if params:
|
||||||
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
|
if json_data:
|
||||||
|
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||||
|
|
||||||
|
self.logger.debug(f"{method} {url}")
|
||||||
|
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as session,
|
||||||
|
session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_data,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
self.logger.debug(f"Response status: {response.status}")
|
||||||
|
|
||||||
|
if response.status == 204:
|
||||||
|
return {"success": True, "message": "Operation completed successfully"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await response.json()
|
||||||
|
except Exception:
|
||||||
|
response_text = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
raise Exception(
|
||||||
|
f"Coolify API error (status {response.status}): {response_text}"
|
||||||
|
)
|
||||||
|
return {"success": True, "message": response_text}
|
||||||
|
|
||||||
|
if response.status >= 400:
|
||||||
|
error_msg = response_data.get("message", "Unknown error")
|
||||||
|
raise Exception(f"Coolify API error (status {response.status}): {error_msg}")
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
# --- Applications ---
|
||||||
|
|
||||||
|
async def list_applications(self, tag: str | None = None) -> list[dict]:
|
||||||
|
"""List all applications."""
|
||||||
|
params = {"tag": tag} if tag else {}
|
||||||
|
return await self.request("GET", "applications", params=params)
|
||||||
|
|
||||||
|
async def get_application(self, uuid: str) -> dict:
|
||||||
|
"""Get application by UUID."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}")
|
||||||
|
|
||||||
|
async def create_application_public(self, data: dict) -> dict:
|
||||||
|
"""Create application from public repository."""
|
||||||
|
return await self.request("POST", "applications/public", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_dockerfile(self, data: dict) -> dict:
|
||||||
|
"""Create application from Dockerfile."""
|
||||||
|
return await self.request("POST", "applications/dockerfile", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_docker_image(self, data: dict) -> dict:
|
||||||
|
"""Create application from Docker image."""
|
||||||
|
return await self.request("POST", "applications/dockerimage", json_data=data)
|
||||||
|
|
||||||
|
async def create_application_docker_compose(self, data: dict) -> dict:
|
||||||
|
"""Create application from Docker Compose."""
|
||||||
|
return await self.request("POST", "applications/dockercompose", json_data=data)
|
||||||
|
|
||||||
|
async def update_application(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update application by UUID."""
|
||||||
|
return await self.request("PATCH", f"applications/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_application(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
delete_connected_networks: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete application by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
"delete_connected_networks": str(delete_connected_networks).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"applications/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_application(
|
||||||
|
self, uuid: str, force: bool = False, instant_deploy: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""Deploy/start application."""
|
||||||
|
params = {}
|
||||||
|
if force:
|
||||||
|
params["force"] = "true"
|
||||||
|
if instant_deploy:
|
||||||
|
params["instant_deploy"] = "true"
|
||||||
|
return await self.request("GET", f"applications/{uuid}/start", params=params)
|
||||||
|
|
||||||
|
async def stop_application(self, uuid: str, docker_cleanup: bool = True) -> dict:
|
||||||
|
"""Stop application."""
|
||||||
|
params = {"docker_cleanup": str(docker_cleanup).lower()}
|
||||||
|
return await self.request("GET", f"applications/{uuid}/stop", params=params)
|
||||||
|
|
||||||
|
async def restart_application(self, uuid: str) -> dict:
|
||||||
|
"""Restart application."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/restart")
|
||||||
|
|
||||||
|
async def get_application_logs(self, uuid: str, lines: int = 100) -> dict:
|
||||||
|
"""Get application logs."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/logs", params={"lines": lines})
|
||||||
|
|
||||||
|
# --- Application Environment Variables ---
|
||||||
|
|
||||||
|
async def list_application_envs(self, uuid: str) -> list[dict]:
|
||||||
|
"""List application environment variables."""
|
||||||
|
return await self.request("GET", f"applications/{uuid}/envs")
|
||||||
|
|
||||||
|
async def create_application_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Create application environment variable."""
|
||||||
|
return await self.request("POST", f"applications/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_application_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update application environment variable."""
|
||||||
|
return await self.request("PATCH", f"applications/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_application_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
|
||||||
|
"""Bulk update application environment variables."""
|
||||||
|
return await self.request(
|
||||||
|
"PATCH", f"applications/{uuid}/envs/bulk", json_data={"data": data}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_application_env(self, uuid: str, env_uuid: str) -> dict:
|
||||||
|
"""Delete application environment variable."""
|
||||||
|
return await self.request("DELETE", f"applications/{uuid}/envs/{env_uuid}")
|
||||||
|
|
||||||
|
# --- Deployments ---
|
||||||
|
|
||||||
|
async def list_deployments(self) -> list[dict]:
|
||||||
|
"""List running deployments."""
|
||||||
|
return await self.request("GET", "deployments")
|
||||||
|
|
||||||
|
async def get_deployment(self, uuid: str) -> dict:
|
||||||
|
"""Get deployment by UUID."""
|
||||||
|
return await self.request("GET", f"deployments/{uuid}")
|
||||||
|
|
||||||
|
async def cancel_deployment(self, uuid: str) -> dict:
|
||||||
|
"""Cancel a deployment."""
|
||||||
|
return await self.request("POST", f"deployments/{uuid}/cancel")
|
||||||
|
|
||||||
|
async def deploy(
|
||||||
|
self,
|
||||||
|
tag: str | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Deploy by tag or UUID."""
|
||||||
|
params = {}
|
||||||
|
if tag:
|
||||||
|
params["tag"] = tag
|
||||||
|
if uuid:
|
||||||
|
params["uuid"] = uuid
|
||||||
|
if force:
|
||||||
|
params["force"] = "true"
|
||||||
|
return await self.request("GET", "deploy", params=params)
|
||||||
|
|
||||||
|
async def list_app_deployments(self, uuid: str, skip: int = 0, take: int = 10) -> list[dict]:
|
||||||
|
"""List deployments for a specific application."""
|
||||||
|
params = {"skip": skip, "take": take}
|
||||||
|
return await self.request("GET", f"deployments/applications/{uuid}", params=params)
|
||||||
|
|
||||||
|
# --- Servers ---
|
||||||
|
|
||||||
|
async def list_servers(self) -> list[dict]:
|
||||||
|
"""List all servers."""
|
||||||
|
return await self.request("GET", "servers")
|
||||||
|
|
||||||
|
async def get_server(self, uuid: str) -> dict:
|
||||||
|
"""Get server by UUID."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}")
|
||||||
|
|
||||||
|
async def create_server(self, data: dict) -> dict:
|
||||||
|
"""Create a new server."""
|
||||||
|
return await self.request("POST", "servers", json_data=data)
|
||||||
|
|
||||||
|
async def update_server(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update server by UUID."""
|
||||||
|
return await self.request("PATCH", f"servers/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_server(self, uuid: str) -> dict:
|
||||||
|
"""Delete server by UUID."""
|
||||||
|
return await self.request("DELETE", f"servers/{uuid}")
|
||||||
|
|
||||||
|
async def get_server_resources(self, uuid: str) -> list[dict]:
|
||||||
|
"""Get resources on a server."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/resources")
|
||||||
|
|
||||||
|
async def get_server_domains(self, uuid: str) -> list[dict]:
|
||||||
|
"""Get domains configured on a server."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/domains")
|
||||||
|
|
||||||
|
async def validate_server(self, uuid: str) -> dict:
|
||||||
|
"""Validate server connectivity and configuration."""
|
||||||
|
return await self.request("GET", f"servers/{uuid}/validate")
|
||||||
|
|
||||||
|
# --- Projects ---
|
||||||
|
|
||||||
|
async def list_projects(self) -> list[dict]:
|
||||||
|
"""List all projects."""
|
||||||
|
return await self.request("GET", "projects")
|
||||||
|
|
||||||
|
async def get_project(self, uuid: str) -> dict:
|
||||||
|
"""Get project by UUID."""
|
||||||
|
return await self.request("GET", f"projects/{uuid}")
|
||||||
|
|
||||||
|
async def create_project(self, data: dict) -> dict:
|
||||||
|
"""Create a new project."""
|
||||||
|
return await self.request("POST", "projects", json_data=data)
|
||||||
|
|
||||||
|
async def update_project(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update project by UUID."""
|
||||||
|
return await self.request("PATCH", f"projects/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_project(self, uuid: str) -> dict:
|
||||||
|
"""Delete project by UUID."""
|
||||||
|
return await self.request("DELETE", f"projects/{uuid}")
|
||||||
|
|
||||||
|
# --- Environments ---
|
||||||
|
|
||||||
|
async def list_environments(self, project_uuid: str) -> list[dict]:
|
||||||
|
"""List environments in a project."""
|
||||||
|
return await self.request("GET", f"projects/{project_uuid}/environments")
|
||||||
|
|
||||||
|
async def get_environment(self, project_uuid: str, environment_name: str) -> dict:
|
||||||
|
"""Get environment by name."""
|
||||||
|
return await self.request("GET", f"projects/{project_uuid}/environments/{environment_name}")
|
||||||
|
|
||||||
|
async def create_environment(self, project_uuid: str, data: dict) -> dict:
|
||||||
|
"""Create environment in a project."""
|
||||||
|
return await self.request("POST", f"projects/{project_uuid}/environments", json_data=data)
|
||||||
|
|
||||||
|
# --- Databases ---
|
||||||
|
|
||||||
|
async def list_databases(self) -> list[dict]:
|
||||||
|
"""List all databases."""
|
||||||
|
return await self.request("GET", "databases")
|
||||||
|
|
||||||
|
async def get_database(self, uuid: str) -> dict:
|
||||||
|
"""Get database by UUID."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}")
|
||||||
|
|
||||||
|
async def update_database(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update database by UUID."""
|
||||||
|
return await self.request("PATCH", f"databases/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_database(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete database by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"databases/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_database(self, uuid: str) -> dict:
|
||||||
|
"""Start database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/start")
|
||||||
|
|
||||||
|
async def stop_database(self, uuid: str) -> dict:
|
||||||
|
"""Stop database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/stop")
|
||||||
|
|
||||||
|
async def restart_database(self, uuid: str) -> dict:
|
||||||
|
"""Restart database."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/restart")
|
||||||
|
|
||||||
|
async def create_database(self, db_type: str, data: dict) -> dict:
|
||||||
|
"""Create a database of given type (postgresql, mysql, mariadb, mongodb, redis, clickhouse)."""
|
||||||
|
return await self.request("POST", f"databases/{db_type}", json_data=data)
|
||||||
|
|
||||||
|
async def get_database_backups(self, uuid: str) -> dict:
|
||||||
|
"""Get database backups."""
|
||||||
|
return await self.request("GET", f"databases/{uuid}/backups")
|
||||||
|
|
||||||
|
async def create_database_backup(self, uuid: str) -> dict:
|
||||||
|
"""Create a manual database backup."""
|
||||||
|
return await self.request("POST", f"databases/{uuid}/backups")
|
||||||
|
|
||||||
|
async def list_backup_executions(self) -> list[dict]:
|
||||||
|
"""List all backup executions."""
|
||||||
|
return await self.request("GET", "databases/backup-executions")
|
||||||
|
|
||||||
|
# --- Services ---
|
||||||
|
|
||||||
|
async def list_services(self) -> list[dict]:
|
||||||
|
"""List all services."""
|
||||||
|
return await self.request("GET", "services")
|
||||||
|
|
||||||
|
async def get_service(self, uuid: str) -> dict:
|
||||||
|
"""Get service by UUID."""
|
||||||
|
return await self.request("GET", f"services/{uuid}")
|
||||||
|
|
||||||
|
async def create_service(self, data: dict) -> dict:
|
||||||
|
"""Create a service from template."""
|
||||||
|
return await self.request("POST", "services", json_data=data)
|
||||||
|
|
||||||
|
async def update_service(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update service by UUID."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}", json_data=data)
|
||||||
|
|
||||||
|
async def delete_service(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
delete_configurations: bool = True,
|
||||||
|
delete_volumes: bool = True,
|
||||||
|
docker_cleanup: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete service by UUID."""
|
||||||
|
params = {
|
||||||
|
"delete_configurations": str(delete_configurations).lower(),
|
||||||
|
"delete_volumes": str(delete_volumes).lower(),
|
||||||
|
"docker_cleanup": str(docker_cleanup).lower(),
|
||||||
|
}
|
||||||
|
return await self.request("DELETE", f"services/{uuid}", params=params)
|
||||||
|
|
||||||
|
async def start_service(self, uuid: str) -> dict:
|
||||||
|
"""Start service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/start")
|
||||||
|
|
||||||
|
async def stop_service(self, uuid: str) -> dict:
|
||||||
|
"""Stop service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/stop")
|
||||||
|
|
||||||
|
async def restart_service(self, uuid: str) -> dict:
|
||||||
|
"""Restart service."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/restart")
|
||||||
|
|
||||||
|
# --- Service Environment Variables ---
|
||||||
|
|
||||||
|
async def list_service_envs(self, uuid: str) -> list[dict]:
|
||||||
|
"""List service environment variables."""
|
||||||
|
return await self.request("GET", f"services/{uuid}/envs")
|
||||||
|
|
||||||
|
async def create_service_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Create service environment variable."""
|
||||||
|
return await self.request("POST", f"services/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_service_env(self, uuid: str, data: dict) -> dict:
|
||||||
|
"""Update service environment variable."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}/envs", json_data=data)
|
||||||
|
|
||||||
|
async def update_service_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
|
||||||
|
"""Bulk update service environment variables."""
|
||||||
|
return await self.request("PATCH", f"services/{uuid}/envs/bulk", json_data={"data": data})
|
||||||
|
|
||||||
|
async def delete_service_env(self, uuid: str, env_uuid: str) -> dict:
|
||||||
|
"""Delete service environment variable."""
|
||||||
|
return await self.request("DELETE", f"services/{uuid}/envs/{env_uuid}")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user