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