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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user