"""Service layer for the Marketplace plugin.""" from __future__ import annotations import logging import shutil import tempfile import zipfile from pathlib import Path from typing import Any import httpx from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.marketplace.config import ( MARKETPLACE_DOWNLOAD_TIMEOUT, MARKETPLACE_MAX_ZIP_SIZE, ) 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(listing) for listing 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("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: # 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 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, }