Files
leocrm/app/plugins/builtins/marketplace/routes.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

213 lines
7.6 KiB
Python

"""Marketplace plugin routes — browse, search, verify, install."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
import app.plugins.builtins.marketplace.services as marketplace_services
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,
MarketplaceListingRead,
MarketplaceListResponse,
MarketplaceVerifyResponse,
)
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(**listing) for listing 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.model_construct(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.model_construct(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),
)