From 30305600e78be391a64a9ce001fbecadf802b2e7 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Mon, 13 May 2024 18:26:38 +0200 Subject: [PATCH] [spotify] Fix: Refresh-Token gets revoked when using two functions Signed-off-by: Knut Ahlers --- docs/content/configuration/templating.md | 6 +- internal/actors/spotify/auth.go | 101 +++++++++++++++++++++++ internal/actors/spotify/client.go | 33 ++++---- internal/actors/spotify/http.go | 17 ---- internal/actors/spotify/spotify.go | 4 +- 5 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 internal/actors/spotify/auth.go diff --git a/docs/content/configuration/templating.md b/docs/content/configuration/templating.md index fb99051..5988789 100644 --- a/docs/content/configuration/templating.md +++ b/docs/content/configuration/templating.md @@ -467,12 +467,12 @@ Example: ``` # Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% -< Your int this hour: 72% +< Your int this hour: 82% ``` ### `spotifyCurrentPlaying` -Retrieves the current playing track for the given channel +Retrieves the current playing track for the given channel (returns an empty string when nothing is playing) Syntax: `spotifyCurrentPlaying ` @@ -487,7 +487,7 @@ Example: ### `spotifyLink` -Retrieves the link for the playing track for the given channel +Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing) Syntax: `spotifyLink ` diff --git a/internal/actors/spotify/auth.go b/internal/actors/spotify/auth.go new file mode 100644 index 0000000..1e1cc3c --- /dev/null +++ b/internal/actors/spotify/auth.go @@ -0,0 +1,101 @@ +package spotify + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Luzifer/twitch-bot/v3/internal/locker" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +const expiryGrace = 10 * time.Second + +func getAuthorizedClient(channel, redirectURL string) (client *http.Client, err error) { + // In templating functions are called multiple times at once which + // with Spotify replacing the refresh-token on each renew would kill + // the stored token when multiple spotify functions are called at + // once. Therefore we do have this method locking itself until it + // has successfully made one request to the users profile and therefore + // renewed the token. The next request then will use the token the + // previous request renewed. + locker.LockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":")) + defer locker.UnlockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":")) + + conf, err := oauthConfig(channel, redirectURL) + if err != nil { + return nil, fmt.Errorf("getting oauth config: %w", err) + } + + var token *oauth2.Token + if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil { + return nil, fmt.Errorf("loading oauth token: %w", err) + } + + ts := conf.TokenSource(context.Background(), token) + + if token.Expiry.After(time.Now().Add(expiryGrace)) { + // Token is still valid long enough, we spare the resources to do + // the profile fetch and directly return the client with the token + // as the scenario described here does not apply. + return oauth2.NewClient(context.Background(), ts), nil + } + + logrus.WithField("channel", channel).Debug("refreshing spotify token") + + ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout) + defer cancel() + + // We do a request to /me once to refresh the token if needed + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.spotify.com/v1/me", nil) + if err != nil { + return nil, fmt.Errorf("creating currently-playing request: %w", err) + } + + oauthClient := oauth2.NewClient(context.Background(), ts) + + resp, err := oauthClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logrus.WithError(err).Error("closing Spotify response body (leaked fd)") + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("requesting user profile: %w", err) + } + + updToken, err := ts.Token() + if err != nil { + return nil, fmt.Errorf("getting updated token: %w", err) + } + + if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), updToken); err != nil { + logrus.WithError(err).Error("storing back Spotify auth token") + } + + return oauthClient, nil +} + +func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) { + clientID, err := getModuleConfig(actorName, channel).String("clientId") + if err != nil { + return nil, fmt.Errorf("getting clientId for channel: %w", err) + } + + return &oauth2.Config{ + ClientID: clientID, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://accounts.spotify.com/authorize", + TokenURL: "https://accounts.spotify.com/api/token", + }, + RedirectURL: redirectURL, + Scopes: []string{"user-read-currently-playing"}, + }, nil +} diff --git a/internal/actors/spotify/client.go b/internal/actors/spotify/client.go index 97fdb32..9948e04 100644 --- a/internal/actors/spotify/client.go +++ b/internal/actors/spotify/client.go @@ -3,34 +3,25 @@ package spotify import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "strings" "github.com/sirupsen/logrus" - "golang.org/x/oauth2" ) +var errNotPlaying = errors.New("nothing playing") + func getCurrentTrackForChannel(channel string) (track currentPlayingTrackResponse, err error) { channel = strings.TrimLeft(channel, "#") - conf, err := oauthConfig(channel, "") + client, err := getAuthorizedClient(channel, "") if err != nil { - return track, fmt.Errorf("getting oauth config: %w", err) + return track, fmt.Errorf("retrieving authorized Spotify client: %w", err) } - var token *oauth2.Token - if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil { - return track, fmt.Errorf("loading oauth token: %w", err) - } - - defer func() { - if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil { - logrus.WithError(err).Error("storing back Spotify auth token") - } - }() - ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout) defer cancel() @@ -39,7 +30,7 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons return track, fmt.Errorf("creating currently-playing request: %w", err) } - resp, err := conf.Client(context.Background(), token).Do(req) + resp, err := client.Do(req) if err != nil { return track, fmt.Errorf("executing request: %w", err) } @@ -58,6 +49,10 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons case http.StatusOK: // This is perfect, continue below + case http.StatusNoContent: + // User is not playing anything + return track, errNotPlaying + case http.StatusUnauthorized: // The token is FUBAR return track, fmt.Errorf("token expired (HTTP 401 - unauthorized)") @@ -85,6 +80,10 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err error) { track, err := getCurrentTrackForChannel(channel) if err != nil { + if errors.Is(err, errNotPlaying) { + return "", nil + } + return "", fmt.Errorf("getting track info: %w", err) } @@ -102,6 +101,10 @@ func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err er func getCurrentLinkForChannel(channel string) (link string, err error) { track, err := getCurrentTrackForChannel(channel) if err != nil { + if errors.Is(err, errNotPlaying) { + return "", nil + } + return "", fmt.Errorf("getting track info: %w", err) } diff --git a/internal/actors/spotify/http.go b/internal/actors/spotify/http.go index 771456a..0c13191 100644 --- a/internal/actors/spotify/http.go +++ b/internal/actors/spotify/http.go @@ -70,20 +70,3 @@ func handleStartAuth(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Spotify is now authorized for this channel, you can close this page") } - -func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) { - clientID, err := getModuleConfig(actorName, channel).String("clientId") - if err != nil { - return nil, fmt.Errorf("getting clientId for channel: %w", err) - } - - return &oauth2.Config{ - ClientID: clientID, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://accounts.spotify.com/authorize", - TokenURL: "https://accounts.spotify.com/api/token", - }, - RedirectURL: redirectURL, - Scopes: []string{"user-read-currently-playing"}, - }, nil -} diff --git a/internal/actors/spotify/spotify.go b/internal/actors/spotify/spotify.go index e267005..ed3ee85 100644 --- a/internal/actors/spotify/spotify.go +++ b/internal/actors/spotify/spotify.go @@ -35,7 +35,7 @@ func Register(args plugins.RegistrationArguments) (err error) { plugins.GenericTemplateFunctionGetter(getCurrentArtistTitleForChannel), plugins.TemplateFuncDocumentation{ Name: "spotifyCurrentPlaying", - Description: "Retrieves the current playing track for the given channel", + Description: "Retrieves the current playing track for the given channel (returns an empty string when nothing is playing)", Syntax: "spotifyCurrentPlaying ", Example: &plugins.TemplateFuncDocumentationExample{ MatchMessage: "^!spotify", @@ -51,7 +51,7 @@ func Register(args plugins.RegistrationArguments) (err error) { plugins.GenericTemplateFunctionGetter(getCurrentLinkForChannel), plugins.TemplateFuncDocumentation{ Name: "spotifyLink", - Description: "Retrieves the link for the playing track for the given channel", + Description: "Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing)", Syntax: "spotifyLink ", Example: &plugins.TemplateFuncDocumentationExample{ MatchMessage: "^!spotifylink",