Files
pvemanager/backend/app/api/appstore/catalog.py
markmorado 09186e23b9
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security audit: tenant isolation, injection fixes, hardening and performance
Security
- Enforce guest ownership and server assignment across Proxmox endpoints
  (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy,
  LXC/ISO creation, golden template) and WebSocket channels
- Keep the panel's PVE root ticket server-side for consoles; single-use
  WebSocket tickets instead of the JWT in URLs; re-check access on open
  WebSocket bridges
- Validate identifiers that reach Proxmox API paths and node shell
  commands; reject backup archives / foreign volumes in create requests
- Close privilege escalation in user management, TOTP replay, reset-link
  poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos)
- SSH host key pinning (TOFU), encrypted 2FA seeds and notification
  secrets, password changes end other sessions, redact secrets in logs
- Quotas count in-flight creations; 2FA failures count toward lockout

Fixes
- Consoles on non-default API ports, large App Store/script files,
  interrupted tasks after restart, impersonation lost on token refresh,
  HTTPExceptions swallowed into 500s, WebSocket client reconnects

Performance and process
- Fewer DB writes and queries on hot paths, lazy-loaded locales and
  heavy pages, off-loop blocking work, indexes (migrations 44-46)
- Deploy waits for backend tests and frontend build; frontend checks
  workflow; API bound to loopback without the SSL profile
2026-10-23 00:04:16 +05:00

188 lines
7.7 KiB
Python

"""
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),
):
# Публичный: логотипы — брендовые картинки, не секреты; тег <img> не шлёт 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 <img> 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)