"""
App Store — эндпоинты каталога (M1).
GET /api/appstore/catalog — список карточек (поиск/категория/пагинация)
GET /api/appstore/catalog/meta — метаданные (last_synced_at, count, категории)
GET /api/appstore/catalog/{app_id} — деталь приложения (описание, форма, compose)
GET /api/appstore/catalog/{app_id}/logo — логотип из кэша
POST /api/appstore/catalog/sync — ручная синхронизация (admin)
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ...auth import PermissionChecker
from ...config import settings
from ...db import SessionLocal, get_db
from ...models import CatalogApp, User
from ...services import appstore_engine as engine
from ...services.appstore_catalog import get_catalog_meta, sync_catalog
router = APIRouter()
@router.get("/api/appstore/catalog")
def list_catalog(
q: Optional[str] = None,
category: Optional[str] = None,
source: Optional[str] = None,
available_only: bool = False,
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
query = db.query(CatalogApp)
if available_only:
query = query.filter(CatalogApp.available.is_(True))
if source:
query = query.filter(CatalogApp.source == source.strip().lower())
if q:
like = f"%{q.strip().lower()}%"
query = query.filter(or_(
CatalogApp.name.ilike(like),
CatalogApp.short_desc.ilike(like),
CatalogApp.app_id.ilike(like),
))
rows = query.order_by(CatalogApp.name.asc()).all()
# Фильтр по категории — в Python: categories хранится как JSON-массив строк,
# это надёжнее JSONB-операторов на generic JSON-колонке (независимо от диалекта).
if category:
rows = [r for r in rows if category in (r.categories or [])]
total = len(rows)
page = rows[offset:offset + limit]
return {
"total": total,
"limit": limit,
"offset": offset,
"items": [a.to_dict(light=True) for a in page],
}
@router.get("/api/appstore/catalog/meta")
def catalog_meta(
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
return get_catalog_meta(db)
@router.get("/api/appstore/catalog/{app_id}")
def get_app(
app_id: str,
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="Приложение не найдено в каталоге")
return app.to_dict(with_compose=True)
@router.get("/api/appstore/catalog/{app_id}/logo")
def get_app_logo(
app_id: str,
db: Session = Depends(get_db),
):
# Публичный: логотипы — брендовые картинки, не секреты; тег
не шлёт JWT.
app = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
if not app or not app.logo_path:
raise HTTPException(status_code=404, detail="Логотип не найден")
base = Path(settings.APPSTORE_DATA_DIR).resolve()
path = (base / app.logo_path).resolve()
# защита от path traversal: файл обязан лежать внутри data-каталога
if base not in path.parents or not path.is_file():
raise HTTPException(status_code=404, detail="Файл логотипа отсутствует")
# Logos come from third-party catalog repos and are often SVG, served from
# the panel's origin: opened directly, a script inside would run next to the
# session token. The sandbox CSP keeps
rendering and blocks scripts.
return FileResponse(str(path), headers={
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "public, max-age=86400",
})
@router.post("/api/appstore/catalog/sync")
async def trigger_sync(
_: object = Depends(PermissionChecker("app:manage")),
):
"""Ручной запуск синхронизации каталога. Блокирующий синк — в пуле потоков."""
def _do() -> dict:
db = SessionLocal()
try:
return sync_catalog(db)
finally:
db.close()
try:
stats = await asyncio.get_event_loop().run_in_executor(None, _do)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Синхронизация не удалась: {e}")
return {"success": True, "stats": stats}
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
_PVE_ID = r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
class GoldenTemplateRequest(BaseModel):
# These go into root shell commands on the node (pct create / pvesm).
server_id: int
node: Optional[str] = Field(default=None, pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$")
template_storage: str = Field(default="local", pattern=_PVE_ID) # dir-storage для итогового vztmpl
rootfs_storage: str = Field(default="local-lvm", pattern=_PVE_ID) # storage для rootfs временного CT
bridge: str = Field(default="vmbr0", pattern=_PVE_ID)
force: bool = False # пересобрать, даже если шаблон уже есть
@router.post("/api/appstore/golden-template")
def build_golden_template(
req: GoldenTemplateRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:manage")),
):
"""Собрать золотой LXC-шаблон на ноде (фоновая операция). Прогресс — по WebSocket."""
from ..proxmox._helpers import get_visible_server_ids
visible = get_visible_server_ids(request, db, current_user)
if visible is not None and req.server_id not in visible:
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
try:
result = engine.prepare_golden_template(
db, current_user, server_id=req.server_id, node=req.node,
template_storage=req.template_storage, rootfs_storage=req.rootfs_storage,
bridge=req.bridge, force=req.force,
)
except engine.EngineError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.get("/api/appstore/golden-template/status")
def golden_template_status(
server_id: int = Query(...),
db: Session = Depends(get_db),
_: object = Depends(PermissionChecker("app:view")),
):
"""Текущий шаблон конкретного сервера (БД/env) и последняя операция сборки на нём.
vztmpl — файл на storage конкретного Proxmox-сервера, поэтому статус
обязательно скопирован по server_id (шаблон сервера A не существует и не
виден на сервере B).
"""
return engine.golden_template_status(db, server_id)