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 - 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
188 lines
7.7 KiB
Python
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)
|