[spotify] Fix: Refresh-Token gets revoked when using two functions

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-05-13 18:26:38 +02:00
parent 5dd6a5323c
commit 30305600e7
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
5 changed files with 124 additions and 37 deletions

View file

@ -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 <channel>`
@ -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 <channel>`

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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 <channel>",
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 <channel>",
Example: &plugins.TemplateFuncDocumentationExample{
MatchMessage: "^!spotifylink",