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
This commit is contained in:
163
backend/core/dal/config_repository.py
Normal file
163
backend/core/dal/config_repository.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
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}
|
||||
Reference in New Issue
Block a user