odysseus/routes/prefs_routes.py
Amir Fathi c095699720
fix(core): stop atomic writes from colliding on a constant PID suffix
atomic_write_json/atomic_write_text build their temp filename as
"{path}.tmp.{os.getpid()}". os.getpid() is constant for the life of a
process, so it only ever distinguishes concurrent writers that live in
different OS processes. Odysseus runs as a single long-lived process
per container, so two concurrent writers to the same path (e.g. two
request handlers racing a settings save) always compute the identical
temp path. Whichever finishes os.replace() first removes the shared
tmp file out from under the other, which then raises FileNotFoundError
on its own os.replace() instead of landing its write.

Fix: derive the temp suffix from uuid4() instead of the PID, so every
call gets a distinct temp path regardless of process/thread identity.

routes/prefs_routes.py's _save() had an independent, hand-rolled copy
of the exact same PID-suffix logic (not the shared core.atomic_io
helper other routes already use, e.g. routes/auth_routes.py) with the
same bug. Replaced it with a call to atomic_write_json.

Fixes #5596
2026-07-23 20:00:41 +00:00

85 lines
2.8 KiB
Python

"""User preferences API — per-user key/value store backed by a JSON file."""
import json
from typing import Optional
from fastapi import APIRouter, Request
from core.atomic_io import atomic_write_json
from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE
PREFS_FILE = USER_PREFS_FILE
def _load():
"""Load the raw prefs file (internal use only)."""
try:
with open(PREFS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save(prefs):
atomic_write_json(PREFS_FILE, prefs, indent=2)
def _load_for_user(user: Optional[str] = None) -> dict:
"""Load preferences for a specific user."""
all_prefs = _load()
if "_users" in all_prefs:
if user is None:
# Auth disabled — return first user's prefs for backward compat
users = all_prefs["_users"]
return dict(next(iter(users.values()), {}))
return dict(all_prefs["_users"].get(user, {}))
# Legacy flat format — return as-is
return dict(all_prefs)
def _save_for_user(user: Optional[str], prefs: dict):
"""Save preferences for a specific user."""
all_prefs = _load()
if user is None:
# Auth disabled. If the store is already multi-user (e.g. auth was
# turned off on a deployment that previously ran multi-user), writing
# `prefs` flat would overwrite the whole `_users` map and destroy every
# other user's preferences. Instead write back into the same (first)
# slot _load_for_user(None) reads from, preserving the others.
if "_users" in all_prefs:
users = all_prefs["_users"]
first_key = next(iter(users), None)
if first_key is not None:
users[first_key] = prefs
_save(all_prefs)
return
_save(prefs)
return
if "_users" not in all_prefs:
all_prefs = {"_users": {}}
all_prefs["_users"][user] = prefs
_save(all_prefs)
def setup_prefs_routes():
router = APIRouter(prefix="/api/prefs", tags=["preferences"])
@router.get("")
async def get_all_prefs(request: Request):
user = get_current_user(request)
return _load_for_user(user)
@router.get("/{key}")
async def get_pref(request: Request, key: str):
user = get_current_user(request)
prefs = _load_for_user(user)
return {"key": key, "value": prefs.get(key)}
@router.put("/{key}")
async def set_pref(request: Request, key: str, body: dict):
user = get_current_user(request)
prefs = _load_for_user(user)
prefs[key] = body.get("value")
_save_for_user(user, prefs)
return {"key": key, "value": prefs[key]}
return router