feat(control-station): add configurable environment shell

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 14:18:08 +03:00
parent 42041b37cd
commit 453d760be4
14 changed files with 1473 additions and 246 deletions
+381
View File
@@ -0,0 +1,381 @@
from __future__ import annotations
import hashlib
import json
import os
import re
from collections.abc import Callable
from pathlib import Path
from threading import Lock
from typing import Annotated, Literal
from urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvironmentSurfaceId = Literal[
"home",
"center",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
]
EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v1"] = (
"missioncore.operator-environment/v1"
)
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1"
] = "missioncore.operator-environment-media/v1"
MAX_ENVIRONMENT_MEDIA_BYTES = 256 * 1024 * 1024
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9А-Яа-яЁё._ -]+")
SUPPORTED_MEDIA_TYPES: dict[str, tuple[EnvironmentMediaKind, str]] = {
"image/avif": ("image", ".avif"),
"image/gif": ("image", ".gif"),
"image/jpeg": ("image", ".jpg"),
"image/png": ("image", ".png"),
"image/webp": ("image", ".webp"),
"video/mp4": ("video", ".mp4"),
"video/quicktime": ("video", ".mov"),
"video/webm": ("video", ".webm"),
}
class StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class EnvironmentHeaderLabels(StrictApiModel):
center: str = Field(min_length=1, max_length=40)
fleet: str = Field(min_length=1, max_length=40)
observation: str = Field(min_length=1, max_length=40)
missions: str = Field(min_length=1, max_length=40)
data: str = Field(min_length=1, max_length=40)
system: str = Field(min_length=1, max_length=40)
polygon: str = Field(min_length=1, max_length=40)
class EnvironmentBackground(StrictApiModel):
enabled: bool = False
source: EnvironmentMediaSource = "file"
url: str | None = Field(default=None, max_length=2048)
media_kind: EnvironmentMediaKind | None = None
file_name: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_source(self) -> EnvironmentBackground:
if not self.enabled:
return self
if not self.url or self.media_kind is None:
raise ValueError("enabled background requires url and media kind")
if self.source == "url":
parsed = urlsplit(self.url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("external background URL must use HTTP or HTTPS")
elif not self.url.startswith("/api/v1/environment/media/"):
raise ValueError("file background must use the Mission Core media endpoint")
return self
class EnvironmentBackgrounds(StrictApiModel):
home: EnvironmentBackground
center: EnvironmentBackground
fleet: EnvironmentBackground
observation: EnvironmentBackground
missions: EnvironmentBackground
data: EnvironmentBackground
system: EnvironmentBackground
polygon: EnvironmentBackground
class EnvironmentSettingsPut(StrictApiModel):
revision: int = Field(ge=0)
header_labels: EnvironmentHeaderLabels
backgrounds: EnvironmentBackgrounds
class EnvironmentSettingsDocument(EnvironmentSettingsPut):
schema_version: Literal["missioncore.operator-environment/v1"] = ENVIRONMENT_SCHEMA_VERSION
class EnvironmentMediaDocument(StrictApiModel):
schema_version: Literal["missioncore.operator-environment-media/v1"] = (
ENVIRONMENT_MEDIA_SCHEMA_VERSION
)
surface_id: EnvironmentSurfaceId
url: str
file_name: str
media_kind: EnvironmentMediaKind
media_type: str
byte_length: int = Field(ge=1, le=MAX_ENVIRONMENT_MEDIA_BYTES)
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
def default_environment_settings() -> EnvironmentSettingsDocument:
background = EnvironmentBackground()
return EnvironmentSettingsDocument(
revision=0,
header_labels=EnvironmentHeaderLabels(
center="Центр",
fleet="Парк",
observation="Наблюдение",
missions="Миссии",
data="Данные",
system="Система",
polygon="Тестировочный контур",
),
backgrounds=EnvironmentBackgrounds(
home=background.model_copy(),
center=background.model_copy(),
fleet=background.model_copy(),
observation=background.model_copy(),
missions=background.model_copy(),
data=background.model_copy(),
system=background.model_copy(),
polygon=background.model_copy(),
),
)
class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
self.settings_path = self.root / "settings.json"
self.media_root = self.root / "media"
self._lock = Lock()
def read(self) -> EnvironmentSettingsDocument:
with self._lock:
if not self.settings_path.is_file():
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return EnvironmentSettingsDocument.model_validate(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
def save(self, request: EnvironmentSettingsPut) -> EnvironmentSettingsDocument:
with self._lock:
current = self._read_unlocked()
if request.revision != current.revision:
raise RuntimeError("operator environment settings revision changed")
document = EnvironmentSettingsDocument(
revision=current.revision + 1,
header_labels=request.header_labels,
backgrounds=request.backgrounds,
)
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = self.settings_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(
document.model_dump(mode="json"),
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
os.replace(temporary, self.settings_path)
return document
def _read_unlocked(self) -> EnvironmentSettingsDocument:
if not self.settings_path.is_file():
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return EnvironmentSettingsDocument.model_validate(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
def media_metadata_path(self, surface_id: EnvironmentSurfaceId) -> Path:
return self.media_root / f"{surface_id}.json"
def read_media(
self,
surface_id: EnvironmentSurfaceId,
generation: str,
) -> tuple[Path, EnvironmentMediaDocument]:
with self._lock:
metadata_path = self.media_metadata_path(surface_id)
try:
metadata = EnvironmentMediaDocument.model_validate_json(
metadata_path.read_text(encoding="utf-8")
)
except (OSError, ValueError) as exc:
raise FileNotFoundError(surface_id) from exc
if generation != metadata.sha256:
raise PermissionError("media generation changed")
path = self.media_root / f"{surface_id}{SUPPORTED_MEDIA_TYPES[metadata.media_type][1]}"
if not path.is_file() or path.stat().st_size != metadata.byte_length:
raise FileNotFoundError(surface_id)
return path, metadata
def finalize_media(
self,
*,
surface_id: EnvironmentSurfaceId,
temporary_path: Path,
file_name: str,
media_type: str,
byte_length: int,
sha256: str,
) -> EnvironmentMediaDocument:
media_kind, extension = SUPPORTED_MEDIA_TYPES[media_type]
with self._lock:
self.media_root.mkdir(mode=0o700, parents=True, exist_ok=True)
destination = self.media_root / f"{surface_id}{extension}"
for stale_path in self.media_root.glob(f"{surface_id}.*"):
if stale_path == self.media_metadata_path(surface_id):
continue
if stale_path != destination and stale_path.is_file():
stale_path.unlink()
os.replace(temporary_path, destination)
document = EnvironmentMediaDocument(
surface_id=surface_id,
url=f"/api/v1/environment/media/{surface_id}?generation={sha256}",
file_name=file_name,
media_kind=media_kind,
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
metadata_path = self.media_metadata_path(surface_id)
metadata_temporary = metadata_path.with_suffix(".json.tmp")
metadata_temporary.write_text(
json.dumps(document.model_dump(mode="json"), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
os.replace(metadata_temporary, metadata_path)
return document
async def _write_upload(
request: Request,
temporary_path: Path,
) -> tuple[int, str]:
digest = hashlib.sha256()
byte_length = 0
try:
with temporary_path.open("xb") as output:
async for chunk in request.stream():
if not chunk:
continue
byte_length += len(chunk)
if byte_length > MAX_ENVIRONMENT_MEDIA_BYTES:
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
output.write(chunk)
digest.update(chunk)
if byte_length == 0:
raise HTTPException(status_code=422, detail="Пустой файл не поддерживается.")
return byte_length, digest.hexdigest()
except BaseException:
temporary_path.unlink(missing_ok=True)
raise
def _safe_file_name(value: str | None, extension: str) -> str:
decoded = unquote(value) if value else f"background{extension}"
candidate = SAFE_FILE_NAME.sub("_", decoded.strip())
if not candidate:
candidate = f"background{extension}"
return candidate[:255]
def build_environment_router(
root_provider: Callable[[], Path],
) -> APIRouter:
router = APIRouter(prefix="/api/v1/environment", tags=["environment"])
def store() -> EnvironmentSettingsStore:
return EnvironmentSettingsStore(root_provider())
@router.get("/settings")
def get_environment_settings() -> EnvironmentSettingsDocument:
try:
return store().read()
except RuntimeError as exc:
raise HTTPException(
status_code=500,
detail="Настройки окружения повреждены.",
) from exc
@router.put("/settings")
def put_environment_settings(
request: EnvironmentSettingsPut,
) -> EnvironmentSettingsDocument:
try:
return store().save(request)
except RuntimeError as exc:
if "revision changed" in str(exc):
raise HTTPException(
status_code=412,
detail="Настройки окружения были изменены в другом окне.",
) from exc
raise HTTPException(
status_code=500,
detail="Настройки окружения повреждены.",
) from exc
@router.put("/media/{surface_id}")
async def upload_environment_media(
surface_id: EnvironmentSurfaceId,
request: Request,
file_name: Annotated[str | None, Header(alias="X-NODEDC-File-Name")] = None,
) -> EnvironmentMediaDocument:
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type not in SUPPORTED_MEDIA_TYPES:
raise HTTPException(
status_code=415,
detail="Поддерживаются PNG, JPEG, WebP, GIF, AVIF, MP4, WebM и MOV.",
)
content_length = request.headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > MAX_ENVIRONMENT_MEDIA_BYTES:
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
except ValueError as exc:
raise HTTPException(status_code=422, detail="Некорректный размер файла.") from exc
environment_store = store()
environment_store.media_root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary_path = environment_store.media_root / f".{surface_id}.{os.getpid()}.upload"
temporary_path.unlink(missing_ok=True)
byte_length, sha256 = await _write_upload(request, temporary_path)
extension = SUPPORTED_MEDIA_TYPES[media_type][1]
return environment_store.finalize_media(
surface_id=surface_id,
temporary_path=temporary_path,
file_name=_safe_file_name(file_name, extension),
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
@router.get("/media/{surface_id}")
def get_environment_media(
surface_id: EnvironmentSurfaceId,
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
) -> FileResponse:
try:
path, metadata = store().read_media(surface_id, generation)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Фон окружения не найден.") from exc
except PermissionError as exc:
raise HTTPException(status_code=412, detail="Фон окружения был заменён.") from exc
return FileResponse(
path,
media_type=metadata.media_type,
headers={
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
"ETag": f'"{metadata.sha256}"',
"X-Content-Type-Options": "nosniff",
},
)
return router