navidrome/core/agents/lastfm/auth_router.go

196 lines
4.9 KiB
Go

package lastfm
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/ReneKroon/ttlcache/v2"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const (
authURL = "https://www.last.fm/api/auth/"
sessionKeyPropertyPrefix = "LastFMSessionKey_"
)
var (
ErrLinkPending = errors.New("linking pending")
ErrUnlinked = errors.New("account not linked")
)
type Router struct {
http.Handler
ds model.DataStore
client *Client
sessionMan *sessionMan
apiKey string
secret string
}
func NewRouter(ds model.DataStore) *Router {
r := &Router{ds: ds, apiKey: lastFMAPIKey, secret: lastFMAPISecret}
r.Handler = r.routes()
if conf.Server.LastFM.ApiKey != "" {
r.apiKey = conf.Server.LastFM.ApiKey
r.secret = conf.Server.LastFM.Secret
}
r.client = NewClient(r.apiKey, r.secret, "en", http.DefaultClient)
r.sessionMan = newSessionMan(ds, r.client)
return r
}
func (s *Router) routes() http.Handler {
r := chi.NewRouter()
r.Use(server.Authenticator(s.ds))
r.Use(server.JWTRefresher)
r.Get("/link", s.starLink)
r.Get("/link/status", s.getLinkStatus)
r.Delete("/link", s.unlink)
return r
}
func (s *Router) starLink(w http.ResponseWriter, r *http.Request) {
token, err := s.client.GetToken(r.Context())
if err != nil {
log.Error(r.Context(), "Error obtaining token from LastFM", err)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(fmt.Sprintf("Error obtaining token from LastFM: %s", err)))
return
}
username, _ := request.UsernameFrom(r.Context())
s.sessionMan.FetchSession(username, token)
params := url.Values{}
params.Add("api_key", s.apiKey)
params.Add("token", token)
http.Redirect(w, r, authURL+"?"+params.Encode(), http.StatusFound)
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
username, _ := request.UsernameFrom(ctx)
_, err := s.sessionMan.Session(ctx, username)
resp := map[string]string{"status": "linked"}
if err != nil {
switch err {
case ErrLinkPending:
resp["status"] = "pending"
case ErrUnlinked:
resp["status"] = "unlinked"
default:
resp["status"] = "unlinked"
resp["error"] = err.Error()
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
return
}
}
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
username, _ := request.UsernameFrom(ctx)
err := s.sessionMan.RemoveSession(ctx, username)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
}
}
type sessionMan struct {
ds model.DataStore
client *Client
tokens *ttlcache.Cache
}
func newSessionMan(ds model.DataStore, client *Client) *sessionMan {
s := &sessionMan{
ds: ds,
client: client,
}
s.tokens = ttlcache.NewCache()
s.tokens.SetCacheSizeLimit(0)
_ = s.tokens.SetTTL(30 * time.Second)
s.tokens.SkipTTLExtensionOnHit(true)
go s.run()
return s
}
func (s *sessionMan) FetchSession(username, token string) {
_ = s.ds.Property(context.Background()).Delete(sessionKeyPropertyPrefix + username)
_ = s.tokens.Set(username, token)
}
func (s *sessionMan) Session(ctx context.Context, username string) (string, error) {
properties := s.ds.Property(context.Background())
key, err := properties.Get(sessionKeyPropertyPrefix + username)
if key != "" {
return key, nil
}
if err != nil && err != model.ErrNotFound {
return "", err
}
_, err = s.tokens.Get(username)
if err == nil {
return "", ErrLinkPending
}
return "", ErrUnlinked
}
func (s *sessionMan) RemoveSession(ctx context.Context, username string) error {
_ = s.tokens.Remove(username)
properties := s.ds.Property(context.Background())
return properties.Delete(sessionKeyPropertyPrefix + username)
}
func (s *sessionMan) run() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
<-t.C
if s.tokens.Count() == 0 {
continue
}
s.fetchSessions()
}
}
func (s *sessionMan) fetchSessions() {
ctx := context.Background()
for _, username := range s.tokens.GetKeys() {
token, err := s.tokens.Get(username)
if err != nil {
log.Error("Error retrieving token from cache", "username", username, err)
_ = s.tokens.Remove(username)
continue
}
sessionKey, err := s.client.GetSession(ctx, token.(string))
log.Debug(ctx, "Fetching session", "username", username, "sessionKey", sessionKey, "token", token, err)
if err != nil {
continue
}
properties := s.ds.Property(ctx)
err = properties.Put(sessionKeyPropertyPrefix+username, sessionKey)
if err != nil {
log.Error("Could not save LastFM session key", "username", username, err)
}
_ = s.tokens.Remove(username)
}
}