566 lines
26 KiB
Go
566 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
const (
|
|
featureSchemaVersion = 1
|
|
featureSonarrPreroll = "sonarr_preroll"
|
|
featureAutomaticMyShows = "automatic_my_shows"
|
|
featureMyShowsNotification = "my_shows_notifications"
|
|
featureHEVCDirectPlay = "hevc_direct_play"
|
|
featureInstallPermission = "install_permission_prompt"
|
|
featureSubtitleDownload = "subtitle_download"
|
|
featureTrickplay = "trickplay"
|
|
featureSkipIntro = "skip_intro"
|
|
featureEndCredits = "end_credits"
|
|
featureSeasonalThemes = "seasonal_themes"
|
|
featureSeasonalDecorations = "seasonal_decorations"
|
|
featureGenreBrowser = "genre_browser"
|
|
featureTVCalendar = "tv_calendar"
|
|
featureContinueWatching = "continue_watching"
|
|
featureWatchTimeDigest = "watch_time_digest"
|
|
featureViewers = "viewers"
|
|
)
|
|
|
|
type featureDefinition struct {
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Area string `json:"area"`
|
|
DefaultEnabled bool `json:"defaultEnabled"`
|
|
MinimumProtocol int `json:"minimumProtocol"`
|
|
Capability string `json:"capability"`
|
|
Recovery string `json:"recovery"`
|
|
}
|
|
|
|
// configurationDefinition is the shared catalogue for booleans and behavioural
|
|
// values. Scope is part of the contract so new features do not grow bespoke settings.
|
|
type configurationDefinition struct {
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Scopes []string `json:"scopes"`
|
|
Default any `json:"default"`
|
|
Options []string `json:"options,omitempty"`
|
|
Min *int `json:"min,omitempty"`
|
|
Max *int `json:"max,omitempty"`
|
|
}
|
|
|
|
var configurationCatalogue = []configurationDefinition{
|
|
{Key: "forYou.enabled", Name: "For You", Description: "Show personalised recommendations on Home.", Type: "boolean", Scopes: []string{"global", "user", "device", "experimental"}, Default: true},
|
|
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
|
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
|
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
|
|
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
|
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
|
{Key: "trailers.enabled", Name: "Trailers", Description: "Offer trailers where available.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
|
{Key: "requests.enabled", Name: "Requests", Description: "Allow title requests from viewers.", Type: "boolean", Scopes: []string{"global", "user"}, Default: true},
|
|
{Key: "hero.enabled", Name: "Hero", Description: "Show the Home hero presentation.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
|
{Key: "home.heroRefreshSeconds", Name: "Hero refresh interval", Description: "Seconds between hero refreshes.", Type: "integer", Scopes: []string{"global", "device"}, Default: 60, Min: intPtr(15), Max: intPtr(3600)},
|
|
{Key: "home.maxItemsPerRow", Name: "Maximum items per row", Description: "Maximum number of cards shown in a row.", Type: "integer", Scopes: []string{"global", "device"}, Default: 20, Min: intPtr(1), Max: intPtr(100)},
|
|
{Key: "home.sectionDefinitions", Name: "Home page sections", Description: "JSON section definitions controlling Home composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Home.SectionDefinitions},
|
|
{Key: "movies.sectionDefinitions", Name: "Movies page sections", Description: "JSON section definitions controlling Movies composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Movies.SectionDefinitions},
|
|
{Key: "tv.sectionDefinitions", Name: "TV page sections", Description: "JSON section definitions controlling TV composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().TV.SectionDefinitions},
|
|
{Key: "branding.markUrl", Name: "Memby mark URL", Description: "HTTPS image used for the TV rail mark; the bundled mark remains the fallback.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
|
{Key: "branding.markVersion", Name: "Memby mark version", Description: "Cache-busting version for the configured mark.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
|
}
|
|
|
|
func intPtr(v int) *int { return &v }
|
|
|
|
var featureCatalogue = []featureDefinition{
|
|
{
|
|
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
|
|
Description: "Show the fan-art calendar before a fresh episode starts.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "sonarr_preroll_v1",
|
|
Recovery: "Takes effect the next time a title is opened.",
|
|
},
|
|
{
|
|
Key: featureAutomaticMyShows, Name: "Automatic My Shows", Area: "My Shows",
|
|
Description: "Follow a continuing Sonarr show after half an episode is watched.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1",
|
|
Recovery: "Server-enforced; takes effect on the next playback report.",
|
|
},
|
|
{
|
|
Key: featureMyShowsNotification, Name: "Automatic follow notification", Area: "Notifications",
|
|
Description: "Notify a viewer when a continuing show is automatically followed.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1",
|
|
Recovery: "Server-enforced; disabling it never removes a saved show.",
|
|
},
|
|
{
|
|
Key: featureHEVCDirectPlay, Name: "HEVC direct play", Area: "Playback",
|
|
Description: "Allow capable TVs to direct-play H.265/HEVC instead of requesting H.264.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "video_hevc_decode",
|
|
Recovery: "Server-enforced; takes effect the next time playback starts or refreshes.",
|
|
},
|
|
{
|
|
Key: featureSubtitleDownload, Name: "Download missing subtitles", Area: "Playback",
|
|
Description: "Let a viewer fetch a subtitle through Bazarr from the player, for a " +
|
|
"title the library has none for.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "subtitle_download_v1",
|
|
Recovery: "Takes effect the next time playback starts; the option simply stops being offered.",
|
|
},
|
|
{
|
|
Key: featureTrickplay, Name: "Seek preview thumbnails", Area: "Playback",
|
|
Description: "Show the frame a skip will land on, from the preview images Emby " +
|
|
"generates. Turn it off to stop the gateway reading them.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "trickplay_v1",
|
|
Recovery: "Takes effect the next time playback starts; the preview simply stops appearing.",
|
|
},
|
|
{
|
|
Key: featureSkipIntro, Name: "Skip the title sequence", Area: "Playback",
|
|
Description: "Offer to jump past an episode's opening titles, from the intro " +
|
|
"markers Emby writes. Turn it off to stop the gateway reading them.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
|
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
|
|
},
|
|
{
|
|
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
|
|
Description: "Shrink the picture and run the closing credits at double speed with " +
|
|
"the next episode beside them. Emby's own marker is preferred where it has one, " +
|
|
"and where it has none the position discovered from episodes viewers are about to watch " +
|
|
"in your library is used instead. It is read from the same chapter list as the " +
|
|
"title sequence, so turning this off saves no request unless that is off too.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
|
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
|
},
|
|
{
|
|
// The only switch there is for seasonal themes, and it is deliberately the
|
|
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
|
|
// turns off in October and never reconsiders, which is the same as the feature not
|
|
// existing. Off here means every television falls back to its viewer's own choice
|
|
// on the next status poll.
|
|
Key: featureSeasonalThemes, Name: "Seasonal themes", Area: "Presentation",
|
|
Description: "Put every television into the Halloween, Christmas or Easter palette " +
|
|
"for its dates. Viewers cannot decline one; turning this off is the only way to " +
|
|
"stop them.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "themes_v1",
|
|
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
|
|
},
|
|
{
|
|
// A second switch rather than a consequence of the one above, because the palette
|
|
// and the animation have quite different costs. Snow drifting over the launcher is
|
|
// the only thing in the app that animates continuously while somebody is browsing,
|
|
// and these are weak boxes; an operator who finds it costs frames should be able to
|
|
// keep December looking like December without it.
|
|
Key: featureSeasonalDecorations, Name: "Seasonal decorations", Area: "Presentation",
|
|
Description: "Drift snow, bats or blossom over the launcher while a seasonal theme " +
|
|
"is on. Turning it off keeps the seasonal colours and stops the animation.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1",
|
|
Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.",
|
|
},
|
|
{
|
|
Key: featureGenreBrowser, Name: "Genre browser", Area: "Presentation",
|
|
Description: "Show the full genre browser at the top of Movies and TV Shows.",
|
|
DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1",
|
|
Recovery: "Takes effect on the next status poll; the browser is hidden when off.",
|
|
},
|
|
{
|
|
Key: featureTVCalendar, Name: "TV calendar", Area: "Presentation",
|
|
Description: "Show the month-by-month Sonarr calendar on the navigation rail. " +
|
|
"Turning it off hides the destination and stops the gateway reading months.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
|
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
|
},
|
|
{
|
|
Key: featureContinueWatching, Name: "Continue Watching", Area: "Home",
|
|
Description: "Show resumable films and episodes on Home. Turning it off hides the " +
|
|
"row and stops the gateway loading its resume and Next Up feeds.",
|
|
DefaultEnabled: true, MinimumProtocol: 1,
|
|
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
|
|
},
|
|
{
|
|
// No capability, because nothing on the television has to understand this: the
|
|
// summary is an ordinary entry in My Alerts, which every build that has that page
|
|
// already renders. This switch is the household's — a viewer's own is the
|
|
// watch-time toggle on their account.
|
|
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
|
|
Description: "Tell each viewer how long they watched this week and this month, on " +
|
|
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
|
|
"Tracearr; a server running none never sends one.",
|
|
DefaultEnabled: true, MinimumProtocol: 1,
|
|
Recovery: "Server-enforced; takes effect before the next summary is due.",
|
|
},
|
|
{
|
|
// Default **off**, the stance the genre browser takes. This is the switch that
|
|
// decides where a household's watched state is written, and a feature that
|
|
// arrives already on is one every server running this build starts using before
|
|
// anybody has decided to — so it is opted into rather than out of.
|
|
//
|
|
// Switching it on or off never deletes a viewer or their history: the rows stay
|
|
// in Postgres and come back intact. Off, the gateway routes nobody's state
|
|
// anywhere but Emby, which is the state a household was in before the feature
|
|
// existed; on, a shadow viewer's watching goes to Memby and is picked up exactly
|
|
// where they left it.
|
|
Key: featureViewers, Name: "Viewers", Area: "Accounts",
|
|
Description: "Let one Emby account hold several people, each with their own " +
|
|
"Continue Watching, watched history and favourites. Off by default; turning " +
|
|
"it off again returns every television to watching as the account itself, " +
|
|
"without losing what anybody has watched.",
|
|
DefaultEnabled: false, MinimumProtocol: 1, Capability: "viewers_v1",
|
|
Recovery: "Takes effect on the next request; nothing a viewer has watched is lost.",
|
|
},
|
|
{
|
|
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
|
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
|
"permission, so a mandatory update is not the first time it comes up.",
|
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "install_permission_v1",
|
|
Recovery: "Appears on the next status poll, and only on a TV missing the permission.",
|
|
},
|
|
}
|
|
|
|
type evaluatedFeature struct {
|
|
featureDefinition
|
|
Enabled bool `json:"enabled"`
|
|
Source string `json:"source"`
|
|
Compatible bool `json:"compatible"`
|
|
}
|
|
|
|
type featureResponse struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Revision int64 `json:"revision"`
|
|
SafeMode bool `json:"safeMode"`
|
|
UpdatedAt any `json:"updatedAt,omitempty"`
|
|
CanRollback bool `json:"canRollback"`
|
|
Features []evaluatedFeature `json:"features"`
|
|
Configuration []evaluatedConfiguration `json:"configuration"`
|
|
}
|
|
|
|
type evaluatedConfiguration struct {
|
|
configurationDefinition
|
|
Value any `json:"value"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
func configurationDefinitionFor(key string) (configurationDefinition, bool) {
|
|
for _, definition := range configurationCatalogue {
|
|
if definition.Key == key {
|
|
return definition, true
|
|
}
|
|
}
|
|
return configurationDefinition{}, false
|
|
}
|
|
|
|
func configurationValue(policy store.FeaturePolicy, definition configurationDefinition, sessions ...store.Session) (any, string) {
|
|
if len(sessions) > 0 {
|
|
session := sessions[0]
|
|
if values, ok := policy.DeviceValues[session.DeviceID]; ok {
|
|
if raw, ok := values[definition.Key]; ok {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
return value, "device"
|
|
}
|
|
}
|
|
}
|
|
if values, ok := policy.UserValues[session.EmbyUserID]; ok {
|
|
if raw, ok := values[definition.Key]; ok {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
return value, "user"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if raw, ok := policy.Values[definition.Key]; ok {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
return value, "global"
|
|
}
|
|
}
|
|
return definition.Default, "default"
|
|
}
|
|
|
|
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
|
|
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
|
|
for _, definition := range configurationCatalogue {
|
|
value, source := configurationValue(policy, definition, sessions...)
|
|
result = append(result, evaluatedConfiguration{configurationDefinition: definition, Value: value, Source: source})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func knownFeature(key string) (featureDefinition, bool) {
|
|
for _, definition := range featureCatalogue {
|
|
if definition.Key == key {
|
|
return definition, true
|
|
}
|
|
}
|
|
return featureDefinition{}, false
|
|
}
|
|
|
|
func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, protocol int) evaluatedFeature {
|
|
enabled, source := definition.DefaultEnabled, "default"
|
|
if override, ok := policy.Overrides[definition.Key]; ok {
|
|
enabled, source = override, "override"
|
|
}
|
|
// Legacy server call-sites continue to use their stable snake_case keys while
|
|
// operators edit the canonical typed catalogue.
|
|
canonical := map[string]string{
|
|
featureContinueWatching: "continueWatching.enabled",
|
|
featureGenreBrowser: "genres.enabled",
|
|
}[definition.Key]
|
|
if canonical != "" {
|
|
if raw, ok := policy.Values[canonical]; ok {
|
|
var value bool
|
|
if json.Unmarshal(raw, &value) == nil {
|
|
enabled, source = value, "configuration"
|
|
}
|
|
}
|
|
}
|
|
if policy.SafeMode {
|
|
enabled, source = false, "safe_mode"
|
|
}
|
|
compatible := protocol >= definition.MinimumProtocol
|
|
if !compatible {
|
|
enabled, source = false, "incompatible_client"
|
|
}
|
|
return evaluatedFeature{featureDefinition: definition, Enabled: enabled, Source: source, Compatible: compatible}
|
|
}
|
|
|
|
// currentFeaturePolicy is read on the request path from sixteen places and by the status
|
|
// poll every open television makes, so it is cached rather than queried — see
|
|
// featurePolicyCache for how long and why that is safe.
|
|
func (s *Server) currentFeaturePolicy(ctx context.Context) store.FeaturePolicy {
|
|
if s.store == nil {
|
|
return store.DefaultFeaturePolicy()
|
|
}
|
|
return s.featurePolicy.read(ctx, func(ctx context.Context) store.FeaturePolicy {
|
|
policy, err := s.store.FeaturePolicy(ctx)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("feature policy unavailable; using safe defaults", "error", err)
|
|
return store.DefaultFeaturePolicy()
|
|
}
|
|
return policy
|
|
})
|
|
}
|
|
|
|
func (s *Server) featureEnabled(ctx context.Context, key string) bool {
|
|
definition, ok := knownFeature(key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return evaluateFeature(s.currentFeaturePolicy(ctx), definition, ProtocolVersion).Enabled
|
|
}
|
|
|
|
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
|
|
return featurePayloadForSession(policy, protocol, nil, capabilities...)
|
|
}
|
|
|
|
func featurePayloadForSession(policy store.FeaturePolicy, protocol int, session *store.Session, capabilities ...[]string) featureResponse {
|
|
features := make([]evaluatedFeature, 0, len(featureCatalogue))
|
|
for _, definition := range featureCatalogue {
|
|
evaluated := evaluateFeature(policy, definition, protocol)
|
|
if len(capabilities) > 0 && definition.Capability != "" &&
|
|
!slices.Contains(capabilities[0], definition.Capability) {
|
|
evaluated.Enabled = false
|
|
evaluated.Compatible = false
|
|
evaluated.Source = "missing_capability"
|
|
}
|
|
features = append(features, evaluated)
|
|
}
|
|
response := featureResponse{
|
|
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
|
|
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
|
|
CanRollback: policy.Previous != nil, Features: features,
|
|
}
|
|
if session != nil {
|
|
response.Configuration = configurationPayload(policy, *session)
|
|
} else {
|
|
response.Configuration = configurationPayload(policy)
|
|
}
|
|
return response
|
|
}
|
|
|
|
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
|
|
result := make(map[string]bool, len(featureCatalogue))
|
|
for _, feature := range featurePayload(policy, protocol, capabilities).Features {
|
|
result[feature.Key] = feature.Enabled
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, session store.Session) {
|
|
writeJSON(w, http.StatusOK, featurePayloadForSession(
|
|
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), &session, clientCapabilities(r),
|
|
))
|
|
}
|
|
|
|
type featurePolicyRequest struct {
|
|
Action string `json:"action"`
|
|
ExpectedRevision int64 `json:"expectedRevision"`
|
|
Overrides map[string]bool `json:"overrides"`
|
|
Values map[string]json.RawMessage `json:"values"`
|
|
UserValues map[string]map[string]json.RawMessage `json:"userValues"`
|
|
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues"`
|
|
Experimental map[string]json.RawMessage `json:"experimental"`
|
|
}
|
|
|
|
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
|
|
var req featurePolicyRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
return
|
|
}
|
|
current := s.currentFeaturePolicy(r.Context())
|
|
if req.ExpectedRevision != current.Revision {
|
|
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
|
|
return
|
|
}
|
|
next := store.FeaturePolicy{Overrides: map[string]bool{}, Values: req.Values, UserValues: req.UserValues, DeviceValues: req.DeviceValues, Experimental: req.Experimental, SafeMode: current.SafeMode}
|
|
switch strings.TrimSpace(req.Action) {
|
|
case "save":
|
|
for key, enabled := range req.Overrides {
|
|
if _, ok := knownFeature(key); !ok {
|
|
writeError(w, http.StatusBadRequest, "unknown feature flag: "+key)
|
|
return
|
|
}
|
|
next.Overrides[key] = enabled
|
|
}
|
|
if err := validateConfigurationValues(next); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
case "safe-mode":
|
|
next.Overrides = current.Overrides
|
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
|
next.SafeMode = true
|
|
case "leave-safe-mode":
|
|
next.Overrides = current.Overrides
|
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
|
next.SafeMode = false
|
|
case "reset":
|
|
next.SafeMode = false
|
|
case "rollback":
|
|
if current.Previous == nil {
|
|
writeError(w, http.StatusConflict, "there is no previous feature revision to restore")
|
|
return
|
|
}
|
|
next.Overrides = current.Previous.Overrides
|
|
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Previous.Values, current.Previous.UserValues, current.Previous.DeviceValues, current.Previous.Experimental
|
|
next.SafeMode = current.Previous.SafeMode
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unknown feature policy action")
|
|
return
|
|
}
|
|
s.featurePolicy.invalidate()
|
|
stored, err := s.store.SetFeaturePolicy(r.Context(), next, req.ExpectedRevision)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrFeaturePolicyConflict) {
|
|
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
|
|
return
|
|
}
|
|
s.loggerFor(r.Context()).Error("feature policy write failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not save feature flags")
|
|
return
|
|
}
|
|
s.loggerFor(r.Context()).Info("feature policy changed", "action", req.Action, "revision", stored.Revision,
|
|
"safe_mode", stored.SafeMode, "overrides", len(stored.Overrides))
|
|
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
|
|
}
|
|
|
|
func validateConfigurationValues(policy store.FeaturePolicy) error {
|
|
for key, raw := range policy.Values {
|
|
definition, ok := configurationDefinitionFor(key)
|
|
if !ok {
|
|
return errors.New("unknown configuration value: " + key)
|
|
}
|
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for scope, values := range map[string]map[string]json.RawMessage{"experimental": policy.Experimental} {
|
|
for key, raw := range values {
|
|
definition, ok := configurationDefinitionFor(key)
|
|
if !ok {
|
|
return errors.New("unknown configuration value: " + key)
|
|
}
|
|
if !slices.Contains(definition.Scopes, scope) {
|
|
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
|
}
|
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for scope, groups := range map[string]map[string]map[string]json.RawMessage{"user": policy.UserValues, "device": policy.DeviceValues} {
|
|
for _, values := range groups {
|
|
for key, raw := range values {
|
|
definition, ok := configurationDefinitionFor(key)
|
|
if !ok {
|
|
return errors.New("unknown configuration value: " + key)
|
|
}
|
|
if !slices.Contains(definition.Scopes, scope) {
|
|
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
|
}
|
|
if err := validateConfigurationValue(definition, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateConfigurationValue(definition configurationDefinition, raw json.RawMessage) error {
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return errors.New("invalid configuration value: " + definition.Key)
|
|
}
|
|
switch definition.Type {
|
|
case "boolean":
|
|
if _, ok := value.(bool); !ok {
|
|
return errors.New("configuration value must be boolean: " + definition.Key)
|
|
}
|
|
case "integer":
|
|
n, ok := value.(float64)
|
|
if !ok || n != float64(int(n)) || (definition.Min != nil && int(n) < *definition.Min) || (definition.Max != nil && int(n) > *definition.Max) {
|
|
return errors.New("configuration value is outside its allowed range: " + definition.Key)
|
|
}
|
|
case "enum":
|
|
text, ok := value.(string)
|
|
if !ok || !slices.Contains(definition.Options, text) {
|
|
return errors.New("configuration value is not an allowed option: " + definition.Key)
|
|
}
|
|
case "json":
|
|
if _, ok := value.([]any); !ok {
|
|
return errors.New("configuration value must be a JSON array: " + definition.Key)
|
|
}
|
|
case "string":
|
|
if _, ok := value.(string); !ok {
|
|
return errors.New("configuration value must be text: " + definition.Key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseCapabilities(raw string) []string {
|
|
seen := map[string]bool{}
|
|
values := []string{}
|
|
for _, value := range strings.Split(raw, ",") {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" || len(value) > 64 || seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
values = append(values, value)
|
|
}
|
|
slices.Sort(values)
|
|
return values
|
|
}
|
|
|
|
func clientCapabilities(r *http.Request) []string {
|
|
return parseCapabilities(r.Header.Get("X-Memby-Capabilities"))
|
|
}
|