package api import ( "encoding/json" "errors" "net/http" "strings" "github.com/ponzischeme89/memby/server/internal/store" ) // The people under one account, from the operator's side. // // It lives on the **account page** rather than on a rail entry of its own, which is the // whole of the design decision: a viewer only exists under an account, and a top-level // page would have to begin by asking which account is being talked about — a question the // page an operator reached this from has already answered. It is the arrangement the // per-account preference editor and the device list already take. // // A television can now do all of this for itself, so this is the operator's copy rather // than the only way in: what it is for is a household that has asked for help over the // phone, and the case a remote genuinely cannot reach — a viewer created on a set that has // since been unplugged. type adminViewersResponse struct { Viewers []store.Viewer `json:"viewers"` // Whether the household's own switch is on. The page says so rather than quietly // offering controls whose effect nothing on any television would show: an operator who // has switched viewers off and then adds one has done something that looks like it // worked and did nothing. Enabled bool `json:"enabled"` // What the gateway will accept, so the console can stop offering Add at the same point // the television does rather than discovering the limit by being refused. MaxShadowViewers int `json:"maxShadowViewers"` } func (s *Server) handleAdminViewers(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } if r.Method == http.MethodPost { s.handleAdminCreateViewer(w, r, userID) return } // The username is only used to name a main viewer that does not exist yet, and an // operator is not the right person to be naming somebody — an account that has never // had a request made against it gets the placeholder, and the television replaces it // with the real Emby name on its first sign-in. viewers, err := s.store.Viewers(r.Context(), userID, "") if err != nil { writeError(w, http.StatusInternalServerError, "could not load viewers") return } writeJSON(w, http.StatusOK, adminViewersResponse{ Viewers: viewers, Enabled: s.viewersEnabled(r.Context()), MaxShadowViewers: store.MaxShadowViewers, }) } func (s *Server) handleAdminCreateViewer(w http.ResponseWriter, r *http.Request, userID string) { var req viewerRequest if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if strings.TrimSpace(req.Name) == "" || len([]rune(req.Name)) > 40 { writeError(w, http.StatusBadRequest, "a name of up to 40 characters is required") return } viewer, err := s.store.CreateShadowViewer(r.Context(), userID, req.Name, req.ShortName, req.Colour) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // The televisions hold a cached list for viewerListTTL, so the write clears it here for // the same reason it does on the client-facing route: a person added from the console // must be pickable on the next request rather than at the end of the window. s.forgetViewers(userID) s.loggerFor(r.Context()).Info("viewer added by operator", "account", userID, "viewer", viewer.ID, "name", viewer.Name) writeJSON(w, http.StatusOK, viewer) } func (s *Server) handleAdminViewer(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) viewerID := strings.TrimSpace(r.PathValue("viewerID")) if userID == "" || viewerID == "" { writeError(w, http.StatusBadRequest, "user and viewer are required") return } if r.Method == http.MethodDelete { if err := s.store.DeleteShadowViewer(r.Context(), userID, viewerID); err != nil { if errors.Is(err, store.ErrViewerNotFound) { writeError(w, http.StatusNotFound, "no such viewer") return } writeError(w, http.StatusInternalServerError, "could not remove that viewer") return } s.forgetViewers(userID) // Everything cached under this viewer's own key is now about nobody. if err := s.cache.InvalidateUser(r.Context(), viewerID); err != nil { s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) } s.loggerFor(r.Context()).Info("viewer removed by operator", "account", userID, "viewer", viewerID) w.WriteHeader(http.StatusNoContent) return } var req viewerRequest if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } viewer, err := s.store.UpdateShadowViewer( r.Context(), userID, viewerID, req.Name, req.ShortName, req.Colour, ) if err != nil { if errors.Is(err, store.ErrViewerNotFound) { // The main viewer lands here too, and that is the honest answer: its name is // the Emby account's, so as a *shadow* viewer to rename it does not exist. writeError(w, http.StatusNotFound, "no such viewer") return } writeError(w, http.StatusBadRequest, err.Error()) return } s.forgetViewers(userID) s.loggerFor(r.Context()).Info("viewer renamed by operator", "account", userID, "viewer", viewer.ID, "name", viewer.Name) writeJSON(w, http.StatusOK, viewer) }