diff --git a/core/agents/lastfm/client.go b/core/agents/lastfm/client.go index 22b22c974..afe3a1686 100644 --- a/core/agents/lastfm/client.go +++ b/core/agents/lastfm/client.go @@ -2,12 +2,17 @@ package lastfm import ( "context" + "crypto/md5" + "encoding/hex" "encoding/json" "fmt" - "io/ioutil" "net/http" "net/url" + "sort" "strconv" + "strings" + + "github.com/navidrome/navidrome/utils" ) const ( @@ -27,52 +32,17 @@ type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func NewClient(apiKey string, lang string, hc httpDoer) *Client { - return &Client{apiKey, lang, hc} +func NewClient(apiKey string, secret string, lang string, hc httpDoer) *Client { + return &Client{apiKey, secret, lang, hc} } type Client struct { apiKey string + secret string lang string hc httpDoer } -func (c *Client) makeRequest(params url.Values) (*Response, error) { - params.Add("format", "json") - params.Add("api_key", c.apiKey) - - req, _ := http.NewRequest("GET", apiBaseUrl, nil) - req.URL.RawQuery = params.Encode() - - resp, err := c.hc.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var response Response - jsonErr := json.Unmarshal(data, &response) - - if resp.StatusCode != 200 && jsonErr != nil { - return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode) - } - - if jsonErr != nil { - return nil, jsonErr - } - - if response.Error != 0 { - return &response, &lastFMError{Code: response.Error, Message: response.Message} - } - - return &response, nil -} - func (c *Client) ArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) { params := url.Values{} params.Add("method", "artist.getInfo") @@ -111,3 +81,76 @@ func (c *Client) ArtistGetTopTracks(ctx context.Context, name string, mbid strin } return &response.TopTracks, nil } + +func (c *Client) GetToken(ctx context.Context) (string, error) { + params := url.Values{} + params.Add("method", "auth.getToken") + c.sign(params) + response, err := c.makeRequest(params) + if err != nil { + return "", err + } + return response.Token, nil +} + +func (c *Client) GetSession(ctx context.Context, token string) (string, error) { + params := url.Values{} + params.Add("method", "auth.getSession") + params.Add("token", token) + c.sign(params) + response, err := c.makeRequest(params) + if err != nil { + return "", err + } + return response.Session.Key, nil +} + +func (c *Client) makeRequest(params url.Values) (*Response, error) { + params.Add("format", "json") + params.Add("api_key", c.apiKey) + + req, _ := http.NewRequest("GET", apiBaseUrl, nil) + req.URL.RawQuery = params.Encode() + + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response Response + jsonErr := decoder.Decode(&response) + if resp.StatusCode != 200 && jsonErr != nil { + return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode) + } + if jsonErr != nil { + return nil, jsonErr + } + if response.Error != 0 { + return &response, &lastFMError{Code: response.Error, Message: response.Message} + } + + return &response, nil +} + +func (c *Client) sign(params url.Values) { + // the parameters must be in order before hashing + keys := make([]string, 0, len(params)) + for k := range params { + if utils.StringInSlice(k, []string{"format", "callback"}) { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + msg := strings.Builder{} + for _, k := range keys { + msg.WriteString(k) + msg.WriteString(params[k][0]) + } + msg.WriteString(c.secret) + hash := md5.Sum([]byte(msg.String())) + params.Add("api_sig", hex.EncodeToString(hash[:])) +} diff --git a/core/agents/lastfm/client_test.go b/core/agents/lastfm/client_test.go index e82dc2cae..cdd707d36 100644 --- a/core/agents/lastfm/client_test.go +++ b/core/agents/lastfm/client_test.go @@ -3,9 +3,12 @@ package lastfm import ( "bytes" "context" + "crypto/md5" "errors" + "fmt" "io/ioutil" "net/http" + "net/url" "os" "github.com/navidrome/navidrome/tests" @@ -19,7 +22,7 @@ var _ = Describe("Client", func() { BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client = NewClient("API_KEY", "pt", httpClient) + client = NewClient("API_KEY", "SECRET", "pt", httpClient) }) Describe("ArtistGetInfo", func() { @@ -27,7 +30,7 @@ var _ = Describe("Client", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - artist, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + artist, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(BeNil()) Expect(artist.Name).To(Equal("U2")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo")) @@ -39,7 +42,7 @@ var _ = Describe("Client", func() { StatusCode: 500, } - _, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + _, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(MatchError("last.fm http status: (500)")) }) @@ -49,7 +52,7 @@ var _ = Describe("Client", func() { StatusCode: 400, } - _, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + _, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) }) @@ -59,14 +62,14 @@ var _ = Describe("Client", func() { StatusCode: 200, } - _, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + _, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) }) It("fails if HttpClient.Do() returns error", func() { httpClient.Err = errors.New("generic error") - _, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + _, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(MatchError("generic error")) }) @@ -76,7 +79,7 @@ var _ = Describe("Client", func() { StatusCode: 200, } - _, err := client.ArtistGetInfo(context.TODO(), "U2", "123") + _, err := client.ArtistGetInfo(context.Background(), "U2", "123") Expect(err).To(MatchError("invalid character '<' looking for beginning of value")) }) @@ -87,7 +90,7 @@ var _ = Describe("Client", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - similar, err := client.ArtistGetSimilar(context.TODO(), "U2", "123", 2) + similar, err := client.ArtistGetSimilar(context.Background(), "U2", "123", 2) Expect(err).To(BeNil()) Expect(len(similar.Artists)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getSimilar")) @@ -99,10 +102,60 @@ var _ = Describe("Client", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - top, err := client.ArtistGetTopTracks(context.TODO(), "U2", "123", 2) + top, err := client.ArtistGetTopTracks(context.Background(), "U2", "123", 2) Expect(err).To(BeNil()) Expect(len(top.Track)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getTopTracks")) }) }) + + Describe("GetToken", func() { + It("returns a token when the request is successful", func() { + httpClient.Res = http.Response{ + Body: ioutil.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)), + StatusCode: 200, + } + + Expect(client.GetToken(context.Background())).To(Equal("TOKEN")) + queryParams := httpClient.SavedRequest.URL.Query() + Expect(queryParams.Get("method")).To(Equal("auth.getToken")) + Expect(queryParams.Get("format")).To(Equal("json")) + Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) + Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + + Describe("GetSession", func() { + It("returns a session key when the request is successful", func() { + httpClient.Res = http.Response{ + Body: ioutil.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)), + StatusCode: 200, + } + + Expect(client.GetSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY")) + queryParams := httpClient.SavedRequest.URL.Query() + Expect(queryParams.Get("method")).To(Equal("auth.getSession")) + Expect(queryParams.Get("format")).To(Equal("json")) + Expect(queryParams.Get("token")).To(Equal("TOKEN")) + Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) + Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + + Describe("sign", func() { + It("adds an api_sig param with the signature", func() { + params := url.Values{} + params.Add("d", "444") + params.Add("callback", "https://myserver.com") + params.Add("a", "111") + params.Add("format", "json") + params.Add("c", "333") + params.Add("b", "222") + client.sign(params) + Expect(params).To(HaveKey("api_sig")) + sig := params.Get("api_sig") + expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET"))) + Expect(sig).To(Equal(expected)) + }) + }) }) diff --git a/core/agents/lastfm/lastfm.go b/core/agents/lastfm/lastfm.go index 37f4a2007..c9c036a36 100644 --- a/core/agents/lastfm/lastfm.go +++ b/core/agents/lastfm/lastfm.go @@ -14,28 +14,30 @@ import ( const ( lastFMAgentName = "lastfm" lastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" - //lastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // Will be needed when implementing Scrobbling + lastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec ) type lastfmAgent struct { ctx context.Context apiKey string + secret string lang string client *Client } func lastFMConstructor(ctx context.Context) agents.Interface { l := &lastfmAgent{ - ctx: ctx, - lang: conf.Server.LastFM.Language, + ctx: ctx, + lang: conf.Server.LastFM.Language, + apiKey: lastFMAPIKey, + secret: lastFMAPISecret, } if conf.Server.LastFM.ApiKey != "" { l.apiKey = conf.Server.LastFM.ApiKey - } else { - l.apiKey = lastFMAPIKey + l.secret = conf.Server.LastFM.Secret } hc := utils.NewCachedHTTPClient(http.DefaultClient, consts.DefaultCachedHttpClientTTL) - l.client = NewClient(l.apiKey, l.lang, hc) + l.client = NewClient(l.apiKey, l.secret, l.lang, hc) return l } diff --git a/core/agents/lastfm/lastfm_test.go b/core/agents/lastfm/lastfm_test.go index 13bab4ee0..f4b771e88 100644 --- a/core/agents/lastfm/lastfm_test.go +++ b/core/agents/lastfm/lastfm_test.go @@ -24,7 +24,7 @@ var _ = Describe("lastfmAgent", func() { Describe("lastFMConstructor", func() { It("uses default api key and language if not configured", func() { conf.Server.LastFM.ApiKey = "" - agent := lastFMConstructor(context.TODO()) + agent := lastFMConstructor(context.Background()) Expect(agent.(*lastfmAgent).apiKey).To(Equal(lastFMAPIKey)) Expect(agent.(*lastfmAgent).lang).To(Equal("en")) }) @@ -32,7 +32,7 @@ var _ = Describe("lastfmAgent", func() { It("uses configured api key and language", func() { conf.Server.LastFM.ApiKey = "123" conf.Server.LastFM.Language = "pt" - agent := lastFMConstructor(context.TODO()) + agent := lastFMConstructor(context.Background()) Expect(agent.(*lastfmAgent).apiKey).To(Equal("123")) Expect(agent.(*lastfmAgent).lang).To(Equal("pt")) }) @@ -43,8 +43,8 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := NewClient("API_KEY", "pt", httpClient) - agent = lastFMConstructor(context.TODO()).(*lastfmAgent) + client := NewClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(context.Background()).(*lastfmAgent) agent.client = client }) @@ -101,8 +101,8 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := NewClient("API_KEY", "pt", httpClient) - agent = lastFMConstructor(context.TODO()).(*lastfmAgent) + client := NewClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(context.Background()).(*lastfmAgent) agent.client = client }) @@ -162,8 +162,8 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := NewClient("API_KEY", "pt", httpClient) - agent = lastFMConstructor(context.TODO()).(*lastfmAgent) + client := NewClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(context.Background()).(*lastfmAgent) agent.client = client }) diff --git a/core/agents/lastfm/responses.go b/core/agents/lastfm/responses.go index 72fbc1fa9..a685ecc25 100644 --- a/core/agents/lastfm/responses.go +++ b/core/agents/lastfm/responses.go @@ -6,6 +6,8 @@ type Response struct { TopTracks TopTracks `json:"toptracks"` Error int `json:"error"` Message string `json:"message"` + Token string `json:"token"` + Session Session `json:"session"` } type Artist struct { @@ -59,3 +61,9 @@ type TopTracks struct { Track []Track `json:"track"` Attr Attr `json:"@attr"` } + +type Session struct { + Name string `json:"name"` + Key string `json:"key"` + Subscriber int `json:"subscriber"` +}