Files
memby/server/internal/api/features.go
T
2026-08-28 23:00:02 +12:00

719 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
featureNextEpisodeAiring = "next_episode_airing"
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: "detailExperience", Name: "Detail page experience", Description: "Which detail-page layout a viewer sees.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "v1", Options: []string{"v1", "v2"}},
{Key: "presentation.fontFamily", Name: "App font family", Description: "Choose the bundled font used in Membys typography trial areas.", Type: "enum", Scopes: []string{"global"}, Default: "system", Options: []string{"system", "inter"}},
{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 }
// rowTypeDefinition is the vocabulary behind the "row type" picker in the admin console's
// visual row editor. An operator configuring Home, Movies or TV composition should never
// need to know that a Continue Watching row is a "mediaRow" reading "emby.resume" — they
// pick the type, and component/dataSource follow from it. It exists so the client's fixed,
// small vocabulary of renderable components (mediaRow, mediaGrid, genreBrowser) is described
// once, here, rather than the admin console guessing at it independently and drifting from
// what a television actually understands.
type rowTypeDefinition struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Component string `json:"component"`
DataSource string `json:"dataSource"`
// Pages lists which of Home/Movies/TV this type may be placed on. A row whose type
// is not valid for the page it is being saved to is exactly the "unsupported row/page
// combination" the visual editor exists to make impossible.
Pages []string `json:"pages"`
// RequiresMatchingDestination is true for a type whose row only makes sense pointing at
// the page it lives on (Genres/Library on Movies must browse Movies, never TV).
RequiresMatchingDestination bool `json:"requiresMatchingDestination"`
// Custom withholds component/dataSource entirely: the operator states them directly,
// which is the escape hatch for a row this catalogue has no opinion about yet.
Custom bool `json:"custom"`
}
var rowTypeCatalogue = []rowTypeDefinition{
{Type: "continueWatching", Label: "Continue Watching", Description: "Resumable films and unwatched next episodes, merged and ordered by recency.", Component: "mediaRow", DataSource: "emby.resume", Pages: []string{"home"}},
{Type: "forYou", Label: "For You", Description: "Personalised recommendations built from this household's viewing history.", Component: "mediaRow", DataSource: "gateway.recommendations", Pages: []string{"home"}},
{Type: "favorites", Label: "Favourites", Description: "Titles marked as favourites.", Component: "mediaRow", DataSource: "emby.favourites", Pages: []string{"home"}},
{Type: "latest", Label: "Recently Added", Description: "The newest titles added to the library.", Component: "mediaRow", DataSource: "emby.latest", Pages: []string{"home"}},
{Type: "genres", Label: "Genre Browser", Description: "A full-width row of genre categories for browsing by taste.", Component: "genreBrowser", DataSource: "emby.genres", Pages: []string{"movies", "tv"}, RequiresMatchingDestination: true},
{Type: "library", Label: "Library Grid", Description: "A paged grid of the whole catalogue for this page.", Component: "mediaGrid", DataSource: "emby.library", Pages: []string{"movies", "tv"}, RequiresMatchingDestination: true},
{Type: "custom", Label: "Custom", Description: "Set the data source and component directly. For development and configurations this catalogue does not yet describe.", Pages: []string{"home", "movies", "tv"}, Custom: true},
}
func rowTypeDefinitionFor(rowType string) (rowTypeDefinition, bool) {
for _, definition := range rowTypeCatalogue {
if definition.Type == rowType {
return definition, true
}
}
return rowTypeDefinition{}, false
}
// sectionDefinitionPageKeys maps a configuration key to the page it composes, which is what
// lets validateSectionDefinitions refuse a row type placed on a page it does not support.
var sectionDefinitionPageKeys = map[string]string{
"home.sectionDefinitions": "home",
"movies.sectionDefinitions": "movies",
"tv.sectionDefinitions": "tv",
}
// validateSectionDefinitions is the structural half of what the visual row editor promises:
// invalid configurations are impossible to save, not merely discouraged. It runs beside the
// generic "json" type check in validateConfigurationValue, which only confirms the value is
// an array at all.
func validateSectionDefinitions(key string, raw json.RawMessage) error {
page, ok := sectionDefinitionPageKeys[key]
if !ok {
return nil
}
var definitions []config.RemoteSectionDefinition
if err := json.Unmarshal(raw, &definitions); err != nil {
return errors.New(key + ": invalid section definitions")
}
if len(definitions) > 32 {
return errors.New(key + ": too many rows")
}
seenIDs := make(map[string]bool, len(definitions))
seenPositions := make(map[int]bool, len(definitions))
for _, definition := range definitions {
id := strings.TrimSpace(definition.ID)
if id == "" {
return errors.New(key + ": every row needs an id")
}
if seenIDs[id] {
return errors.New(key + ": duplicate row id " + id)
}
seenIDs[id] = true
if strings.TrimSpace(definition.Title) == "" {
return errors.New(key + ": row " + id + " needs a title")
}
if definition.Position < 0 {
return errors.New(key + ": row " + id + " has an invalid position")
}
if seenPositions[definition.Position] {
return errors.New(key + ": duplicate row position for " + id)
}
seenPositions[definition.Position] = true
if definition.MaxItems < 0 || definition.MaxItems > 100 {
return errors.New(key + ": row " + id + " has an invalid maximum item count")
}
if definition.Layout != "" &&
definition.Layout != config.SectionLayoutPoster && definition.Layout != config.SectionLayoutThumb {
return errors.New(key + ": row " + id + " has an invalid card layout")
}
if strings.TrimSpace(definition.Component) == "" {
return errors.New(key + ": row " + id + " needs a component")
}
rowType, known := rowTypeDefinitionFor(definition.Type)
if !known || rowType.Custom {
continue
}
if !slices.Contains(rowType.Pages, page) {
return errors.New(key + ": " + rowType.Label + " rows are not supported on this page")
}
if definition.Component != rowType.Component {
return errors.New(key + ": row " + id + " has a component that does not match its row type")
}
if definition.DataSource != rowType.DataSource {
return errors.New(key + ": row " + id + " has a data source that does not match its row type")
}
if rowType.RequiresMatchingDestination && definition.Destination != page {
return errors.New(key + ": row " + id + " must target the " + page + " page")
}
}
return nil
}
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.",
},
{
Key: featureNextEpisodeAiring, Name: "Next episode airing notice", Area: "Playback",
Description: "Show a quiet notice ahead of the Next Up banner naming when a " +
"continuing show's next episode airs, from Sonarr's schedule.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "next_episode_airing_v1",
Recovery: "Takes effect the next time playback starts; the notice simply stops appearing.",
},
{
// 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"`
// RowTypes is the static catalogue behind the console's visual row editor. It travels
// with every feature-policy response rather than a dedicated endpoint, because it is
// read alongside Configuration and never changes independently of a server release.
RowTypes []rowTypeDefinition `json:"rowTypes"`
}
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"
}
// detailExperienceFor resolves the "detailExperience" configuration value for a session,
// validating the stored value rather than trusting its type: Values/UserValues/DeviceValues
// are opaque JSON, and a value that is not exactly "v1" or "v2" must never reach the client
// as something it has to guess how to handle.
func detailExperienceFor(policy store.FeaturePolicy, sess store.Session) string {
definition, ok := configurationDefinitionFor("detailExperience")
if !ok {
return "v1"
}
value, _ := configurationValue(policy, definition, sess)
if text, ok := value.(string); ok && (text == "v1" || text == "v2") {
return text
}
return "v1"
}
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,
RowTypes: rowTypeCatalogue,
}
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)
}
if err := validateSectionDefinitions(definition.Key, raw); err != nil {
return err
}
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"))
}