package quickconnect
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/singleton"
)
const timeout = 10 * time.Minute
const maxPending = 1000
var (
ErrAlreadyAuthorized = errors.New("quick connect request already authorized")
ErrTooManyRequests = errors.New("too many pending quick connect requests")
)
type Device struct {
ID string
Name string
App string
AppVersion string
}
type Request struct {
Device Device
Secret string
Code string
DateAdded time.Time
UserID string
}
func (r Request) Authorized() bool {
return r.UserID != ""
}
type QuickConnect interface {
Initiate(device Device) (Request, error)
Status(secret string) (Request, error)
Lookup(code string) (Request, error)
Authorize(code, userID string) (Request, error)
Redeem(secret string) (string, error)
}
type entry struct {
Request
expiresAt time.Time
}
type quickConnect struct {
mu sync.Mutex
bySecret map[string]*entry
byCode map[string]*entry
}
func GetInstance() QuickConnect {
return singleton.GetInstance(newStore)
}
func New() QuickConnect {
return newStore()
}
func newStore() *quickConnect {
return &quickConnect{
bySecret: map[string]*entry{},
byCode: map[string]*entry{},
}
}
func Enabled() bool {
return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect
}
func (qc *quickConnect) Initiate(device Device) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
qc.expire()
if len(qc.bySecret) >= maxPending {
return Request{}, ErrTooManyRequests
}
device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name),
App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)}
now := time.Now()
e := &entry{
Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now},
expiresAt: now.Add(timeout),
}
qc.bySecret[e.Secret] = e
qc.byCode[e.Code] = e
return e.Request, nil
}
func (qc *quickConnect) Status(secret string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Lookup(code string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Authorize(code, userID string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
e.UserID = userID
e.expiresAt = time.Now().Add(timeout)
return e.Request, nil
}
func (qc *quickConnect) Redeem(secret string) (string, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil || !e.Authorized() {
return "", model.ErrNotFound
}
qc.remove(e)
return e.UserID, nil
}
func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) {
qc.expire()
e, ok := index[key]
if !ok {
return nil, model.ErrNotFound
}
return e, nil
}
func (qc *quickConnect) findPending(code string) (*entry, error) {
e, err := qc.find(qc.byCode, normalizeCode(code))
if err == nil && e.Authorized() {
return nil, ErrAlreadyAuthorized
}
return e, err
}
func (qc *quickConnect) expire() {
now := time.Now()
for _, e := range qc.bySecret {
if !now.Before(e.expiresAt) {
qc.remove(e)
}
}
}
func (qc *quickConnect) remove(e *entry) {
delete(qc.bySecret, e.Secret)
delete(qc.byCode, e.Code)
}
func (qc *quickConnect) newCode() string {
for {
code := fmt.Sprintf("%06d", random.Int64N(900000)+100000)
if _, taken := qc.byCode[code]; !taken {
return code
}
}
}
func newSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func normalizeCode(code string) string {
return strings.Join(strings.Fields(code), "")
}