feat(settings): close the unknown-key extension bag

keyUsesUserScope returned true for any key the registry did not know, so a
client could invent a production setting unilaterally and the server stored
it as an unvalidated string. That is how six ui.* settings and five orphan
keys reached production untyped, and it is the root enabler the design
names.

An unknown key is no longer a user setting, so the legacy write path
rejects it and the canonical API — which validates every value against its
own definition — is the only way to store something new.

jellycompat's DisplayPreferences blobs ride the same table under synthetic
keys and keep working: they are that subsystem's storage rather than user
settings, and they move to dedicated storage in the follow-up rather than
being dropped here.

Also repoints the DisplayPreferences seed at the canonical resolver.
Resolved at profile scope with no device on purpose — Jellyfin clients do
not carry Silo's device identity, so a device override leaking into the
seed would hand one device's settings to every Jellyfin client on the
account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 00:44:33 +00:00
co-authored by Claude Opus 5
parent 3d039e163c
commit 9ed0aefd66
2 changed files with 68 additions and 8 deletions
+24 -1
View File
@@ -754,12 +754,35 @@ func validateRegisteredSetting(key, value string, expectedScope settingsScope) e
return spec.Validate(value)
}
// keyUsesUserScope reports whether a key is stored at account scope by the
// legacy endpoints.
//
// This used to return true for any *unregistered* key, which is the extension
// bag: a client could invent a production setting unilaterally and the server
// stored it as an unvalidated string. That is how six ui.* settings and five
// orphan keys reached production untyped, and closing it is the point of the
// contract.
//
// An unknown key is now simply not a user setting, so the legacy write path
// rejects it and the canonical API — which validates against the manifest — is
// the only way to store anything new.
func keyUsesUserScope(key string) bool {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
return !ok || spec.Scope == scopeUser
if !ok {
// The jellycompat DisplayPreferences blobs ride this table under
// synthetic keys. They are that subsystem's storage rather than user
// settings, and they move to dedicated storage in the follow-up; until
// then they must keep working.
return strings.HasPrefix(key, jellycompatSettingPrefix)
}
return spec.Scope == scopeUser
}
// jellycompatSettingPrefix marks rows that belong to the Jellyfin
// compatibility layer rather than to the user settings system.
const jellycompatSettingPrefix = "jellycompat:"
func keyUsesDeviceScope(key string) bool {
key = canonicalDeviceSettingKey(key)
spec, ok := settingsRegistry[key]
+44 -7
View File
@@ -9,6 +9,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -127,20 +130,54 @@ func defaultDisplayPreferences(id, client string) displayPreferencesDTO {
}
}
// seedFromProfile fills a fresh DisplayPreferences document from the user's
// real settings, so a Jellyfin client's first read reflects choices made in
// Silo rather than empty defaults.
//
// Resolved at profile scope with no device: this seeds what a Jellyfin client
// sees, and those clients do not carry Silo's device identity. A device
// override leaking in here would hand one device's settings to every Jellyfin
// client on the account.
func (h *DisplayPreferencesHandler) seedFromProfile(r *http.Request, session *Session, dto *displayPreferencesDTO) {
store, err := h.storeProvider.ForUser(r.Context(), session.StreamAppUserID)
if err != nil {
return
}
profile, err := store.GetProfile(r.Context(), session.ProfileID)
if err != nil || profile == nil {
contract, err := settingscontract.Load()
if err != nil {
return
}
if profile.SubtitleLanguage != "" {
dto.CustomPrefs["subtitleLanguage"] = profile.SubtitleLanguage
resolved, err := settingsresolve.New(contract).Resolve(r.Context(), store,
settingsresolve.Context{ProfileID: session.ProfileID},
[]string{
settingskeys.PlaybackSubtitleLanguage,
settingskeys.PlaybackSubtitleMode,
settingskeys.PlaybackAutoSkipCredits,
}, nil)
if err != nil {
return
}
if profile.SubtitleMode != "" {
dto.CustomPrefs["subtitleMode"] = profile.SubtitleMode
for _, eff := range resolved {
switch eff.Key {
case settingskeys.PlaybackSubtitleLanguage:
var language string
if json.Unmarshal(eff.Value, &language) == nil && language != "" {
dto.CustomPrefs["subtitleLanguage"] = language
}
case settingskeys.PlaybackSubtitleMode:
var mode string
if json.Unmarshal(eff.Value, &mode) == nil && mode != "" {
dto.CustomPrefs["subtitleMode"] = mode
}
case settingskeys.PlaybackAutoSkipCredits:
// Jellyfin spells this as the inverse: the overlay is what plays
// instead of skipping.
var skip bool
if json.Unmarshal(eff.Value, &skip) == nil {
dto.CustomPrefs["enableNextVideoInfoOverlay"] = strconv.FormatBool(!skip)
}
}
}
dto.CustomPrefs["enableNextVideoInfoOverlay"] = strconv.FormatBool(!profile.AutoSkipCredits)
}