Files
languard-servers-manager/backend/adapters/arma3/mission_manager.py
Tran G. (Revernomad) Khoa 6511353b55 feat: implement full backend + frontend server detail, settings, and create server pages
Backend:
- Complete FastAPI backend with 42+ REST endpoints (auth, servers, config,
  players, bans, missions, mods, games, system)
- Game adapter architecture with Arma 3 as first-class adapter
- WebSocket real-time events for status, metrics, logs, players
- Background thread system (process monitor, metrics, log tail, RCon poller)
- Fernet encryption for sensitive config fields at rest
- JWT auth with admin/viewer roles, bcrypt password hashing
- SQLite with WAL mode, parameterized queries, migration system
- APScheduler cleanup jobs for logs, metrics, events

Frontend:
- Server Detail page with 7 tabs (overview, config, players, bans,
  missions, mods, logs)
- Settings page with password change and admin user management
- Create Server wizard (4-step; known bug: silent validation failure)
- New hooks: useServerDetail, useAuth, useGames
- New components: ServerHeader, ConfigEditor, PlayerTable, BanTable,
  MissionList, ModList, LogViewer, PasswordChange, UserManager
- WebSocket onEvent callback for real-time log accumulation
- 120 unit tests passing (Vitest + React Testing Library)

Docs:
- Added .gitignore, CLAUDE.md, README.md
- Updated FRONTEND.md, ARCHITECTURE.md with current implementation state
- Added .env.example for backend configuration

Known issues:
- Create Server form: "Next" buttons don't validate before advancing,
  causing silent submit failure when fields are invalid
- Config sub-tabs need UX redesign for non-technical users
2026-04-17 11:58:34 +07:00

191 lines
6.2 KiB
Python

"""Arma 3 mission manager — handles .pbo mission files, upload, delete, rotation."""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
from pydantic import BaseModel
from adapters.exceptions import AdapterError
from core.utils.file_utils import get_server_dir, sanitize_filename, safe_delete_file
logger = logging.getLogger(__name__)
_MISSIONS_DIR = "mpmissions"
_ALLOWED_EXTENSION = ".pbo"
_MAX_MISSION_SIZE_MB = 500
class Arma3MissionData(BaseModel):
"""Mission data schema for Arma 3."""
terrain: str = ""
difficulty: str = "Regular"
class Arma3MissionManager:
file_extension = ".pbo"
def __init__(self, server_id: int | None = None) -> None:
self._server_id = server_id
def _missions_dir(self) -> Path:
return get_server_dir(self._server_id) / _MISSIONS_DIR
# ── File operations ──
def list_missions(self) -> list[dict]:
"""
Scan the mpmissions directory and return all .pbo files.
Returns list of dicts:
name: str — filename without extension
filename: str — full filename
size_bytes: int — file size
"""
missions_dir = self._missions_dir()
if not missions_dir.exists():
return []
missions = []
try:
for entry in missions_dir.iterdir():
if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION:
missions.append({
"name": entry.stem,
"filename": entry.name,
"size_bytes": entry.stat().st_size,
})
except OSError as exc:
raise AdapterError(f"Cannot list missions: {exc}") from exc
missions.sort(key=lambda m: m["filename"].lower())
return missions
def upload_mission(self, filename: str, content: bytes) -> dict:
"""
Save a mission file to the mpmissions directory.
Args:
filename: Original filename from the upload (will be sanitized).
content: Raw file bytes.
Returns the saved mission dict.
"""
safe_name = sanitize_filename(filename)
if not safe_name.lower().endswith(_ALLOWED_EXTENSION):
raise AdapterError(
f"Invalid mission file extension. Only {_ALLOWED_EXTENSION} files are allowed."
)
size_mb = len(content) / (1024 * 1024)
if size_mb > _MAX_MISSION_SIZE_MB:
raise AdapterError(
f"Mission file too large ({size_mb:.1f} MB). Max is {_MAX_MISSION_SIZE_MB} MB."
)
missions_dir = self._missions_dir()
missions_dir.mkdir(parents=True, exist_ok=True)
dest_path = missions_dir / safe_name
# Atomic write: write to .tmp first, then replace
tmp_path = str(dest_path) + ".tmp"
try:
with open(tmp_path, "wb") as f:
f.write(content)
os.replace(tmp_path, str(dest_path))
except OSError as exc:
safe_delete_file(Path(tmp_path))
raise AdapterError(f"Cannot save mission file: {exc}") from exc
logger.info(
"Mission uploaded for server %d: %s (%d bytes)",
self._server_id, safe_name, len(content),
)
return {
"name": dest_path.stem,
"filename": safe_name,
"size_bytes": len(content),
}
def delete_mission(self, filename: str) -> bool:
"""
Delete a mission file.
Returns True if deleted, False if not found.
"""
safe_name = sanitize_filename(filename)
if not safe_name.lower().endswith(_ALLOWED_EXTENSION):
raise AdapterError("Invalid mission filename")
dest_path = self._missions_dir() / safe_name
# Verify resolved path is inside missions directory (path traversal guard)
try:
dest_path.resolve().relative_to(self._missions_dir().resolve())
except ValueError:
raise AdapterError("Path traversal detected in filename")
if not dest_path.exists():
return False
try:
dest_path.unlink()
logger.info("Mission deleted for server %d: %s", self._server_id, safe_name)
return True
except OSError as exc:
raise AdapterError(f"Cannot delete mission: {exc}") from exc
# ── Mission rotation config ──
def parse_mission_filename(self, filename: str) -> dict:
"""
Parse Arma 3 mission filename.
Format: MissionName.Terrain.pbo
"""
name = filename
if name.endswith(self.file_extension):
name = name[: -len(self.file_extension)]
parts = name.rsplit(".", 1)
if len(parts) == 2:
return {
"mission_name": parts[0],
"terrain": parts[1],
"filename": filename,
}
return {
"mission_name": name,
"terrain": "",
"filename": filename,
}
def get_rotation_config(self, rotation_entries: list[dict]) -> str:
"""
Generate Arma 3 mission rotation config block.
rotation_entries: list of {mission_name, terrain, difficulty, params_json}
"""
if not rotation_entries:
return ""
lines = ['class Missions {']
for i, entry in enumerate(rotation_entries):
mission = entry.get("mission_name", "")
terrain = entry.get("terrain", "")
difficulty = entry.get("difficulty", "Regular")
params = entry.get("params_json", "{}")
lines.append(f' class Mission_{i} {{')
lines.append(f' template = "{mission}.{terrain}";')
lines.append(f' difficulty = "{difficulty}";')
if params and params != "{}":
lines.append(f' params = {params};')
lines.append(' };')
lines.append('};')
return "\n".join(lines)
def get_missions_dir(self, server_dir: Path) -> Path:
return server_dir / _MISSIONS_DIR
def get_mission_data_schema(self) -> type[BaseModel] | None:
return Arma3MissionData