202 lines
7.2 KiB
Go
202 lines
7.2 KiB
Go
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 []adminViewer `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"`
|
|
}
|
|
|
|
type adminViewer struct {
|
|
store.Viewer
|
|
PIN string `json:"pin,omitempty"`
|
|
}
|
|
|
|
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
|
|
}
|
|
admin := make([]adminViewer, 0, len(viewers))
|
|
for _, viewer := range viewers {
|
|
pin, pinErr := s.store.ViewerPIN(r.Context(), userID, viewer.ID)
|
|
if pinErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not load PINs")
|
|
return
|
|
}
|
|
admin = append(admin, adminViewer{Viewer: viewer, PIN: pin})
|
|
}
|
|
writeJSON(w, http.StatusOK, adminViewersResponse{
|
|
Viewers: admin,
|
|
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
|
|
}
|
|
var requestedPINHash []byte
|
|
if req.PIN != "" {
|
|
var err error
|
|
requestedPINHash, err = pinHash(req.PIN)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
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
|
|
}
|
|
if req.PIN != "" {
|
|
if err := s.store.SetViewerPIN(r.Context(), userID, viewer.ID, requestedPINHash); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not save PIN")
|
|
return
|
|
}
|
|
if err := s.store.SetViewerPINValue(r.Context(), userID, viewer.ID, req.PIN); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not save PIN")
|
|
return
|
|
}
|
|
viewer.HasPIN = true
|
|
}
|
|
// 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
|
|
}
|
|
var viewer store.Viewer
|
|
var err error
|
|
// The main viewer cannot be renamed here, but its PIN is an account credential and
|
|
// administrators must still be able to set or reset it.
|
|
if viewerID == userID && (req.PIN != "" || req.ClearPIN) {
|
|
viewer, err = s.store.ViewerFor(r.Context(), userID, viewerID)
|
|
} else {
|
|
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
|
|
}
|
|
if req.ClearPIN || req.PIN != "" {
|
|
if req.ClearPIN {
|
|
err = s.store.ClearViewerPIN(r.Context(), userID, viewerID)
|
|
if err == nil {
|
|
err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, "")
|
|
}
|
|
} else {
|
|
var hash []byte
|
|
hash, err = pinHash(req.PIN)
|
|
if err == nil {
|
|
err = s.store.SetViewerPIN(r.Context(), userID, viewerID, hash)
|
|
if err == nil {
|
|
err = s.store.SetViewerPINValue(r.Context(), userID, viewerID, req.PIN)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
viewer.HasPIN = !req.ClearPIN
|
|
}
|
|
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)
|
|
}
|