360 lines
13 KiB
Go
360 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ViewerKind separates the one viewer whose state is Emby's from the ones whose state is
|
|
// Memby's. It is stated on the row rather than derived from whether an id looks like an
|
|
// Emby GUID: the id shape is a safety property, not a source of truth, and a household
|
|
// that arrived at an odd id must not silently change which viewer publishes.
|
|
const (
|
|
ViewerMain = "main"
|
|
ViewerShadow = "shadow"
|
|
)
|
|
|
|
// ErrViewerNotFound is returned when an id names no viewer of the account that asked.
|
|
var ErrViewerNotFound = errors.New("store: viewer not found")
|
|
|
|
// MaxShadowViewers bounds an account's list. A picker is a row of cards on a television
|
|
// and the D-pad has to reach the end of it; this is a limit on the UI, not on the schema.
|
|
const MaxShadowViewers = 7
|
|
|
|
type Viewer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ShortName string `json:"shortName,omitempty"`
|
|
Colour string `json:"colour,omitempty"`
|
|
Kind string `json:"kind"`
|
|
HasPIN bool `json:"hasPin"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type DeviceRecoveryProfile struct {
|
|
DeviceID string
|
|
DeviceName string
|
|
UserID string
|
|
Username string
|
|
ServerID string
|
|
Viewer Viewer
|
|
EmbyToken string
|
|
}
|
|
|
|
var ErrPINLocked = errors.New("store: pin temporarily locked")
|
|
var ErrPINInvalid = errors.New("store: invalid pin")
|
|
|
|
// IsMain reports whether this viewer's state is published to Emby.
|
|
func (v Viewer) IsMain() bool { return v.Kind == ViewerMain }
|
|
|
|
// NewShadowViewerID mints an id that cannot be mistaken for an Emby user id.
|
|
//
|
|
// Emby's are 32 hex characters. This is a "v" followed by 32 more, so the two are
|
|
// distinguishable by inspection anywhere one is read out of a log line or a cache key —
|
|
// which matters because a viewer id is substituted for an emby_user_id in twenty tables
|
|
// that cannot tell the difference themselves.
|
|
func NewShadowViewerID() (string, error) {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("store: viewer id: %w", err)
|
|
}
|
|
return "v" + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// IsShadowViewerID reports whether an id belongs to the shadow namespace. Callers holding
|
|
// no viewer record use it to answer "is this Emby's user or Memby's" cheaply.
|
|
func IsShadowViewerID(id string) bool {
|
|
return strings.HasPrefix(id, "v") && len(id) == 33
|
|
}
|
|
|
|
// Viewers lists an account's viewers, main first and the rest in the order they were
|
|
// added. The main viewer is created on demand: an account that predates this feature has
|
|
// no row, and its first request must still resolve to something rather than to an error.
|
|
func (s *Store) Viewers(ctx context.Context, embyUserID, username string) ([]Viewer, error) {
|
|
if err := s.ensureMainViewer(ctx, embyUserID, username); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
|
|
FROM viewers WHERE emby_user_id = $1
|
|
ORDER BY kind = 'main' DESC, created_at, id`, embyUserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list viewers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
viewers := []Viewer{}
|
|
for rows.Next() {
|
|
var v Viewer
|
|
if err := rows.Scan(
|
|
&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("store: scan viewer: %w", err)
|
|
}
|
|
viewers = append(viewers, v)
|
|
}
|
|
return viewers, rows.Err()
|
|
}
|
|
|
|
// DeviceRecoveryProfiles is deliberately based on the surviving gateway session, not
|
|
// on IP address or device name. A reinstall loses local credentials but not this record.
|
|
func (s *Store) DeviceRecoveryProfiles(ctx context.Context, deviceID string) ([]DeviceRecoveryProfile, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT s.device_id, s.device_name, s.emby_user_id, s.username, s.server_id, s.emby_token
|
|
FROM sessions s WHERE s.device_id = $1 ORDER BY s.last_seen_at DESC LIMIT 1`, deviceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: device recovery profiles: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
profiles := []DeviceRecoveryProfile{}
|
|
for rows.Next() {
|
|
var p DeviceRecoveryProfile
|
|
if err := rows.Scan(&p.DeviceID, &p.DeviceName, &p.UserID, &p.Username, &p.ServerID, &p.EmbyToken); err != nil {
|
|
return nil, fmt.Errorf("store: scan recovery profile: %w", err)
|
|
}
|
|
viewers, err := s.Viewers(ctx, p.UserID, p.Username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, viewer := range viewers {
|
|
copy := p
|
|
copy.Viewer = viewer
|
|
profiles = append(profiles, copy)
|
|
}
|
|
}
|
|
return profiles, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SetViewerPIN(ctx context.Context, embyUserID, viewerID string, hash []byte) error {
|
|
result, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_hash = $3, pin_failed_attempts = 0,
|
|
pin_locked_until = NULL, updated_at = now() WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, hash)
|
|
if err != nil {
|
|
return fmt.Errorf("store: set viewer pin: %w", err)
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return ErrViewerNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetViewerPINValue(ctx context.Context, embyUserID, viewerID, pin string) error {
|
|
_, err := s.pool.Exec(ctx, `UPDATE viewers SET pin_value = $3 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, nilIfEmpty(pin))
|
|
return err
|
|
}
|
|
|
|
func nilIfEmpty(value string) any {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (s *Store) ClearViewerPIN(ctx context.Context, embyUserID, viewerID string) error {
|
|
return s.SetViewerPIN(ctx, embyUserID, viewerID, nil)
|
|
}
|
|
|
|
// ViewerPIN is intentionally not part of Viewer: only the admin handler may ask for it.
|
|
func (s *Store) ViewerPIN(ctx context.Context, embyUserID, viewerID string) (string, error) {
|
|
var pin *string
|
|
err := s.pool.QueryRow(ctx, `SELECT pin_value FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&pin)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrViewerNotFound
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if pin == nil {
|
|
return "", nil
|
|
}
|
|
return *pin, nil
|
|
}
|
|
|
|
// CheckViewerPIN applies the failed-attempt budget. Ten failures are not
|
|
// permanent revocation; they are a short server-side pause which survives a reinstall.
|
|
func (s *Store) CheckViewerPIN(ctx context.Context, embyUserID, viewerID string, valid func([]byte) bool) (bool, error) {
|
|
var hash []byte
|
|
var failures int
|
|
var lockedUntil *time.Time
|
|
err := s.pool.QueryRow(ctx, `SELECT pin_hash, pin_failed_attempts, pin_locked_until
|
|
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID).Scan(&hash, &failures, &lockedUntil)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, ErrViewerNotFound
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("store: read viewer pin: %w", err)
|
|
}
|
|
if len(hash) == 0 {
|
|
return true, nil
|
|
}
|
|
if lockedUntil != nil && time.Now().Before(*lockedUntil) {
|
|
return false, ErrPINLocked
|
|
}
|
|
if valid(hash) {
|
|
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = 0, pin_locked_until = NULL WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID)
|
|
return err == nil, err
|
|
}
|
|
failures++
|
|
if failures >= 5 {
|
|
lockedUntil = func() *time.Time { t := time.Now().Add(time.Duration(failures-4) * 15 * time.Second); return &t }()
|
|
}
|
|
_, err = s.pool.Exec(ctx, `UPDATE viewers SET pin_failed_attempts = $3, pin_locked_until = $4 WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID, failures, lockedUntil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return false, ErrPINInvalid
|
|
}
|
|
|
|
// ensureMainViewer records the account's own viewer if it has none.
|
|
//
|
|
// The insert is ON CONFLICT DO NOTHING on the primary key, so two televisions signing in
|
|
// at once cannot both create it, and the name is only ever set on the way in: the viewer
|
|
// may have been renamed since, and an Emby username arriving on every request must not
|
|
// overwrite that.
|
|
func (s *Store) ensureMainViewer(ctx context.Context, embyUserID, username string) error {
|
|
if strings.TrimSpace(embyUserID) == "" {
|
|
return fmt.Errorf("store: main viewer: no account")
|
|
}
|
|
name := strings.TrimSpace(username)
|
|
if name == "" {
|
|
name = "Me"
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO viewers (id, emby_user_id, name, kind)
|
|
VALUES ($1, $1, $2, 'main')
|
|
ON CONFLICT (id) DO NOTHING`, embyUserID, name)
|
|
if err != nil {
|
|
return fmt.Errorf("store: ensure main viewer: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ViewerFor resolves one viewer *of this account*.
|
|
//
|
|
// The account is part of the query rather than checked afterwards: the id arrives in a
|
|
// request header, so this is the boundary at which one household's television is stopped
|
|
// from naming another household's viewer.
|
|
func (s *Store) ViewerFor(ctx context.Context, embyUserID, viewerID string) (Viewer, error) {
|
|
var v Viewer
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at
|
|
FROM viewers WHERE emby_user_id = $1 AND id = $2`, embyUserID, viewerID,
|
|
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Viewer{}, ErrViewerNotFound
|
|
}
|
|
if err != nil {
|
|
return Viewer{}, fmt.Errorf("store: viewer: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// CreateShadowViewer adds a person to an account.
|
|
//
|
|
// The count is taken inside the transaction, because the limit is the only thing standing
|
|
// between a held D-pad on the add button and an unbounded picker.
|
|
func (s *Store) CreateShadowViewer(
|
|
ctx context.Context, embyUserID, name, shortName, colour string,
|
|
) (Viewer, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return Viewer{}, fmt.Errorf("store: viewer name is required")
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return Viewer{}, fmt.Errorf("store: begin create viewer: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var shadows int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT count(*) FROM viewers WHERE emby_user_id = $1 AND kind = 'shadow'`,
|
|
embyUserID,
|
|
).Scan(&shadows); err != nil {
|
|
return Viewer{}, fmt.Errorf("store: count viewers: %w", err)
|
|
}
|
|
if shadows >= MaxShadowViewers {
|
|
return Viewer{}, fmt.Errorf("store: %d viewers is the limit", MaxShadowViewers)
|
|
}
|
|
|
|
id, err := NewShadowViewerID()
|
|
if err != nil {
|
|
return Viewer{}, err
|
|
}
|
|
var v Viewer
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO viewers (id, emby_user_id, name, short_name, colour, kind)
|
|
VALUES ($1, $2, $3, $4, $5, 'shadow')
|
|
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
|
|
id, embyUserID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
|
|
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt); err != nil {
|
|
return Viewer{}, fmt.Errorf("store: create viewer: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Viewer{}, fmt.Errorf("store: commit create viewer: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// UpdateShadowViewer renames or re-colours a viewer. The main viewer is deliberately not
|
|
// updatable here: its name is the Emby account's and belongs to Emby.
|
|
func (s *Store) UpdateShadowViewer(
|
|
ctx context.Context, embyUserID, viewerID, name, shortName, colour string,
|
|
) (Viewer, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return Viewer{}, fmt.Errorf("store: viewer name is required")
|
|
}
|
|
var v Viewer
|
|
err := s.pool.QueryRow(ctx, `
|
|
UPDATE viewers SET name = $3, short_name = $4, colour = $5, updated_at = now()
|
|
WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'
|
|
RETURNING id, name, short_name, colour, kind, pin_hash IS NOT NULL, created_at`,
|
|
embyUserID, viewerID, name, strings.TrimSpace(shortName), strings.TrimSpace(colour),
|
|
).Scan(&v.ID, &v.Name, &v.ShortName, &v.Colour, &v.Kind, &v.HasPIN, &v.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Viewer{}, ErrViewerNotFound
|
|
}
|
|
if err != nil {
|
|
return Viewer{}, fmt.Errorf("store: update viewer: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// DeleteShadowViewer removes a viewer and everything Memby held on their behalf.
|
|
//
|
|
// A main viewer can never be deleted through this route: it is the account's own, and an
|
|
// account with no main viewer would have nothing to fall back to. The playback state goes
|
|
// with the row rather than being left to a housekeeping task, because the whole of what it
|
|
// describes is a person who no longer exists.
|
|
func (s *Store) DeleteShadowViewer(ctx context.Context, embyUserID, viewerID string) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("store: begin delete viewer: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
DELETE FROM viewers WHERE emby_user_id = $1 AND id = $2 AND kind = 'shadow'`,
|
|
embyUserID, viewerID)
|
|
if err != nil {
|
|
return fmt.Errorf("store: delete viewer: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrViewerNotFound
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM viewer_playback_state WHERE viewer_id = $1`, viewerID); err != nil {
|
|
return fmt.Errorf("store: delete viewer state: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("store: commit delete viewer: %w", err)
|
|
}
|
|
return nil
|
|
}
|