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
240 lines
8.1 KiB
Python
240 lines
8.1 KiB
Python
"""
|
||
Каталог скриптов — CRUD своих скриптов + управление git-источниками.
|
||
|
||
GET /api/scripts — список каталога
|
||
POST /api/scripts — создать свой скрипт (source=manual)
|
||
GET /api/scripts/{id} — детали + содержимое
|
||
PUT /api/scripts/{id} — редактировать (только manual)
|
||
DELETE /api/scripts/{id} — удалить (только manual)
|
||
GET /api/scripts/repos — список git-источников
|
||
POST /api/scripts/repos — добавить git-источник
|
||
POST /api/scripts/repos/{id}/sync — запустить синк
|
||
DELETE /api/scripts/repos/{id} — удалить источник (и его скрипты)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field, field_validator
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ...auth import PermissionChecker
|
||
from ...db import get_db
|
||
from ...models import ScriptCatalog, ScriptGitRepo, User
|
||
from ...services import script_catalog_sync, script_engine
|
||
|
||
router = APIRouter()
|
||
|
||
_SLUG_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
|
||
|
||
|
||
class ParamField(BaseModel):
|
||
name: str
|
||
label: str
|
||
type: str = "string"
|
||
required: bool = False
|
||
default: Optional[str] = None
|
||
|
||
@field_validator("name")
|
||
@classmethod
|
||
def _check_name(cls, v: str) -> str:
|
||
# Parameters become `export NAME=...` lines in the rendered script.
|
||
if not script_engine.is_valid_param_name(v):
|
||
raise ValueError("Parameter name must match [A-Za-z_][A-Za-z0-9_]*")
|
||
return v
|
||
|
||
|
||
def _check_interpreter(v: Optional[str]) -> Optional[str]:
|
||
if v is not None and not script_engine.is_valid_interpreter(v):
|
||
raise ValueError("Interpreter must be an absolute path like /bin/bash")
|
||
return v
|
||
|
||
|
||
class ScriptCreateRequest(BaseModel):
|
||
name: str
|
||
slug: str
|
||
description: Optional[str] = None
|
||
category: Optional[str] = None
|
||
target_type: str = Field(default="guest", pattern="^(node|guest)$")
|
||
interpreter: str = "/bin/bash"
|
||
content: str
|
||
params_schema: List[ParamField] = []
|
||
|
||
_interpreter = field_validator("interpreter")(_check_interpreter)
|
||
|
||
|
||
class ScriptUpdateRequest(BaseModel):
|
||
name: Optional[str] = None
|
||
description: Optional[str] = None
|
||
category: Optional[str] = None
|
||
target_type: Optional[str] = Field(default=None, pattern="^(node|guest)$")
|
||
interpreter: Optional[str] = None
|
||
content: Optional[str] = None
|
||
params_schema: Optional[List[ParamField]] = None
|
||
|
||
_interpreter = field_validator("interpreter")(_check_interpreter)
|
||
|
||
|
||
class RepoCreateRequest(BaseModel):
|
||
url: str
|
||
branch: str = "main"
|
||
path_glob: str = "**/*.sh"
|
||
metadata_format: str = Field(default="header-comment", pattern="^(header-comment|community-scripts-ct)$")
|
||
enabled: bool = True
|
||
|
||
@field_validator("url")
|
||
@classmethod
|
||
def _check_url(cls, v: str) -> str:
|
||
return script_catalog_sync.validate_repo_url(v)
|
||
|
||
@field_validator("branch")
|
||
@classmethod
|
||
def _check_branch(cls, v: str) -> str:
|
||
return script_catalog_sync.validate_branch(v)
|
||
|
||
@field_validator("path_glob")
|
||
@classmethod
|
||
def _check_glob(cls, v: str) -> str:
|
||
return script_catalog_sync.validate_path_glob(v)
|
||
|
||
|
||
@router.get("/api/scripts")
|
||
def list_scripts(
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:view")),
|
||
):
|
||
items = db.query(ScriptCatalog).order_by(ScriptCatalog.name).all()
|
||
return {"items": [s.to_dict() for s in items]}
|
||
|
||
|
||
@router.post("/api/scripts")
|
||
def create_script(
|
||
req: ScriptCreateRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
if not _SLUG_RE.match(req.slug):
|
||
raise HTTPException(status_code=400, detail="Некорректный slug (a-z0-9-)")
|
||
if db.query(ScriptCatalog).filter(ScriptCatalog.slug == req.slug).first():
|
||
raise HTTPException(status_code=409, detail="Скрипт с таким slug уже существует")
|
||
|
||
script = ScriptCatalog(
|
||
name=req.name, slug=req.slug, description=req.description,
|
||
category=req.category, target_type=req.target_type,
|
||
interpreter=req.interpreter or "/bin/bash", content=req.content,
|
||
params_schema=[p.model_dump() for p in req.params_schema],
|
||
source="manual", created_by=current_user.id,
|
||
)
|
||
db.add(script)
|
||
db.commit()
|
||
db.refresh(script)
|
||
return script.to_dict(with_content=True)
|
||
|
||
|
||
@router.get("/api/scripts/repos")
|
||
def list_repos(
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
items = db.query(ScriptGitRepo).order_by(ScriptGitRepo.id).all()
|
||
return {"items": [r.to_dict() for r in items]}
|
||
|
||
|
||
@router.post("/api/scripts/repos")
|
||
def create_repo(
|
||
req: RepoCreateRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
repo = ScriptGitRepo(
|
||
url=req.url, branch=req.branch, path_glob=req.path_glob,
|
||
metadata_format=req.metadata_format, enabled=req.enabled,
|
||
)
|
||
db.add(repo)
|
||
db.commit()
|
||
db.refresh(repo)
|
||
return repo.to_dict()
|
||
|
||
|
||
@router.post("/api/scripts/repos/{repo_id}/sync")
|
||
def sync_repo(
|
||
repo_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
repo = db.get(ScriptGitRepo, repo_id)
|
||
if not repo:
|
||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||
result = script_catalog_sync.sync_git_repo(db, repo)
|
||
return {"success": "error" not in result, **result}
|
||
|
||
|
||
@router.delete("/api/scripts/repos/{repo_id}")
|
||
def delete_repo(
|
||
repo_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
repo = db.get(ScriptGitRepo, repo_id)
|
||
if not repo:
|
||
raise HTTPException(status_code=404, detail="Источник не найден")
|
||
db.delete(repo)
|
||
db.commit()
|
||
return {"success": True}
|
||
|
||
|
||
@router.get("/api/scripts/{script_id}")
|
||
def get_script(
|
||
script_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:view")),
|
||
):
|
||
script = db.get(ScriptCatalog, script_id)
|
||
if not script:
|
||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||
return script.to_dict(with_content=True)
|
||
|
||
|
||
@router.put("/api/scripts/{script_id}")
|
||
def update_script(
|
||
script_id: int,
|
||
req: ScriptUpdateRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
script = db.get(ScriptCatalog, script_id)
|
||
if not script:
|
||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||
if script.source != "manual":
|
||
raise HTTPException(status_code=400, detail="Скрипты из git-источника нельзя редактировать вручную")
|
||
|
||
for field in ("name", "description", "category", "target_type", "interpreter", "content"):
|
||
value = getattr(req, field)
|
||
if value is not None:
|
||
setattr(script, field, value)
|
||
if req.params_schema is not None:
|
||
script.params_schema = [p.model_dump() for p in req.params_schema]
|
||
|
||
db.commit()
|
||
db.refresh(script)
|
||
return script.to_dict(with_content=True)
|
||
|
||
|
||
@router.delete("/api/scripts/{script_id}")
|
||
def delete_script(
|
||
script_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(PermissionChecker("script:manage")),
|
||
):
|
||
script = db.get(ScriptCatalog, script_id)
|
||
if not script:
|
||
raise HTTPException(status_code=404, detail="Скрипт не найден")
|
||
if script.source != "manual":
|
||
raise HTTPException(status_code=400, detail="Скрипты из git-источника удаляются вместе с источником")
|
||
db.delete(script)
|
||
db.commit()
|
||
return {"success": True}
|