Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
mods/ subdir instead of server root, migrate old string-list format to
dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
call through the mod_manager capability; pass is_server_mod to
build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
instead of bare strings
Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
in split-pane selector
Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
and README files (idempotent)
Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
77 lines
2.4 KiB
Python
77 lines
2.4 KiB
Python
"""Game-agnostic file operations."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
def get_server_dir(server_id: int) -> Path:
|
|
"""Return the absolute directory path for a server's data."""
|
|
from config import settings
|
|
base = Path(settings.servers_dir).resolve()
|
|
return base / str(server_id)
|
|
|
|
|
|
def ensure_server_dirs(
|
|
server_id: int,
|
|
layout: list[str] | None = None,
|
|
readme_provider: Callable[[str], str | None] | None = None,
|
|
) -> None:
|
|
"""
|
|
Create servers/{id}/ and any subdirectories from adapter layout.
|
|
If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
|
|
"""
|
|
server_dir = get_server_dir(server_id)
|
|
server_dir.mkdir(parents=True, exist_ok=True)
|
|
if layout:
|
|
for subdir in layout:
|
|
subdir_path = server_dir / subdir
|
|
subdir_path.mkdir(parents=True, exist_ok=True)
|
|
if readme_provider:
|
|
content = readme_provider(subdir)
|
|
if content:
|
|
readme_path = subdir_path / "README.txt"
|
|
if not readme_path.exists():
|
|
readme_path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def safe_delete_file(path: Path) -> bool:
|
|
"""Delete a file if it exists. Returns True if deleted."""
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def sanitize_filename(filename: str) -> str:
|
|
"""
|
|
Sanitize a filename for safe disk storage.
|
|
|
|
Rules:
|
|
- Strip path separators (/ \\ and ..)
|
|
- Allow only alphanumeric, dots, hyphens, underscores, @ signs
|
|
- Collapse consecutive dots (prevent ../ tricks)
|
|
- Truncate to 255 characters
|
|
- Raise ValueError if the result is empty
|
|
"""
|
|
# Take only the basename — strip any directory components
|
|
filename = filename.replace("\\", "/").split("/")[-1]
|
|
|
|
# Remove null bytes and control characters
|
|
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
|
|
|
|
# Allow only safe characters: alphanum, dot, hyphen, underscore, @
|
|
filename = re.sub(r"[^\w.\-@]", "_", filename)
|
|
|
|
# Collapse consecutive dots to prevent tricks like ".../.."
|
|
filename = re.sub(r"\.{2,}", ".", filename)
|
|
|
|
# Truncate
|
|
filename = filename[:255]
|
|
|
|
if not filename or filename in (".", ".."):
|
|
raise ValueError(f"Filename '{filename}' is not safe for storage")
|
|
|
|
return filename |