abbe7a18fc
- 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
213 lines
7.6 KiB
Python
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),
|
|
)
|