From 2d74be984f438bebf6c4d6ce4a6bc6aa9ac63802 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:36:38 +0000 Subject: [PATCH] feat(settings): publish user_settings change events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user_settings realtime channel so clients learn when a setting changed on another device without polling. The channel is modeled on user_state: non-admin subscribable, per-user addressed envelopes, null snapshot. SettingValuesHandler gains an EventsHub and publishes user_settings.changed after every successful PUT and DELETE on /settings/values/{key}. The payload carries only key, scope and profile_id — never the value. Admins receive every user's user-scoped events, so a value in the payload would leak private settings to admins; interested clients re-fetch over the scoped REST API instead. The payload is always non-empty because an empty Data falls back to a null snapshot in the hub. A nil hub (tests) skips publishing. Co-Authored-By: Claude Fable 5 --- internal/api/handlers/events_ws.go | 1 + internal/api/handlers/settings_values.go | 9 ++ internal/api/handlers/user_settings_events.go | 41 ++++++ .../api/handlers/user_settings_events_test.go | 118 ++++++++++++++++++ internal/api/router.go | 1 + internal/events/types.go | 2 + 6 files changed, 172 insertions(+) create mode 100644 internal/api/handlers/user_settings_events.go create mode 100644 internal/api/handlers/user_settings_events_test.go diff --git a/internal/api/handlers/events_ws.go b/internal/api/handlers/events_ws.go index e22789a5..52426179 100644 --- a/internal/api/handlers/events_ws.go +++ b/internal/api/handlers/events_ws.go @@ -334,6 +334,7 @@ func allowedChannelsForRole(role string) []evt.EventChannel { evt.ChannelCatalog, evt.ChannelHistoryImport, evt.ChannelUserState, + evt.ChannelUserSettings, evt.ChannelNotifications, } if role == "admin" { diff --git a/internal/api/handlers/settings_values.go b/internal/api/handlers/settings_values.go index 03d123bb..deb6e326 100644 --- a/internal/api/handlers/settings_values.go +++ b/internal/api/handlers/settings_values.go @@ -14,6 +14,7 @@ import ( "github.com/Silo-Server/silo-server/internal/access" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/settingscontract" "github.com/Silo-Server/silo-server/internal/settingsresolve" "github.com/Silo-Server/silo-server/internal/userstore" @@ -31,6 +32,10 @@ type SettingValuesHandler struct { storeProvider userstore.UserStoreProvider contract *settingscontract.Manifest resolver *settingsresolve.Resolver + + // EventsHub, when set, receives a user_settings.changed event after every + // successful write or delete. Nil (as in tests) simply skips publishing. + EventsHub *evt.Hub } // NewSettingValuesHandler builds the handler over the embedded contract. @@ -241,6 +246,8 @@ func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Req writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting") return } + publishUserSettingsEvent(r.Context(), h.EventsHub, + apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope)) writeJSON(w, http.StatusOK, settingValueToResponse(*stored)) } @@ -265,6 +272,8 @@ func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope") return } + publishUserSettingsEvent(r.Context(), h.EventsHub, + apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope)) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers/user_settings_events.go b/internal/api/handlers/user_settings_events.go new file mode 100644 index 00000000..7550c917 --- /dev/null +++ b/internal/api/handlers/user_settings_events.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + + evt "github.com/Silo-Server/silo-server/internal/events" +) + +// userSettingsEventPayload deliberately carries the identity of what changed +// and never the value. Admins receive every user's user-scoped events (see +// allowsEventForClaims in events_ws.go), so a value here would leak private +// settings to admins. Clients that care about the new value re-fetch it over +// the REST API, where access is scoped to the caller's own session. +type userSettingsEventPayload struct { + Key string `json:"key"` + Scope string `json:"scope"` + ProfileID string `json:"profile_id,omitempty"` +} + +const userSettingsChangedEvent = "user_settings.changed" + +func publishUserSettingsEvent( + ctx context.Context, + hub *evt.Hub, + userID int, + profileID, key, scope string, +) { + if hub == nil || userID == 0 || key == "" || scope == "" { + return + } + // The payload is always non-empty: an empty Data would fall back to a null + // snapshot frame in the hub, telling subscribers nothing at all. + _ = hub.PublishJSON(ctx, evt.ChannelUserSettings, userSettingsChangedEvent, userSettingsEventPayload{ + Key: key, + Scope: scope, + ProfileID: profileID, + }, evt.PublishOptions{ + UserID: userID, + ProfileID: profileID, + }) +} diff --git a/internal/api/handlers/user_settings_events_test.go b/internal/api/handlers/user_settings_events_test.go new file mode 100644 index 00000000..52713935 --- /dev/null +++ b/internal/api/handlers/user_settings_events_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Silo-Server/silo-server/internal/cache" + evt "github.com/Silo-Server/silo-server/internal/events" +) + +// receiveUserSettingsEvent drains one envelope from the subscription, which is +// already buffered by the time the handler returns because local fan-out is +// synchronous. +func receiveUserSettingsEvent(t *testing.T, events <-chan evt.Envelope) evt.Envelope { + t.Helper() + select { + case env := <-events: + return env + default: + t.Fatal("no event was published to the hub") + return evt.Envelope{} + } +} + +func assertUserSettingsEnvelope(t *testing.T, env evt.Envelope, wantKey, wantScope string) { + t.Helper() + if env.Channel != evt.ChannelUserSettings { + t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelUserSettings) + } + if env.Event != userSettingsChangedEvent { + t.Errorf("event = %q, want %q", env.Event, userSettingsChangedEvent) + } + if env.UserID != 1 || env.ProfileID != "profile-1" { + t.Errorf("addressed to user %d profile %q, want 1/profile-1", env.UserID, env.ProfileID) + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(env.Data, &payload); err != nil { + t.Fatalf("payload is not a JSON object: %v", err) + } + if string(payload["key"]) != `"`+wantKey+`"` { + t.Errorf("payload key = %s, want %q", payload["key"], wantKey) + } + if string(payload["scope"]) != `"`+wantScope+`"` { + t.Errorf("payload scope = %s, want %q", payload["scope"], wantScope) + } + if string(payload["profile_id"]) != `"profile-1"` { + t.Errorf("payload profile_id = %s, want \"profile-1\"", payload["profile_id"]) + } + // The value must never ride along: admins receive every user's user-scoped + // events, so a value here would leak private settings to admins. + if raw, present := payload["value"]; present { + t.Errorf("payload carries a value (%s); it must never leak into events", raw) + } +} + +func TestSetValuePublishesUserSettingsEvent(t *testing.T) { + handler, _ := newValuesTestHandler(t) + handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{}) + events, unsubscribe := handler.EventsHub.Subscribe() + defer unsubscribe() + + rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile", []byte(`{"value":"ja"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String()) + } + + env := receiveUserSettingsEvent(t, events) + assertUserSettingsEnvelope(t, env, "playback.subtitle_language", "profile") +} + +func TestDeleteValuePublishesUserSettingsEvent(t *testing.T) { + handler, _ := newValuesTestHandler(t) + handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{}) + events, unsubscribe := handler.EventsHub.Subscribe() + defer unsubscribe() + + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile", []byte(`{"value":"ja"}`)); rec.Code != http.StatusOK { + t.Fatalf("seeding PUT = %d: %s", rec.Code, rec.Body.String()) + } + <-events // drain the write's own event + + rec := routeValues(t, handler, http.MethodDelete, "playback.subtitle_language", + "scope=profile", nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String()) + } + + env := receiveUserSettingsEvent(t, events) + assertUserSettingsEnvelope(t, env, "playback.subtitle_language", "profile") +} + +// TestFailedMutationsPublishNothing: a refused write and a delete of nothing +// must not tell clients something changed. +func TestFailedMutationsPublishNothing(t *testing.T) { + handler, _ := newValuesTestHandler(t) + handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{}) + events, unsubscribe := handler.EventsHub.Subscribe() + defer unsubscribe() + + if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language", + "scope=profile", []byte(`{"value":"!!!"}`)); rec.Code != http.StatusBadRequest { + t.Fatalf("invalid PUT = %d, want 400", rec.Code) + } + if rec := routeValues(t, handler, http.MethodDelete, "playback.subtitle_language", + "scope=profile", nil); rec.Code != http.StatusNotFound { + t.Fatalf("DELETE of nothing = %d, want 404", rec.Code) + } + + select { + case env := <-events: + t.Errorf("a failed mutation published %s on %s", env.Event, env.Channel) + default: + } +} diff --git a/internal/api/router.go b/internal/api/router.go index af4f6522..aa652185 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -790,6 +790,7 @@ func NewRouter(deps Dependencies) chi.Router { // which degrades to "no typed settings routes" instead of no server. if contract, err := settingscontract.Load(); err == nil { settingValuesHandler = handlers.NewSettingValuesHandler(deps.UserStoreProvider, contract) + settingValuesHandler.EventsHub = deps.EventsHub } homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider) homeDismissalHandler.EventsHub = deps.EventsHub diff --git a/internal/events/types.go b/internal/events/types.go index a037bacd..1921422f 100644 --- a/internal/events/types.go +++ b/internal/events/types.go @@ -15,6 +15,7 @@ const ( ChannelScans EventChannel = "scans" ChannelHistoryImport EventChannel = "history_import" ChannelUserState EventChannel = "user_state" + ChannelUserSettings EventChannel = "user_settings" ChannelSettings EventChannel = "settings" ChannelPlugins EventChannel = "plugins" // ChannelNotifications carries profile-scoped user notifications @@ -31,6 +32,7 @@ var AllChannels = []EventChannel{ ChannelScans, ChannelHistoryImport, ChannelUserState, + ChannelUserSettings, ChannelSettings, ChannelPlugins, ChannelNotifications,