Implement Last.FM Desktop Auth flow endpoints

This commit is contained in:
Deluan 2021-06-18 18:27:15 -04:00 committed by Deluan Quintão
parent 8ee5c1f245
commit 502a719e96
8 changed files with 224 additions and 16 deletions

View File

@ -75,6 +75,9 @@ func startServer() (func() error, func(err error)) {
a := CreateServer(conf.Server.MusicFolder) a := CreateServer(conf.Server.MusicFolder)
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter()) a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter()) a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
if conf.Server.DevEnableScrobble {
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
}
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port)) return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
}, func(err error) { }, func(err error) {
if err != nil { if err != nil {

View File

@ -8,6 +8,7 @@ package cmd
import ( import (
"github.com/google/wire" "github.com/google/wire"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcoder" "github.com/navidrome/navidrome/core/transcoder"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
@ -53,6 +54,12 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
return router return router
} }
func CreateLastFMRouter() *lastfm.Router {
dataStore := persistence.New()
router := lastfm.NewRouter(dataStore)
return router
}
func createScanner() scanner.Scanner { func createScanner() scanner.Scanner {
dataStore := persistence.New() dataStore := persistence.New()
artworkCache := core.GetImageCache() artworkCache := core.GetImageCache()
@ -75,7 +82,7 @@ func createScheduler() scheduler.Scheduler {
// wire_injectors.go: // wire_injectors.go:
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, GetBroker) var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, GetBroker)
// Scanner must be a Singleton // Scanner must be a Singleton
var ( var (

View File

@ -7,6 +7,7 @@ import (
"github.com/google/wire" "github.com/google/wire"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/scheduler"
@ -21,6 +22,7 @@ var allProviders = wire.NewSet(
subsonic.New, subsonic.New,
nativeapi.New, nativeapi.New,
persistence.New, persistence.New,
lastfm.NewRouter,
GetBroker, GetBroker,
) )
@ -44,6 +46,12 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
)) ))
} }
func CreateLastFMRouter() *lastfm.Router {
panic(wire.Build(
allProviders,
))
}
// Scanner must be a Singleton // Scanner must be a Singleton
var ( var (
onceScanner sync.Once onceScanner sync.Once

View File

@ -0,0 +1,195 @@
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)
}
}

View File

@ -12,5 +12,6 @@ type Property struct {
type PropertyRepository interface { type PropertyRepository interface {
Put(id string, value string) error Put(id string, value string) error
Get(id string) (string, error) Get(id string) (string, error)
Delete(id string) error
DefaultGet(id string, defaultValue string) (string, error) DefaultGet(id string, defaultValue string) (string, error)
} }

View File

@ -3,7 +3,7 @@ package persistence
import ( import (
"context" "context"
"github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm" "github.com/astaxie/beego/orm"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
) )
@ -21,7 +21,7 @@ func NewPropertyRepository(ctx context.Context, o orm.Ormer) model.PropertyRepos
} }
func (r propertyRepository) Put(id string, value string) error { func (r propertyRepository) Put(id string, value string) error {
update := squirrel.Update(r.tableName).Set("value", value).Where(squirrel.Eq{"id": id}) update := Update(r.tableName).Set("value", value).Where(Eq{"id": id})
count, err := r.executeSQL(update) count, err := r.executeSQL(update)
if err != nil { if err != nil {
return nil return nil
@ -29,13 +29,13 @@ func (r propertyRepository) Put(id string, value string) error {
if count > 0 { if count > 0 {
return nil return nil
} }
insert := squirrel.Insert(r.tableName).Columns("id", "value").Values(id, value) insert := Insert(r.tableName).Columns("id", "value").Values(id, value)
_, err = r.executeSQL(insert) _, err = r.executeSQL(insert)
return err return err
} }
func (r propertyRepository) Get(id string) (string, error) { func (r propertyRepository) Get(id string) (string, error) {
sel := squirrel.Select("value").From(r.tableName).Where(squirrel.Eq{"id": id}) sel := Select("value").From(r.tableName).Where(Eq{"id": id})
resp := struct { resp := struct {
Value string Value string
}{} }{}
@ -56,3 +56,7 @@ func (r propertyRepository) DefaultGet(id string, defaultValue string) (string,
} }
return value, nil return value, nil
} }
func (r propertyRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}

View File

@ -15,7 +15,6 @@ import (
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/ui" "github.com/navidrome/navidrome/ui"
"github.com/navidrome/navidrome/utils"
) )
type Server struct { type Server struct {
@ -85,15 +84,6 @@ func (s *Server) initRoutes() {
r.Post("/createAdmin", createAdmin(s.ds)) r.Post("/createAdmin", createAdmin(s.ds))
}) })
r.Get("/api/lastfm/link/status", func(w http.ResponseWriter, r *http.Request) {
rs := "false"
c := utils.ParamInt(r, "c", 0)
if (c == 4) {
rs = "true"
}
_, _ = w.Write([]byte(rs))
})
// Redirect root to UI URL // Redirect root to UI URL
r.Get("/*", func(w http.ResponseWriter, r *http.Request) { r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.appRoot+"/", http.StatusFound) http.Redirect(w, r, s.appRoot+"/", http.StatusFound)

View File

@ -3,7 +3,7 @@ package tests
import "github.com/navidrome/navidrome/model" import "github.com/navidrome/navidrome/model"
type MockedPropertyRepo struct { type MockedPropertyRepo struct {
model.UserRepository model.PropertyRepository
data map[string]string data map[string]string
err error err error
} }