diff --git a/muxplex/main.py b/muxplex/main.py index 6b85f9b..4946b72 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -30,7 +30,7 @@ import websockets from websockets.typing import Subprotocol from fastapi import FastAPI, Form, HTTPException, Request, WebSocket -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, field_validator from starlette.responses import RedirectResponse @@ -65,6 +65,7 @@ from muxplex.state import ( state_lock, ) from muxplex.settings import ( + apply_synced_settings, get_syncable_settings, load_federation_key, load_settings, @@ -359,6 +360,11 @@ class CreateSessionPayload(BaseModel): return stripped +class SettingsSyncPayload(BaseModel): + settings: dict + settings_updated_at: float + + # --------------------------------------------------------------------------- # Frontend directory + hostname # --------------------------------------------------------------------------- @@ -739,6 +745,36 @@ async def get_settings_sync() -> dict: return {"settings": settings, "settings_updated_at": ts} +@app.put("/api/settings/sync") +async def put_settings_sync(payload: SettingsSyncPayload): + """Accept synced settings from a remote server (newer-wins). + + Compares the incoming timestamp against the local settings_updated_at. + If the incoming timestamp is strictly newer, applies only the syncable + keys via apply_synced_settings() and returns 200 with the final state. + If the incoming timestamp is equal to or older than the local one, returns + 409 (Conflict) with the current local state so the caller can see what + this instance has. + """ + current = load_settings() + local_ts: float = current.get("settings_updated_at", 0.0) + + if payload.settings_updated_at > local_ts: + apply_synced_settings(payload.settings, payload.settings_updated_at) + syncable = get_syncable_settings() + ts = syncable.get("settings_updated_at", 0.0) + settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"} + return {"settings": settings_out, "settings_updated_at": ts} + else: + syncable = get_syncable_settings() + ts = syncable.get("settings_updated_at", 0.0) + settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"} + return JSONResponse( + status_code=409, + content={"settings": settings_out, "settings_updated_at": ts}, + ) + + @app.get("/api/instance-info") async def instance_info() -> dict: """Return this instance's display name and version. diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index bb95258..eee77c6 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -2915,3 +2915,124 @@ def test_federation_auth_headers_guard_empty_key(): "use `{...} if key else {}` to skip the header when key is empty:\n" + "\n".join(f" {line}" for line in offending) ) + + +# --------------------------------------------------------------------------- +# PUT /api/settings/sync (task-8) +# --------------------------------------------------------------------------- + + +def test_put_settings_sync_applies_when_newer(client, tmp_path, monkeypatch): + """PUT /api/settings/sync applies settings when incoming timestamp is newer.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + # Set local timestamp to something old + settings_path.write_text(json.dumps({"settings_updated_at": 1000.0})) + + response = client.put( + "/api/settings/sync", + json={"settings": {"fontSize": 20}, "settings_updated_at": 2000.0}, + ) + assert response.status_code == 200 + data = response.json() + assert "settings" in data, f"Response must have 'settings' key, got: {data}" + assert "settings_updated_at" in data, ( + f"Response must have 'settings_updated_at' key, got: {data}" + ) + assert data["settings_updated_at"] == 2000.0, ( + f"Timestamp must be 2000.0, got: {data['settings_updated_at']}" + ) + assert data["settings"]["fontSize"] == 20, ( + f"fontSize must be 20, got: {data['settings'].get('fontSize')}" + ) + + +def test_put_settings_sync_rejects_when_older(client, tmp_path, monkeypatch): + """PUT /api/settings/sync returns 409 when incoming timestamp is older.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + # Set local timestamp to something newer than the incoming + settings_path.write_text(json.dumps({"settings_updated_at": 2000.0})) + + response = client.put( + "/api/settings/sync", + json={"settings": {"fontSize": 18}, "settings_updated_at": 1000.0}, + ) + assert response.status_code == 409 + data = response.json() + assert "settings" in data, f"409 body must have 'settings' key, got: {data}" + assert "settings_updated_at" in data, ( + f"409 body must have 'settings_updated_at' key, got: {data}" + ) + # Should return local state, which has the newer timestamp + assert data["settings_updated_at"] == 2000.0, ( + f"409 body must return local timestamp 2000.0, got: {data['settings_updated_at']}" + ) + + +def test_put_settings_sync_rejects_when_equal(client, tmp_path, monkeypatch): + """PUT /api/settings/sync returns 409 when timestamps are equal.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + # Set local timestamp + settings_path.write_text(json.dumps({"settings_updated_at": 1500.0})) + + response = client.put( + "/api/settings/sync", + json={"settings": {"fontSize": 16}, "settings_updated_at": 1500.0}, + ) + assert response.status_code == 409, ( + f"Equal timestamps must return 409, got {response.status_code}" + ) + data = response.json() + assert data["settings_updated_at"] == 1500.0, ( + f"409 body must return local timestamp 1500.0, got: {data.get('settings_updated_at')}" + ) + + +def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatch): + """PUT /api/settings/sync does not apply non-syncable keys like host.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + # Start with default settings (host defaults to "127.0.0.1") + settings_path.write_text(json.dumps({"settings_updated_at": 0.0})) + + response = client.put( + "/api/settings/sync", + json={ + "settings": {"fontSize": 20, "host": "evil.com"}, + "settings_updated_at": 9999999999.0, + }, + ) + assert response.status_code == 200 + + # Verify fontSize (syncable) was changed + local = settings_mod.load_settings() + assert local["fontSize"] == 20, ( + f"Syncable key 'fontSize' must be updated to 20, got: {local['fontSize']}" + ) + + # Verify host (non-syncable) was NOT changed + assert local["host"] == "127.0.0.1", ( + f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}" + )