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
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""
|
|
Manages the game_configs table.
|
|
Handles Fernet encryption/decryption of sensitive fields transparently.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from core.dal.base_repository import BaseRepository
|
|
from core.utils.crypto import decrypt, encrypt, is_encrypted
|
|
|
|
|
|
class ConfigRepository(BaseRepository):
|
|
|
|
def _encrypt_sensitive(
|
|
self, config: dict, sensitive_fields: list[str]
|
|
) -> dict:
|
|
"""Return new dict with sensitive fields encrypted."""
|
|
result = dict(config)
|
|
for field in sensitive_fields:
|
|
if field in result and result[field] and not is_encrypted(str(result[field])):
|
|
result[field] = encrypt(str(result[field]))
|
|
return result
|
|
|
|
def _decrypt_sensitive(
|
|
self, config: dict, sensitive_fields: list[str]
|
|
) -> dict:
|
|
"""Return new dict with sensitive fields decrypted."""
|
|
result = dict(config)
|
|
for field in sensitive_fields:
|
|
if field in result and is_encrypted(str(result[field])):
|
|
result[field] = decrypt(str(result[field]))
|
|
return result
|
|
|
|
def get_section(
|
|
self,
|
|
server_id: int,
|
|
section: str,
|
|
sensitive_fields: list[str] | None = None,
|
|
) -> dict | None:
|
|
"""Get a config section. Decrypts sensitive fields automatically."""
|
|
row = self._fetchone(
|
|
"SELECT * FROM game_configs WHERE server_id = :sid AND section = :sec",
|
|
{"sid": server_id, "sec": section},
|
|
)
|
|
if row is None:
|
|
return None
|
|
config = json.loads(row["config_json"])
|
|
if sensitive_fields:
|
|
config = self._decrypt_sensitive(config, sensitive_fields)
|
|
config["_meta"] = {
|
|
"config_version": row["config_version"],
|
|
"schema_version": row["schema_version"],
|
|
}
|
|
return config
|
|
|
|
def get_all_sections(
|
|
self,
|
|
server_id: int,
|
|
sensitive_fields_by_section: dict[str, list[str]] | None = None,
|
|
) -> dict[str, dict]:
|
|
"""Get all config sections for a server."""
|
|
rows = self._fetchall(
|
|
"SELECT * FROM game_configs WHERE server_id = :sid ORDER BY section",
|
|
{"sid": server_id},
|
|
)
|
|
result = {}
|
|
for row in rows:
|
|
config = json.loads(row["config_json"])
|
|
sf = (sensitive_fields_by_section or {}).get(row["section"], [])
|
|
if sf:
|
|
config = self._decrypt_sensitive(config, sf)
|
|
config["_meta"] = {
|
|
"config_version": row["config_version"],
|
|
"schema_version": row["schema_version"],
|
|
}
|
|
result[row["section"]] = config
|
|
return result
|
|
|
|
def upsert_section(
|
|
self,
|
|
server_id: int,
|
|
game_type: str,
|
|
section: str,
|
|
config_data: dict,
|
|
schema_version: str,
|
|
sensitive_fields: list[str] | None = None,
|
|
expected_config_version: int | None = None,
|
|
) -> int:
|
|
"""
|
|
Upsert a config section.
|
|
If expected_config_version is provided, checks optimistic lock.
|
|
Returns the new config_version.
|
|
Raises ValueError on version conflict (caller returns 409).
|
|
"""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Strip _meta before storing
|
|
data_to_store = {k: v for k, v in config_data.items() if k != "_meta"}
|
|
|
|
# Encrypt sensitive fields
|
|
if sensitive_fields:
|
|
data_to_store = self._encrypt_sensitive(data_to_store, sensitive_fields)
|
|
|
|
# Check if row exists
|
|
existing = self._fetchone(
|
|
"SELECT id, config_version FROM game_configs WHERE server_id = :sid AND section = :sec",
|
|
{"sid": server_id, "sec": section},
|
|
)
|
|
|
|
if existing is None:
|
|
# Insert
|
|
self._execute(
|
|
"""
|
|
INSERT INTO game_configs
|
|
(server_id, game_type, section, config_json, config_version, schema_version, updated_at)
|
|
VALUES (:sid, :gt, :sec, :json, 1, :sv, :now)
|
|
""",
|
|
{
|
|
"sid": server_id, "gt": game_type, "sec": section,
|
|
"json": json.dumps(data_to_store), "sv": schema_version, "now": now,
|
|
},
|
|
)
|
|
return 1
|
|
else:
|
|
current_version = existing["config_version"]
|
|
if expected_config_version is not None and expected_config_version != current_version:
|
|
raise ValueError(
|
|
f"CONFIG_VERSION_CONFLICT:{current_version}"
|
|
)
|
|
new_version = current_version + 1
|
|
self._execute(
|
|
"""
|
|
UPDATE game_configs
|
|
SET config_json = :json, config_version = :cv,
|
|
schema_version = :sv, updated_at = :now
|
|
WHERE server_id = :sid AND section = :sec
|
|
""",
|
|
{
|
|
"json": json.dumps(data_to_store),
|
|
"cv": new_version,
|
|
"sv": schema_version,
|
|
"now": now,
|
|
"sid": server_id,
|
|
"sec": section,
|
|
},
|
|
)
|
|
return new_version
|
|
|
|
def delete_sections(self, server_id: int) -> None:
|
|
self._execute(
|
|
"DELETE FROM game_configs WHERE server_id = :sid",
|
|
{"sid": server_id},
|
|
)
|
|
|
|
def get_raw_sections(self, server_id: int) -> dict[str, dict]:
|
|
"""Get all sections without decryption — for config file generation."""
|
|
rows = self._fetchall(
|
|
"SELECT section, config_json FROM game_configs WHERE server_id = :sid",
|
|
{"sid": server_id},
|
|
)
|
|
return {row["section"]: json.loads(row["config_json"]) for row in rows} |