399 lines
16 KiB
Go
399 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
type adminOnboardingRating struct {
|
|
ItemID string `json:"itemId"`
|
|
Title string `json:"title"`
|
|
Rating int `json:"rating"`
|
|
}
|
|
|
|
type adminOnboardingPreferences struct {
|
|
Completed bool `json:"completed"`
|
|
Prompted bool `json:"prompted"`
|
|
Updated bool `json:"updated"`
|
|
Ratings []adminOnboardingRating `json:"ratings"`
|
|
Genres []string `json:"genres"`
|
|
Studios []string `json:"studios"`
|
|
Actors []string `json:"actors"`
|
|
Actresses []string `json:"actresses"`
|
|
Directors []string `json:"directors"`
|
|
ContentTypes []string `json:"contentTypes"`
|
|
}
|
|
|
|
type adminMembyAccount struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Initials string `json:"initials"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
Devices []store.MembyDevice `json:"devices"`
|
|
Recommendations adminOnboardingPreferences `json:"recommendations"`
|
|
// Settings is the same document the television reads, normalised the same way, so
|
|
// the console is editing what the TV will actually receive rather than a projection
|
|
// of it. Saved is false for someone who has never synced — the values shown are then
|
|
// the defaults, and saying so is the difference between "chose this" and "has not
|
|
// chosen anything".
|
|
Settings adminAccountSettings `json:"settings"`
|
|
Notifications store.NotificationPreferences `json:"notifications"`
|
|
// Themes is the ids this person may choose between, and an empty array means every
|
|
// selectable theme rather than none — the same permissive reading the store and
|
|
// themeAllowed take. The console renders that as every box ticked, which is what an
|
|
// operator who has never touched the page should see.
|
|
Themes []string `json:"themes"`
|
|
}
|
|
|
|
type adminAccountSettings struct {
|
|
Saved bool `json:"saved"`
|
|
Revision int64 `json:"revision"`
|
|
Source string `json:"source,omitempty"`
|
|
UpdatedAt any `json:"updatedAt,omitempty"`
|
|
Preferences map[string]any `json:"preferences"`
|
|
}
|
|
|
|
func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
|
accounts, err := s.store.MembyAccounts(r.Context())
|
|
if err != nil {
|
|
s.loggerFor(r.Context()).Error("Memby account list failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not load Memby accounts")
|
|
return
|
|
}
|
|
|
|
allRatingIDs := []string{}
|
|
preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
|
|
for _, account := range accounts {
|
|
var pref recommend.OnboardingPreferences
|
|
_ = json.Unmarshal(account.RecommendationPreferences, &pref)
|
|
preferences[account.ID] = pref
|
|
for id := range pref.Ratings {
|
|
allRatingIDs = append(allRatingIDs, id)
|
|
}
|
|
}
|
|
titles := map[string]string{}
|
|
if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
|
|
for _, item := range recommend.Decode(raws) {
|
|
titles[item.ID] = item.Name
|
|
}
|
|
}
|
|
|
|
settings, err := s.store.AllUserPreferences(r.Context())
|
|
if err != nil {
|
|
// A settings read failure must not cost the operator the account list; the
|
|
// devices and the sign-out buttons are what this page is for.
|
|
s.loggerFor(r.Context()).Warn("account settings read failed", "error", err)
|
|
settings = map[string]store.UserPreferences{}
|
|
}
|
|
|
|
themes, err := s.store.AllUserThemes(r.Context())
|
|
if err != nil {
|
|
// Same trade the settings read above makes: a colour allowlist that would not load
|
|
// must not cost the operator the device list and the sign-out buttons.
|
|
s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err)
|
|
themes = map[string][]string{}
|
|
}
|
|
|
|
notifications, err := s.store.AllNotificationPreferences(r.Context())
|
|
if err != nil {
|
|
s.loggerFor(r.Context()).Warn("account notification preferences read failed", "error", err)
|
|
notifications = map[string]store.NotificationPreferences{}
|
|
}
|
|
|
|
result := make([]adminMembyAccount, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
pref := preferences[account.ID]
|
|
ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
|
|
for itemID, rating := range pref.Ratings {
|
|
title := titles[itemID]
|
|
if title == "" {
|
|
title = itemID
|
|
}
|
|
ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
|
|
}
|
|
sort.Slice(ratings, func(i, j int) bool {
|
|
if ratings[i].Rating != ratings[j].Rating {
|
|
return ratings[i].Rating > ratings[j].Rating
|
|
}
|
|
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
|
|
})
|
|
stored, saved := settings[account.ID]
|
|
accountSettings := adminAccountSettings{
|
|
Saved: saved, Revision: stored.Revision, Source: stored.Source,
|
|
Preferences: decodePreferences(stored.Preferences),
|
|
}
|
|
if !stored.UpdatedAt.IsZero() {
|
|
accountSettings.UpdatedAt = stored.UpdatedAt
|
|
}
|
|
notificationPrefs, savedNotifications := notifications[account.ID]
|
|
if !savedNotifications {
|
|
notificationPrefs = store.DefaultNotificationPreferences()
|
|
}
|
|
result = append(result, adminMembyAccount{
|
|
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
|
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
|
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
|
Themes: nonNilStrings(themes[account.ID]),
|
|
Notifications: notificationPrefs,
|
|
Recommendations: adminOnboardingPreferences{
|
|
Completed: pref.Completed, Prompted: pref.Prompted,
|
|
Updated: len(account.RecommendationPreferences) > 2,
|
|
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
|
|
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
|
|
Actresses: nonNilStrings(pref.Actresses),
|
|
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
|
|
},
|
|
})
|
|
}
|
|
// The catalogue rides along so the console builds its editor from the server's own
|
|
// vocabulary. A page that hard-coded the controls would drift from what the TV
|
|
// accepts the first time a setting is added, and would do it silently.
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"accounts": result, "catalogue": preferenceCatalogue,
|
|
"schemaVersion": preferenceSchemaVersion,
|
|
// The selectable themes, for the same reason the preference catalogue rides along:
|
|
// a page that hard-coded the swatches would drift from what the gateway will accept
|
|
// the first time a theme is added, and would do it without saying so. Seasonal ones
|
|
// are absent because they are not grantable — see themes.go.
|
|
"themes": selectableThemes(),
|
|
})
|
|
}
|
|
|
|
func stringPreference(preferences map[string]any, key string) string {
|
|
value, _ := preferences[key].(string)
|
|
return value
|
|
}
|
|
|
|
// handleAdminNotificationPreferences changes what one person is told without requiring
|
|
// a television or an app release. The loaded value is decoded in place so a console from
|
|
// an older gateway generation cannot accidentally turn off fields it does not know.
|
|
func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
prefs, err := s.notificationPreferencesFor(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not load notification settings")
|
|
return
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&prefs); err != nil {
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
return
|
|
}
|
|
if err := s.saveNotificationPreferences(r.Context(), userID, prefs); err != nil {
|
|
s.loggerFor(r.Context()).Error("admin notification preferences save failed", "user", userID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not save notification settings")
|
|
return
|
|
}
|
|
s.loggerFor(r.Context()).Info("notification settings saved for viewer", "user", userID)
|
|
writeJSON(w, http.StatusOK, prefs)
|
|
}
|
|
|
|
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
accounts, err := s.store.MembyAccounts(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not load Memby account")
|
|
return
|
|
}
|
|
found := false
|
|
for _, account := range accounts {
|
|
if account.ID == userID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "Memby account not found")
|
|
return
|
|
}
|
|
var preferences recommend.OnboardingPreferences
|
|
if raw, err := s.store.RecommendationOnboarding(r.Context(), userID); err == nil {
|
|
_ = json.Unmarshal(raw, &preferences)
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, "could not load recommendation choices")
|
|
return
|
|
}
|
|
if preferences.Completed {
|
|
writeError(w, http.StatusConflict, "recommendation setup is already complete")
|
|
return
|
|
}
|
|
preferences.Prompted = true
|
|
raw, _ := json.Marshal(preferences)
|
|
if err := s.store.SetRecommendationOnboarding(r.Context(), userID, raw); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not queue recommendation prompt")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) {
|
|
userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
|
|
var req renameDeviceRequest
|
|
if userID == "" || deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
|
|
writeError(w, http.StatusBadRequest, "user, device and name are required")
|
|
return
|
|
}
|
|
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
|
if req.DeviceName == "" || len([]rune(req.DeviceName)) > 80 {
|
|
writeError(w, http.StatusBadRequest, "device name must be between 1 and 80 characters")
|
|
return
|
|
}
|
|
if err := s.store.RenameUserDevice(r.Context(), userID, deviceID, req.DeviceName); err == store.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "Memby device not found")
|
|
return
|
|
} else if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not rename Memby device")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
|
userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
|
|
if userID == "" || deviceID == "" {
|
|
writeError(w, http.StatusBadRequest, "user and device are required")
|
|
return
|
|
}
|
|
hash, err := s.store.DeleteUserDevice(r.Context(), userID, deviceID)
|
|
if err == store.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "Memby device not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not sign out Memby device")
|
|
return
|
|
}
|
|
if s.cache != nil {
|
|
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash)))
|
|
}
|
|
s.retireEmbyDevice(r.Context(), deviceID)
|
|
if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil {
|
|
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
|
"removed_device_id", deviceID, "error", err)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
hashes, err := s.store.DeleteUserSessions(r.Context(), userID)
|
|
if err == store.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "Memby account not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not revoke Memby account access")
|
|
return
|
|
}
|
|
if s.cache != nil {
|
|
keys := make([]string, 0, len(hashes))
|
|
for _, hash := range hashes {
|
|
keys = append(keys, cache.SessionKey(hexHash(hash)))
|
|
}
|
|
_ = s.cache.Delete(r.Context(), keys...)
|
|
_ = s.cache.InvalidateUser(r.Context(), userID)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type adminPreferencesRequest struct {
|
|
Preferences map[string]any `json:"preferences"`
|
|
}
|
|
|
|
// handleAdminPushPreferences is the operator writing someone's TV settings for them.
|
|
//
|
|
// It writes with store.ForceRevision on purpose. Every one of that person's televisions
|
|
// is also a writer of this row, and making the console lose a revision race would mean an
|
|
// operator's deliberate change being quietly reverted by whichever set happened to sync
|
|
// next. The push wins; the televisions notice on their next status poll and adopt it.
|
|
func (s *Server) handleAdminPushPreferences(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
var req adminPreferencesRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
|
return
|
|
}
|
|
raw, err := json.Marshal(normalizePreferences(req.Preferences))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "could not read those settings")
|
|
return
|
|
}
|
|
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
|
|
Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
|
|
})
|
|
if err != nil {
|
|
s.loggerFor(r.Context()).Error("admin preferences push failed", "user", userID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not push those settings")
|
|
return
|
|
}
|
|
s.loggerFor(r.Context()).Info("settings pushed to viewer",
|
|
"user", userID, "revision", stored.Revision, "source", "admin")
|
|
writeJSON(w, http.StatusOK, preferencesPayload(stored))
|
|
}
|
|
|
|
// handleAdminResetPreferences puts someone back on the defaults. It is a write rather than
|
|
// a delete so it still bumps the revision — a delete would leave every television holding
|
|
// the old document with nothing to tell them it had gone.
|
|
func (s *Server) handleAdminResetPreferences(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
raw, err := json.Marshal(normalizePreferences(nil))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not build default settings")
|
|
return
|
|
}
|
|
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
|
|
Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
|
|
})
|
|
if err != nil {
|
|
s.loggerFor(r.Context()).Error("admin preferences reset failed", "user", userID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "could not reset those settings")
|
|
return
|
|
}
|
|
s.loggerFor(r.Context()).Info("settings reset to defaults",
|
|
"user", userID, "revision", stored.Revision, "source", "admin")
|
|
writeJSON(w, http.StatusOK, preferencesPayload(stored))
|
|
}
|
|
|
|
func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) {
|
|
userID := strings.TrimSpace(r.PathValue("userID"))
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, "user is required")
|
|
return
|
|
}
|
|
if err := s.store.ClearRecommendationOnboarding(r.Context(), userID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not reset recommendation choices")
|
|
return
|
|
}
|
|
if s.cache != nil {
|
|
_ = s.cache.Delete(r.Context(), cache.RecommendationsKey(userID))
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|