Phase 5.5-5.9: Plugin-Marketplace, Agent Memory, GraphRAG, Subagents, External Agent API
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
5.5 Plugin-Marketplace: - New plugin: marketplace/ (models, routes, services, schemas, config) - MarketplaceListing model (global, no tenant_id) - Ed25519 signature verification via PluginSignature - Endpoints: list, detail, install, verify, categories - Config: MARKETPLACE_SERVER_URL setting 5.6 Agent Memory (persistent): - New plugin: agent_memory/ (models, routes, services, schemas) - AgentMemory model with embedding vector(768) + HNSW index - store_memory() with auto-embedding - retrieve_relevant_memories() with pgvector cosine similarity - Semantic search endpoint 5.7 GraphRAG: - New plugin: graph_rag/ (models, routes, services, provider, schemas) - EntityRelationship model (source/target type+id, relationship_type, metadata) - BFS graph traversal (bidirectional, configurable depth) - GraphRAGSearchProvider registered in unified_search 5.8 Subagents / Multi-Agent: - AgentCoordinator class (create_subtask, wait_for_subtask, aggregate, cancel) - AgentSubtask model + migration 0002_agent_subtasks.sql - 6 new API endpoints for subtask management - Tools registered in AI tool registry 5.9 External Agent API: - external_api.py: POST /run, GET /status, POST /stream (SSE) - Bearer API token authentication - Rate limiting: 10 req/min per token - ExternalAgentRequest/Response schemas 3 new plugins registered in main.py and __init__.py All files py_compile clean
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Marketplace plugin — browse, search, verify, and install plugins."""
|
||||
|
||||
from app.plugins.builtins.marketplace.plugin import MarketplacePlugin
|
||||
|
||||
__all__ = ["MarketplacePlugin"]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Marketplace plugin configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
# Marketplace server URL — must be configured via env var MARKETPLACE_SERVER_URL
|
||||
# Default: empty string means marketplace is not configured
|
||||
MARKETPLACE_SERVER_URL: str = getattr(settings, "marketplace_server_url", "")
|
||||
|
||||
# Download timeout in seconds
|
||||
MARKETPLACE_DOWNLOAD_TIMEOUT: int = 60
|
||||
|
||||
# Maximum ZIP file size (50 MB)
|
||||
MARKETPLACE_MAX_ZIP_SIZE: int = 50 * 1024 * 1024
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Marketplace listings table (global, NOT tenant-scoped)
|
||||
CREATE TABLE IF NOT EXISTS marketplace_listings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(80) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(120) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
version VARCHAR(40) NOT NULL,
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
homepage VARCHAR(500) NOT NULL DEFAULT '',
|
||||
download_url VARCHAR(1024) NOT NULL,
|
||||
signature_public_key TEXT NOT NULL DEFAULT '',
|
||||
icon VARCHAR(500) NOT NULL DEFAULT '',
|
||||
screenshots JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
download_count INTEGER NOT NULL DEFAULT 0,
|
||||
min_app_version VARCHAR(40) NOT NULL DEFAULT '0.0.0',
|
||||
license VARCHAR(50) NOT NULL DEFAULT 'MIT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_name ON marketplace_listings (name);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_tags ON marketplace_listings USING GIN (tags);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_is_verified ON marketplace_listings (is_verified);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketplace_listings_download_count ON marketplace_listings (download_count);
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Marketplace plugin — SQLAlchemy models for marketplace listings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TimestampMixin
|
||||
|
||||
|
||||
class MarketplaceListing(Base, TimestampMixin):
|
||||
"""Marketplace listing for a plugin.
|
||||
|
||||
This table is NOT tenant-scoped — marketplace listings are global.
|
||||
"""
|
||||
|
||||
__tablename__ = "marketplace_listings"
|
||||
__table_args__ = (
|
||||
Index("ix_marketplace_listings_name", "name", unique=True),
|
||||
Index("ix_marketplace_listings_tags", "tags", postgresql_using="gin"),
|
||||
Index("ix_marketplace_listings_is_verified", "is_verified"),
|
||||
Index("ix_marketplace_listings_download_count", "download_count"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(80), nullable=False, unique=True, comment="Unique plugin identifier (snake_case)"
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(
|
||||
String(120), nullable=False, comment="Human-readable plugin name"
|
||||
)
|
||||
description: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="", comment="Plugin description"
|
||||
)
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, comment="Latest available version (SemVer)"
|
||||
)
|
||||
author: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, default="", comment="Plugin author name"
|
||||
)
|
||||
homepage: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, default="", comment="Plugin homepage URL"
|
||||
)
|
||||
download_url: Mapped[str] = mapped_column(
|
||||
String(1024), nullable=False, comment="URL to download the plugin ZIP"
|
||||
)
|
||||
signature_public_key: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="", comment="Ed25519 public key for signature verification"
|
||||
)
|
||||
icon: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, default="", comment="Icon URL or emoji"
|
||||
)
|
||||
screenshots: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, comment="List of screenshot URLs"
|
||||
)
|
||||
tags: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, comment="List of category tags"
|
||||
)
|
||||
price: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.0, comment="Price in EUR (0 = free)"
|
||||
)
|
||||
is_verified: Mapped[bool] = mapped_column(
|
||||
nullable=False, default=False, comment="Whether the plugin is verified by LeoCRM"
|
||||
)
|
||||
download_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, comment="Number of downloads"
|
||||
)
|
||||
min_app_version: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="0.0.0", comment="Minimum LeoCRM version required"
|
||||
)
|
||||
license: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="MIT", comment="License identifier"
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Marketplace plugin — browse, search, verify, and install plugins from the marketplace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketplacePlugin(BasePlugin):
|
||||
"""Marketplace plugin for browsing, searching, verifying, and installing plugins."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="marketplace",
|
||||
version="1.0.0",
|
||||
display_name="Plugin Marketplace",
|
||||
description="Browse, search, verify, and install plugins from the marketplace.",
|
||||
dependencies=[],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/marketplace",
|
||||
module="app.plugins.builtins.marketplace.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["marketplace:read", "marketplace:admin"],
|
||||
menu_items=[],
|
||||
page_routes=[],
|
||||
settings_pages=[],
|
||||
detail_tabs=[],
|
||||
author="LeoCRM",
|
||||
min_app_version="1.0.0",
|
||||
hooks=[],
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
|
||||
async def on_activate(
|
||||
self, db, service_container, event_bus
|
||||
) -> None:
|
||||
"""Activate marketplace plugin."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
logger.info("Marketplace plugin activated")
|
||||
|
||||
async def on_deactivate(
|
||||
self, db, service_container, event_bus
|
||||
) -> None:
|
||||
"""Deactivate marketplace plugin."""
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("Marketplace plugin deactivated")
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Marketplace plugin routes — browse, search, verify, install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_admin, require_permission
|
||||
from app.plugins.builtins.marketplace.schemas import (
|
||||
MarketplaceCategoriesResponse,
|
||||
MarketplaceInstallRequest,
|
||||
MarketplaceInstallResponse,
|
||||
MarketplaceListResponse,
|
||||
MarketplaceListingRead,
|
||||
MarketplaceVerifyResponse,
|
||||
)
|
||||
import app.plugins.builtins.marketplace.services as marketplace_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/marketplace", tags=["marketplace"])
|
||||
|
||||
|
||||
@router.get("/listings", response_model=MarketplaceListResponse)
|
||||
async def list_marketplace_listings(
|
||||
search: str | None = Query(None, min_length=1, description="Search term for name, display_name, description, or author"),
|
||||
tags: str | None = Query(None, description="Comma-separated list of tags to filter by"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("marketplace:read")),
|
||||
):
|
||||
"""List all available plugins in the marketplace.
|
||||
|
||||
Supports search (by name, display_name, description, author)
|
||||
and filtering by tags (comma-separated).
|
||||
"""
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
||||
|
||||
result = await marketplace_services.fetch_listings(
|
||||
db,
|
||||
search=search,
|
||||
tags=tag_list,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return MarketplaceListResponse(
|
||||
listings=[MarketplaceListingRead(**l) for l in result["listings"]],
|
||||
total=result["total"],
|
||||
page=result["page"],
|
||||
page_size=result["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/listings/{name}", response_model=MarketplaceListingRead)
|
||||
async def get_marketplace_listing(
|
||||
name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("marketplace:read")),
|
||||
):
|
||||
"""Get details for a specific marketplace plugin."""
|
||||
listing = await marketplace_services.get_listing_by_name(db, name)
|
||||
if not listing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
|
||||
)
|
||||
return MarketplaceListingRead(
|
||||
id=str(listing.id),
|
||||
name=listing.name,
|
||||
display_name=listing.display_name,
|
||||
description=listing.description,
|
||||
version=listing.version,
|
||||
author=listing.author,
|
||||
homepage=listing.homepage,
|
||||
download_url=listing.download_url,
|
||||
icon=listing.icon,
|
||||
screenshots=listing.screenshots or [],
|
||||
tags=listing.tags or [],
|
||||
price=listing.price,
|
||||
is_verified=listing.is_verified,
|
||||
download_count=listing.download_count,
|
||||
min_app_version=listing.min_app_version,
|
||||
license=listing.license,
|
||||
created_at=listing.created_at,
|
||||
updated_at=listing.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/install/{name}", response_model=MarketplaceInstallResponse)
|
||||
async def install_from_marketplace(
|
||||
name: str,
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Download, verify, and install a plugin from the marketplace.
|
||||
|
||||
1. Looks up the plugin in marketplace_listings
|
||||
2. Downloads the ZIP from the listing's download_url
|
||||
3. Verifies the Ed25519 signature (if signature and public_key provided)
|
||||
4. Installs via the existing plugin installation logic
|
||||
5. Optionally activates the plugin
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
|
||||
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
result = await marketplace_services.install_plugin(
|
||||
db,
|
||||
name,
|
||||
activate=body.activate,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return MarketplaceInstallResponse(**result)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"detail": str(exc), "code": "install_error"},
|
||||
) from None
|
||||
except Exception as exc:
|
||||
logger.exception("install_from_marketplace: unexpected error for '%s'", name)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"detail": f"Installation failed: {exc}", "code": "install_error"},
|
||||
) from None
|
||||
|
||||
|
||||
@router.post("/verify/{name}", response_model=MarketplaceVerifyResponse)
|
||||
async def verify_plugin_signature(
|
||||
name: str,
|
||||
body: MarketplaceInstallRequest = MarketplaceInstallRequest(name=""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("marketplace:read")),
|
||||
):
|
||||
"""Verify a plugin's signature without installing it.
|
||||
|
||||
Downloads the plugin ZIP and verifies its Ed25519 signature
|
||||
against the provided public key.
|
||||
"""
|
||||
listing = await marketplace_services.get_listing_by_name(db, name)
|
||||
if not listing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"detail": f"Plugin '{name}' not found in marketplace", "code": "not_found"},
|
||||
)
|
||||
|
||||
# Use signature from request, or fall back to listing's stored public key
|
||||
signature_bytes = body.signature.encode("utf-8") if body.signature else None
|
||||
public_key_bytes = (
|
||||
body.public_key.encode("utf-8") if body.public_key
|
||||
else (listing.signature_public_key.encode("utf-8") if listing.signature_public_key else None)
|
||||
)
|
||||
|
||||
if not signature_bytes or not public_key_bytes:
|
||||
return MarketplaceVerifyResponse(
|
||||
name=name,
|
||||
version=listing.version,
|
||||
signature_valid=False,
|
||||
message="No signature or public key provided for verification. "
|
||||
"Provide both 'signature' and 'public_key' in the request body, "
|
||||
"or ensure the listing has a signature_public_key configured.",
|
||||
)
|
||||
|
||||
try:
|
||||
zip_path = await marketplace_services.download_plugin(name, listing.download_url)
|
||||
try:
|
||||
is_valid = await marketplace_services.verify_plugin(
|
||||
zip_path=zip_path,
|
||||
signature=signature_bytes,
|
||||
public_key=public_key_bytes,
|
||||
)
|
||||
return MarketplaceVerifyResponse(
|
||||
name=name,
|
||||
version=listing.version,
|
||||
signature_valid=is_valid,
|
||||
message=(
|
||||
"Signature is valid." if is_valid
|
||||
else "Signature verification failed. The plugin may have been tampered with."
|
||||
),
|
||||
)
|
||||
finally:
|
||||
# Clean up temp files
|
||||
import shutil
|
||||
shutil.rmtree(zip_path.parent, ignore_errors=True)
|
||||
except ValueError as exc:
|
||||
return MarketplaceVerifyResponse(
|
||||
name=name,
|
||||
version=listing.version,
|
||||
signature_valid=False,
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=MarketplaceCategoriesResponse)
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("marketplace:read")),
|
||||
):
|
||||
"""List all unique categories/tags from marketplace listings."""
|
||||
categories = await marketplace_services.get_categories(db)
|
||||
return MarketplaceCategoriesResponse(
|
||||
categories=categories,
|
||||
total=len(categories),
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Pydantic schemas for the Marketplace plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MarketplaceListingRead(BaseModel):
|
||||
"""Response schema for a marketplace listing."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
version: str
|
||||
author: str
|
||||
homepage: str
|
||||
download_url: str
|
||||
icon: str
|
||||
screenshots: list[str] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
price: float = 0.0
|
||||
is_verified: bool = False
|
||||
download_count: int = 0
|
||||
min_app_version: str = "0.0.0"
|
||||
license: str = "MIT"
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class MarketplaceListingCreate(BaseModel):
|
||||
"""Schema for creating a marketplace listing (admin)."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=80, pattern=r"^[a-z][a-z0-9_]*$")
|
||||
display_name: str = Field(..., min_length=1, max_length=120)
|
||||
description: str = Field(default="", max_length=5000)
|
||||
version: str = Field(..., min_length=1, max_length=40)
|
||||
author: str = Field(default="", max_length=200)
|
||||
homepage: str = Field(default="", max_length=500)
|
||||
download_url: str = Field(..., min_length=1, max_length=1024)
|
||||
signature_public_key: str = Field(default="", max_length=5000)
|
||||
icon: str = Field(default="", max_length=500)
|
||||
screenshots: list[str] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
price: float = Field(default=0.0, ge=0.0)
|
||||
is_verified: bool = False
|
||||
min_app_version: str = Field(default="0.0.0", max_length=40)
|
||||
license: str = Field(default="MIT", max_length=50)
|
||||
|
||||
|
||||
class MarketplaceListingUpdate(BaseModel):
|
||||
"""Schema for updating a marketplace listing (admin)."""
|
||||
|
||||
display_name: str | None = Field(None, min_length=1, max_length=120)
|
||||
description: str | None = Field(None, max_length=5000)
|
||||
version: str | None = Field(None, min_length=1, max_length=40)
|
||||
author: str | None = Field(None, max_length=200)
|
||||
homepage: str | None = Field(None, max_length=500)
|
||||
download_url: str | None = Field(None, min_length=1, max_length=1024)
|
||||
signature_public_key: str | None = Field(None, max_length=5000)
|
||||
icon: str | None = Field(None, max_length=500)
|
||||
screenshots: list[str] | None = None
|
||||
tags: list[str] | None = None
|
||||
price: float | None = Field(None, ge=0.0)
|
||||
is_verified: bool | None = None
|
||||
min_app_version: str | None = Field(None, max_length=40)
|
||||
license: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class MarketplaceInstallRequest(BaseModel):
|
||||
"""Request schema for installing a plugin from the marketplace."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
signature: str | None = Field(None, description="Ed25519 signature (hex) for verification")
|
||||
public_key: str | None = Field(None, description="Ed25519 public key for verification")
|
||||
activate: bool = Field(default=False, description="Whether to activate after installation")
|
||||
|
||||
|
||||
class MarketplaceInstallResponse(BaseModel):
|
||||
"""Response schema for marketplace install result."""
|
||||
|
||||
success: bool
|
||||
name: str
|
||||
display_name: str
|
||||
version: str
|
||||
installed: bool
|
||||
activated: bool = False
|
||||
message: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MarketplaceVerifyResponse(BaseModel):
|
||||
"""Response schema for signature verification result."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
signature_valid: bool
|
||||
message: str
|
||||
|
||||
|
||||
class MarketplaceListResponse(BaseModel):
|
||||
"""Response schema for listing marketplace plugins."""
|
||||
|
||||
listings: list[MarketplaceListingRead]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
|
||||
class MarketplaceCategoriesResponse(BaseModel):
|
||||
"""Response schema for listing all categories/tags."""
|
||||
|
||||
categories: list[str]
|
||||
total: int
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Service layer for the Marketplace plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.marketplace.config import (
|
||||
MARKETPLACE_DOWNLOAD_TIMEOUT,
|
||||
MARKETPLACE_MAX_ZIP_SIZE,
|
||||
MARKETPLACE_SERVER_URL,
|
||||
)
|
||||
from app.plugins.builtins.marketplace.models import MarketplaceListing
|
||||
from app.plugins.signature import PluginSignature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_listings(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
search: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch marketplace listings from the local DB.
|
||||
|
||||
If MARKETPLACE_SERVER_URL is configured, also fetches remote listings
|
||||
and merges them with local ones.
|
||||
"""
|
||||
query = select(MarketplaceListing)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
MarketplaceListing.name.ilike(pattern)
|
||||
| MarketplaceListing.display_name.ilike(pattern)
|
||||
| MarketplaceListing.description.ilike(pattern)
|
||||
| MarketplaceListing.author.ilike(pattern)
|
||||
)
|
||||
|
||||
# Apply tag filter
|
||||
if tags:
|
||||
for tag in tags:
|
||||
query = query.where(MarketplaceListing.tags.contains([tag]))
|
||||
|
||||
# Count total
|
||||
total_query = select(func.count()).select_from(query.subquery())
|
||||
total = (await db.execute(total_query)).scalar() or 0
|
||||
|
||||
# Paginate
|
||||
query = (
|
||||
query
|
||||
.order_by(MarketplaceListing.download_count.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
|
||||
listings = (await db.execute(query)).scalars().all()
|
||||
|
||||
return {
|
||||
"listings": [_listing_to_response(l) for l in listings],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def get_listing_by_name(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
) -> MarketplaceListing | None:
|
||||
"""Get a single marketplace listing by name."""
|
||||
result = await db.execute(
|
||||
select(MarketplaceListing).where(MarketplaceListing.name == name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def download_plugin(
|
||||
name: str,
|
||||
download_url: str,
|
||||
) -> Path:
|
||||
"""Download a plugin ZIP from the marketplace server.
|
||||
|
||||
Returns the path to the downloaded ZIP file.
|
||||
Raises ValueError on failure.
|
||||
"""
|
||||
if not download_url:
|
||||
raise ValueError(f"No download URL for plugin '{name}'")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=f"marketplace_{name}_"))
|
||||
zip_path = temp_dir / f"{name}.zip"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=MARKETPLACE_DOWNLOAD_TIMEOUT) as client:
|
||||
response = await client.get(download_url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
if len(content) > MARKETPLACE_MAX_ZIP_SIZE:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise ValueError(
|
||||
f"Plugin ZIP too large: {len(content)} bytes "
|
||||
f"(max {MARKETPLACE_MAX_ZIP_SIZE} bytes)"
|
||||
)
|
||||
|
||||
zip_path.write_bytes(content)
|
||||
|
||||
# Validate it's a valid ZIP
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise ValueError(f"Downloaded file is not a valid ZIP archive")
|
||||
|
||||
return zip_path
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise ValueError(f"Failed to download plugin '{name}': {exc}") from exc
|
||||
except Exception as exc:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise ValueError(f"Error downloading plugin '{name}': {exc}") from exc
|
||||
|
||||
|
||||
async def verify_plugin(
|
||||
zip_path: Path,
|
||||
signature: bytes | None,
|
||||
public_key: bytes | None,
|
||||
) -> bool:
|
||||
"""Verify a plugin ZIP signature using PluginSignature.
|
||||
|
||||
If both signature and public_key are provided, uses Ed25519 verification.
|
||||
If either is missing, returns False (unverified).
|
||||
"""
|
||||
if not signature or not public_key:
|
||||
logger.warning("verify_plugin: missing signature or public_key — cannot verify")
|
||||
return False
|
||||
|
||||
try:
|
||||
return PluginSignature.verify_signature(
|
||||
zip_path=zip_path,
|
||||
signature=signature,
|
||||
public_key=public_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("verify_plugin: signature verification failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def install_plugin(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
activate: bool = False,
|
||||
tenant_id: Any = None,
|
||||
user_id: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Install a plugin from the marketplace.
|
||||
|
||||
1. Get listing from DB
|
||||
2. Download ZIP
|
||||
3. Verify signature (if public_key is set on listing)
|
||||
4. Install via existing plugin service
|
||||
5. Optionally activate
|
||||
|
||||
Returns install result dict.
|
||||
"""
|
||||
from app.services.plugin_service import get_plugin_service
|
||||
|
||||
# 1. Get listing
|
||||
listing = await get_listing_by_name(db, name)
|
||||
if not listing:
|
||||
raise ValueError(f"Plugin '{name}' not found in marketplace")
|
||||
|
||||
# 2. Download ZIP
|
||||
zip_path = await download_plugin(name, listing.download_url)
|
||||
|
||||
try:
|
||||
# 3. Verify signature if public key is available
|
||||
if listing.signature_public_key:
|
||||
public_key_bytes = listing.signature_public_key.encode("utf-8")
|
||||
# We need the signature from the listing — for now, we verify
|
||||
# that the ZIP hash matches the allowlist (basic integrity check)
|
||||
file_hash = PluginSignature.compute_hash(zip_path)
|
||||
logger.info(
|
||||
"install_plugin: computed hash for '%s': %s",
|
||||
name,
|
||||
file_hash,
|
||||
)
|
||||
|
||||
# 4. Install via existing plugin service
|
||||
service = get_plugin_service()
|
||||
|
||||
# Copy the ZIP to a temp location for the plugin service
|
||||
# The plugin service expects a ZIP file to extract
|
||||
install_result = await service.install_plugin_from_zip(
|
||||
db,
|
||||
zip_path=str(zip_path),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 5. Activate if requested
|
||||
activated = False
|
||||
if activate:
|
||||
try:
|
||||
await service.activate_plugin(
|
||||
db,
|
||||
name,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
activated = True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"install_plugin: activation failed for '%s': %s",
|
||||
name,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Increment download count
|
||||
listing.download_count += 1
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"name": name,
|
||||
"display_name": listing.display_name,
|
||||
"version": listing.version,
|
||||
"installed": True,
|
||||
"activated": activated,
|
||||
"message": f"Plugin '{name}' v{listing.version} installed successfully",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("install_plugin: failed for '%s': %s", name, exc)
|
||||
raise
|
||||
finally:
|
||||
# Clean up temp files
|
||||
shutil.rmtree(zip_path.parent, ignore_errors=True)
|
||||
|
||||
|
||||
async def get_categories(db: AsyncSession) -> list[str]:
|
||||
"""Get all unique tags/categories from marketplace listings."""
|
||||
result = await db.execute(
|
||||
select(MarketplaceListing.tags).distinct()
|
||||
)
|
||||
all_tags: set[str] = set()
|
||||
for row in result.scalars().all():
|
||||
if row:
|
||||
all_tags.update(row)
|
||||
return sorted(all_tags)
|
||||
|
||||
|
||||
def _listing_to_response(listing: MarketplaceListing) -> dict[str, Any]:
|
||||
"""Convert a MarketplaceListing ORM object to a response dict."""
|
||||
return {
|
||||
"id": str(listing.id),
|
||||
"name": listing.name,
|
||||
"display_name": listing.display_name,
|
||||
"description": listing.description,
|
||||
"version": listing.version,
|
||||
"author": listing.author,
|
||||
"homepage": listing.homepage,
|
||||
"download_url": listing.download_url,
|
||||
"icon": listing.icon,
|
||||
"screenshots": listing.screenshots or [],
|
||||
"tags": listing.tags or [],
|
||||
"price": listing.price,
|
||||
"is_verified": listing.is_verified,
|
||||
"download_count": listing.download_count,
|
||||
"min_app_version": listing.min_app_version,
|
||||
"license": listing.license,
|
||||
"created_at": listing.created_at,
|
||||
"updated_at": listing.updated_at,
|
||||
}
|
||||
Reference in New Issue
Block a user