From 9ab587466cdf79f6ace8f486b6612b2b6fd67d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= <78488229+viktor138irk@users.noreply.github.com> Date: Wed, 6 May 2026 21:15:53 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20ISO-=D0=BC=D0=B5=D0=BD=D0=B5=D0=B4=D0=B6=D0=B5=D1=80?= =?UTF-8?q?=20=D0=B2=20web-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/app.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/web/app.py b/web/app.py index 33ffd91..3925f19 100644 --- a/web/app.py +++ b/web/app.py @@ -3,13 +3,14 @@ import crypt import os import re import secrets +import shutil import spwd import subprocess from datetime import datetime from pathlib import Path from typing import Any -from fastapi import FastAPI, Request, Form +from fastapi import FastAPI, Request, Form, UploadFile, File from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -134,13 +135,41 @@ def list_iso_files() -> list[dict[str, str]]: files = [] for item in sorted(ISO_DIR.glob("*.iso")): try: - size_mb = item.stat().st_size / 1024 / 1024 + stat = item.stat() + size_mb = stat.st_size / 1024 / 1024 + updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") except OSError: size_mb = 0 - files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB"}) + updated = "unknown" + files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated}) return files +def safe_iso_filename(filename: str) -> str | None: + name = Path(filename or "").name.strip().replace(" ", "-") + name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name) + if not name.lower().endswith(".iso"): + return None + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name): + return None + return name + + +def iso_path_by_name(name: str) -> Path | None: + safe_name = safe_iso_filename(name) + if not safe_name: + return None + path = (ISO_DIR / safe_name).resolve() + iso_root = ISO_DIR.resolve() + if iso_root not in path.parents: + return None + return path + + +def refresh_iso_pool() -> None: + run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20) + + def valid_vm_name(name: str) -> bool: return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or "")) @@ -216,6 +245,59 @@ def dashboard(request: Request): return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": vms, "pools": pools, "network": network_summary(), "user": AUTH_USER}) +@app.get("/iso", response_class=HTMLResponse) +def iso_page(request: Request, error: str | None = None): + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect + return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error}) + + +@app.post("/iso/upload", response_class=HTMLResponse) +def iso_upload(request: Request, iso_file: UploadFile = File(...)): + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect + safe_name = safe_iso_filename(iso_file.filename or "") + if not safe_name: + return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400) + ISO_DIR.mkdir(parents=True, exist_ok=True) + target = ISO_DIR / safe_name + if target.exists(): + return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400) + tmp_target = ISO_DIR / f".{safe_name}.uploading" + try: + with tmp_target.open("wb") as out: + shutil.copyfileobj(iso_file.file, out) + tmp_target.rename(target) + refresh_iso_pool() + except Exception as exc: + tmp_target.unlink(missing_ok=True) + return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}"}, status_code=500) + return RedirectResponse(url="/iso", status_code=303) + + +@app.post("/iso/{name}/delete") +def iso_delete(request: Request, name: str): + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect + path = iso_path_by_name(name) + if path and path.exists() and path.is_file(): + path.unlink() + refresh_iso_pool() + return RedirectResponse(url="/iso", status_code=303) + + +@app.post("/iso/refresh") +def iso_refresh(request: Request): + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect + refresh_iso_pool() + return RedirectResponse(url="/iso", status_code=303) + + # Важно: статический маршрут /vm/create должен быть выше динамического /vm/{name}. # Иначе FastAPI воспринимает слово "create" как имя виртуальной машины. @app.get("/vm/create", response_class=HTMLResponse)