"""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), )