feat(control-station): add configurable environment shell
This commit is contained in:
@@ -33,6 +33,7 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
@@ -398,6 +399,11 @@ app.include_router(
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_environment_router(
|
||||
root_provider=lambda: session_store.data_dir / "ui-environment"
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_polygon_router(
|
||||
root_provider=lambda: configured_polygon_runs_root()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user