- Fix redirects to /login for auth required pages
- Add handling for 401/429 + No connection responses in API calls
- Add background workers for Go (clear out password resets)
- Fixed timezone issues
This commit is contained in:
Daniel Mason 2021-04-02 22:24:00 +13:00
parent fd615102a8
commit 9866dea36b
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
33 changed files with 795 additions and 248 deletions

View File

@ -9,8 +9,6 @@ REDIS_DB=
REDIS_PREFIX="gs:"
REDIS_AUTH=""
TIMEZONE=Etc/UTC
JWT_SECRET=
JWT_EXPIRY=86400

View File

@ -12,6 +12,11 @@ import (
"gitlab.com/idanoo/go-scrobble/internal/goscrobble"
)
func init() {
// Set UTC for errything
os.Setenv("TZ", "Etc/UTC")
}
func main() {
fmt.Println("Starting goscrobble")
err := godotenv.Load()
@ -51,8 +56,9 @@ func main() {
goscrobble.InitRedis()
defer goscrobble.CloseRedisConn()
// Clear old reset tokens regularly
// go goscrobble.ClearTokenTimer()
// Start background workers
go goscrobble.StartBackgroundWorkers()
defer goscrobble.EndBackgroundWorkers()
// Boot up API webserver \o/
goscrobble.HandleRequests(port)

View File

@ -1,3 +1,10 @@
# 0.0.11
- Fix redirects to /login for auth required pages
- Add handling for 401/429 + No connection responses in API calls
- Add background workers for Go (clear out password resets)
- Add spotify scrobbling!!!11111!!!!!
- Fixed timezone issues
# 0.0.10
- Fixed looking up invalid profiles
- Added valid error handling to bad request && rate limiting

View File

@ -1,3 +1,6 @@
## Timezones
GoScrobble runs as UTC and connects to MySQL as UTC. All timezone handling is done in the frontend.
## FRONTEND VARS
These are stored in `web/.env.production` and `web/.env.development`
@ -17,8 +20,6 @@ These are stored in `web/.env.production` and `web/.env.development`
REDIS_PREFIX="gs:" // Redis key prefix
REDIS_AUTH="" // Redis password
TIMEZONE= // Unix Timezone. Used for MySQL connection
JWT_SECRET= // 32+ Char JWT secret
JWT_EXPIRY=86400 // JWT expiry

2
go.mod
View File

@ -24,7 +24,9 @@ require (
github.com/sendgrid/rest v2.6.3+incompatible // indirect
github.com/sendgrid/sendgrid-go v3.8.0+incompatible
github.com/sirupsen/logrus v1.7.0 // indirect
github.com/zmb3/spotify v1.1.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba
google.golang.org/genproto v0.0.0-20201030142918-24207fddd1c3 // indirect
google.golang.org/grpc v1.33.1 // indirect

9
go.sum
View File

@ -1,4 +1,5 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
@ -104,6 +105,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zmb3/spotify v1.1.1 h1:3v2IxmzMrvl/1mieDINOI4JAXCJYWP/NQ4o2A1Ni35k=
github.com/zmb3/spotify v1.1.1/go.mod h1:GD7AAEMUJVYc2Z7p2a2S0E3/5f/KxM/vOnErNr4j+Tw=
go.opentelemetry.io/otel v0.19.0 h1:Lenfy7QHRXPZVsw/12CWpxX6d/JkrX8wrx2vO8G80Ng=
go.opentelemetry.io/otel v0.19.0/go.mod h1:j9bF567N9EfomkSidSfmMwIwIBuP37AMAIzVW85OxSg=
go.opentelemetry.io/otel/metric v0.19.0 h1:dtZ1Ju44gkJkYvo+3qGqVXmf88tc+a42edOywypengg=
@ -124,6 +127,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -133,8 +137,11 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -167,8 +174,10 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=

View File

@ -3,6 +3,7 @@ package goscrobble
import (
"database/sql"
"errors"
"fmt"
"log"
)
@ -12,41 +13,47 @@ type Album struct {
Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"`
}
// insertAlbum - This will return if it exists or create it based on MBID > Name
func insertAlbum(name string, mbid string, artists []string, tx *sql.Tx) (Album, error) {
func insertAlbum(name string, mbid string, spotifyId string, artists []string, tx *sql.Tx) (Album, error) {
album := Album{}
// Try locate our album
if mbid != "" {
album = fetchAlbum("mbid", mbid, tx)
if album.Uuid == "" {
err := insertNewAlbum(name, mbid, tx)
if err != nil {
log.Printf("Error inserting album via MBID %s %+v", name, err)
return album, errors.New("Failed to insert album")
}
} else if spotifyId != "" {
album = fetchAlbum("spotify_id", spotifyId, tx)
}
album = fetchAlbum("mbid", mbid, tx)
err = album.linkAlbumToArtists(artists, tx)
if err != nil {
return album, err
}
}
} else {
// If it didn't match above, lookup by name
if album.Uuid == "" {
album = fetchAlbum("name", name, tx)
if album.Uuid == "" {
err := insertNewAlbum(name, mbid, tx)
if err != nil {
log.Printf("Error inserting album via Name %s %+v", name, err)
return album, errors.New("Failed to insert album")
}
}
// If we can't find it. Lets add it!
if album.Uuid == "" {
err := insertNewAlbum(name, mbid, spotifyId, tx)
if err != nil {
return album, errors.New("Failed to insert album")
}
// Fetch the recently inserted album to get the UUID
if mbid != "" {
album = fetchAlbum("mbid", mbid, tx)
} else if spotifyId != "" {
album = fetchAlbum("spotify_id", spotifyId, tx)
}
if album.Uuid == "" {
album = fetchAlbum("name", name, tx)
err = album.linkAlbumToArtists(artists, tx)
if err != nil {
return album, err
}
}
// Try linkem up
err = album.linkAlbumToArtists(artists, tx)
if err != nil {
return album, errors.New("Unable to link albums!")
}
}
@ -72,9 +79,9 @@ func fetchAlbum(col string, val string, tx *sql.Tx) Album {
return album
}
func insertNewAlbum(name string, mbid string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `albums` (`uuid`, `name`, `mbid`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid)
func insertNewAlbum(name string, mbid string, spotifyId string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `albums` (`uuid`, `name`, `mbid`, `spotify_id`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?,?)", name, mbid, spotifyId)
return err
}
@ -85,9 +92,16 @@ func (album *Album) linkAlbumToArtists(artists []string, tx *sql.Tx) error {
_, err = tx.Exec("INSERT INTO `album_artist` (`album`, `artist`) "+
"VALUES (UUID_TO_BIN(?, true), UUID_TO_BIN(?, true))", album.Uuid, artist)
if err != nil {
fmt.Println(err)
return err
}
}
return err
}
func updateAlbum(uuid string, col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `albums` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid)
return err
}

View File

@ -12,36 +12,45 @@ type Artist struct {
Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"`
}
// insertArtist - This will return if it exists or create it based on MBID > Name
func insertArtist(name string, mbid string, tx *sql.Tx) (Artist, error) {
func insertArtist(name string, mbid string, spotifyId string, tx *sql.Tx) (Artist, error) {
artist := Artist{}
// Try locate our artist
if mbid != "" {
artist = fetchArtist("mbid", mbid, tx)
if artist.Uuid == "" {
err := insertNewArtist(name, mbid, tx)
if err != nil {
log.Printf("Error inserting artist via MBID %s %+v", name, err)
return artist, errors.New("Failed to insert artist")
}
} else if spotifyId != "" {
artist = fetchArtist("spotify_id", spotifyId, tx)
}
artist = fetchArtist("mbid", mbid, tx)
}
} else {
// If it didn't match above, lookup by name
if artist.Uuid == "" {
artist = fetchArtist("name", name, tx)
if artist.Uuid == "" {
err := insertNewArtist(name, mbid, tx)
if err != nil {
log.Printf("Error inserting artist via Name %s %+v", name, err)
return artist, errors.New("Failed to insert artist")
}
}
artist = fetchArtist("name", name, tx)
// If we can't find it. Lets add it!
if artist.Uuid == "" {
err := insertNewArtist(name, mbid, spotifyId, tx)
if err != nil {
log.Printf("Error inserting artist: %+v", err)
return artist, errors.New("Failed to insert artist")
}
}
// Fetch the recently inserted artist to get the UUID
if mbid != "" {
artist = fetchArtist("mbid", mbid, tx)
} else if spotifyId != "" {
artist = fetchArtist("spotify_id", spotifyId, tx)
}
if artist.Uuid == "" {
artist = fetchArtist("name", name, tx)
}
if artist.Uuid == "" {
return artist, errors.New("Unable to fetch artist!")
}
@ -64,9 +73,15 @@ func fetchArtist(col string, val string, tx *sql.Tx) Artist {
return artist
}
func insertNewArtist(name string, mbid string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `artists` (`uuid`, `name`, `mbid`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid)
func insertNewArtist(name string, mbid string, spotifyId string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `artists` (`uuid`, `name`, `mbid`, `spotify_id`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?,?)", name, mbid, spotifyId)
return err
}
func updateArtist(uuid string, col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `artists` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid)
return err
}

View File

@ -1,6 +1,7 @@
package goscrobble
import (
"database/sql"
"errors"
"fmt"
"log"
@ -55,3 +56,17 @@ func updateConfigValue(key string, value string) error {
return nil
}
func getConfigValue(key string) (string, error) {
var value string
err := db.QueryRow("SELECT `value` FROM `config` "+
"WHERE `key` = ?",
key).Scan(&value)
if err == sql.ErrNoRows {
return value, errors.New("Config key doesn't exist")
}
return value, nil
}

View File

@ -6,7 +6,6 @@ import (
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
@ -23,13 +22,8 @@ func InitDb() {
dbUser := os.Getenv("MYSQL_USER")
dbPass := os.Getenv("MYSQL_PASS")
dbName := os.Getenv("MYSQL_DB")
timeZone := os.Getenv("TIMEZONE")
dbTz := ""
if timeZone != "" {
dbTz = "&loc=" + strings.Replace(timeZone, "/", fmt.Sprintf("%%2F"), 1)
}
dbConn, err := sql.Open("mysql", dbUser+":"+dbPass+"@tcp("+dbHost+")/"+dbName+"?multiStatements=true&parseTime=true"+dbTz)
dbConn, err := sql.Open("mysql", dbUser+":"+dbPass+"@tcp("+dbHost+")/"+dbName+"?multiStatements=true&parseTime=true&loc=Etc%2FUTC")
if err != nil {
panic(err)
}

View File

@ -6,12 +6,13 @@ import (
"fmt"
"log"
"net"
"time"
)
// ParseJellyfinInput - Transform API data into a common struct
// ParseJellyfinInput - Transform API data into a common struct. Uses MusicBrainzID primarily
func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error {
// Debugging
fmt.Printf("%+v", data)
// fmt.Printf("%+v", data)
if data["ItemType"] != "Audio" {
return errors.New("Media type not audio")
@ -31,7 +32,7 @@ func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP,
}
// Insert artist if not exist
artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), tx)
artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), "", tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map artist")
@ -39,30 +40,29 @@ func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP,
// Insert album if not exist
artists := []string{artist.Uuid}
album, err := insertAlbum(fmt.Sprintf("%s", data["Album"]), fmt.Sprintf("%s", data["Provider_musicbrainzalbum"]), artists, tx)
album, err := insertAlbum(fmt.Sprintf("%s", data["Album"]), fmt.Sprintf("%s", data["Provider_musicbrainzalbum"]), "", artists, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map album")
}
// Insert album if not exist
track, err := insertTrack(fmt.Sprintf("%s", data["Name"]), fmt.Sprintf("%s", data["Provider_musicbrainztrack"]), album.Uuid, artists, tx)
// Insert track if not exist
length := timestampToSeconds(fmt.Sprintf("%s", data["RunTime"]))
track, err := insertTrack(fmt.Sprintf("%s", data["Name"]), length, fmt.Sprintf("%s", data["Provider_musicbrainztrack"]), "", album.Uuid, artists, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map track")
}
// Insert album if not exist
err = insertScrobble(userUUID, track.Uuid, "jellyfin", ip, tx)
// Insert scrobble if not exist
timestamp := time.Now()
fmt.Println(timestamp)
err = insertScrobble(userUUID, track.Uuid, "jellyfin", timestamp, ip, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map track")
}
_ = album
_ = artist
_ = track
// Insert track if not exist
return nil
}

View File

@ -0,0 +1,202 @@
package goscrobble
import (
"database/sql"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"time"
"github.com/zmb3/spotify"
"golang.org/x/oauth2"
)
// updateSpotifyData - Pull data for all users
func updateSpotifyData() {
// Lets ignore if not configured
val, _ := getConfigValue("SPOTIFY_APP_SECRET")
if val == "" {
return
}
// Get all active users with a spotify token
users, err := getAllSpotifyUsers()
if err != nil {
fmt.Printf("Failed to fetch spotify users")
return
}
for _, user := range users {
user.updateSpotifyPlaydata()
}
}
func getSpotifyAuthHandler() spotify.Authenticator {
appId, _ := getConfigValue("SPOTIFY_APP_ID")
appSecret, _ := getConfigValue("SPOTIFY_APP_SECRET")
redirectUrl := os.Getenv("GOSCROBBLE_DOMAIN") + "/api/v1/link/spotify"
if redirectUrl == "http://localhost:3000/api/v1/link/spotify" {
// Handle backend on a different port
redirectUrl = "http://localhost:42069/api/v1/link/spotify"
}
auth := spotify.NewAuthenticator(redirectUrl,
spotify.ScopeUserReadRecentlyPlayed, spotify.ScopeUserReadCurrentlyPlaying)
auth.SetAuthInfo(appId, appSecret)
return auth
}
func connectSpotifyResponse(r *http.Request) error {
urlParams := r.URL.Query()
userUuid := urlParams["state"][0]
// TODO: Add validation user exists here
auth := getSpotifyAuthHandler()
token, err := auth.Token(userUuid, r)
if err != nil {
fmt.Printf("%+v", err)
return err
}
// Get displayName
client := auth.NewClient(token)
client.AutoRetry = true
spotifyUser, err := client.CurrentUser()
// Lets pull in last 30 minutes
time := time.Now().UTC().Add(-(time.Duration(30) * time.Minute))
err = insertOauthToken(userUuid, "spotify", token.AccessToken, token.RefreshToken, token.Expiry, spotifyUser.DisplayName, time)
if err != nil {
fmt.Printf("%+v", err)
return err
}
return nil
}
func (user *User) updateSpotifyPlaydata() {
dbToken, err := user.getSpotifyTokens()
if err != nil {
fmt.Printf("No spotify token for user: %+v %+v", user.Username, err)
return
}
token := new(oauth2.Token)
token.AccessToken = dbToken.AccessToken
token.RefreshToken = dbToken.RefreshToken
token.Expiry = dbToken.Expiry
token.TokenType = "Bearer"
auth := getSpotifyAuthHandler()
client := auth.NewClient(token)
client.AutoRetry = true
// Only fetch tracks since last sync
opts := spotify.RecentlyPlayedOptions{
AfterEpochMs: dbToken.LastSynced.UnixNano() / int64(time.Millisecond),
}
// We want the next sync timestamp from before we call
// so we don't end up with a few seconds gap
currTime := time.Now()
items, err := client.PlayerRecentlyPlayedOpt(&opts)
if err != nil {
fmt.Println(err)
fmt.Printf("Unable to get recently played tracks for user: %+v", user.Username)
return
}
for _, v := range items {
if !checkIfSpotifyAlreadyScrobbled(user.UUID, v) {
tx, _ := db.Begin()
err := ParseSpotifyInput(user.UUID, v, client, tx)
if err != nil {
fmt.Printf("Failed to insert Spotify scrobble: %+v", err)
tx.Rollback()
break
}
tx.Commit()
fmt.Printf("Updated spotify track: %+v", v.Track.Name)
}
}
// Check if token has changed.. if so, save it to db
currToken, err := client.Token()
err = insertOauthToken(user.UUID, "spotify", currToken.AccessToken, currToken.RefreshToken, currToken.Expiry, dbToken.Username, currTime)
if err != nil {
fmt.Printf("Failed to update spotify token in database")
}
}
func checkIfSpotifyAlreadyScrobbled(userUuid string, data spotify.RecentlyPlayedItem) bool {
return checkIfScrobbleExists(userUuid, data.PlayedAt, "spotify")
}
// ParseSpotifyInput - Transform API data
func ParseSpotifyInput(userUUID string, data spotify.RecentlyPlayedItem, client spotify.Client, tx *sql.Tx) error {
artists := make([]string, 0)
albumartists := make([]string, 0)
// Insert track artists
for _, artist := range data.Track.Artists {
artist, err := insertArtist(artist.Name, "", artist.ID.String(), tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map artist: " + artist.Name)
}
artists = append(artists, artist.Uuid)
}
// Get full track data (album / track info)
fulltrack, err := client.GetTrack(data.Track.ID)
if err != nil {
fmt.Printf("Failed to get full track info from spotify: %+v", data.Track.Name)
return errors.New("Failed to get full track info from spotify: " + data.Track.Name)
}
// Insert album artists
for _, artist := range fulltrack.Album.Artists {
albumartist, err := insertArtist(artist.Name, "", artist.ID.String(), tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map album: " + artist.Name)
}
albumartists = append(albumartists, albumartist.Uuid)
}
// Insert album if not exist
album, err := insertAlbum(fulltrack.Album.Name, "", fulltrack.Album.ID.String(), albumartists, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map album")
}
// Insert track if not exist
length := int(fulltrack.Duration / 60)
track, err := insertTrack(fulltrack.Name, length, "", fulltrack.ID.String(), album.Uuid, artists, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map track")
}
// Insert scrobble if not exist
ip := net.ParseIP("0.0.0.0")
err = insertScrobble(userUUID, track.Uuid, "spotify", data.PlayedAt, ip, tx)
if err != nil {
log.Printf("%+v", err)
return errors.New("Failed to map track")
}
return nil
}

View File

@ -0,0 +1,44 @@
package goscrobble
import (
"database/sql"
"errors"
"time"
)
type OauthToken struct {
UserUUID string `json:"user"`
Service string `json:"service"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Expiry time.Time `json:"expiry"`
Username string `json:"username"`
LastSynced time.Time `json:"last_synced"`
}
func getOauthToken(userUuid string, service string) (OauthToken, error) {
var oauth OauthToken
err := db.QueryRow("SELECT BIN_TO_UUID(`user`, true), `service`, `access_token`, `refresh_token`, `expiry`, `username`, `last_synced` FROM `oauth_tokens` "+
"WHERE `user` = UUID_TO_BIN(?, true) AND `service` = ?",
userUuid, service).Scan(&oauth.UserUUID, &oauth.Service, &oauth.AccessToken, &oauth.RefreshToken, &oauth.Expiry, &oauth.Username, &oauth.LastSynced)
if err == sql.ErrNoRows {
return oauth, errors.New("No token for user")
}
return oauth, nil
}
func insertOauthToken(userUuid string, service string, token string, refresh string, expiry time.Time, username string, lastSynced time.Time) error {
_, err := db.Exec("REPLACE INTO `oauth_tokens` (`user`, `service`, `access_token`, `refresh_token`, `expiry`, `username`, `last_synced`) "+
"VALUES (UUID_TO_BIN(?, true),?,?,?,?,?,?)", userUuid, service, token, refresh, expiry, username, lastSynced)
return err
}
func removeOauthToken(userUuid string, service string) error {
_, err := db.Exec("DELETE FROM `oauth_tokens` WHERE `user` = UUID_TO_BIN(?, true) AND `service` = ?", userUuid, service)
return err
}

View File

@ -1,9 +1,9 @@
package goscrobble
type ProfileResponse struct {
UUID string `json:"uuid"`
Username string `json:"username"`
Scrobbles []ScrobbleRequestItem `json:"scrobbles"`
UUID string `json:"uuid"`
Username string `json:"username"`
Scrobbles []ScrobbleResponseItem `json:"scrobbles"`
}
func getProfile(user User) (ProfileResponse, error) {

View File

@ -3,6 +3,7 @@ package goscrobble
import (
"database/sql"
"errors"
"fmt"
"log"
"net"
"time"
@ -16,28 +17,29 @@ type Scrobble struct {
Track string `json:"track"`
}
type ScrobbleRequest struct {
Meta ScrobbleRequestMeta `json:"meta"`
Items []ScrobbleRequestItem `json:"items"`
type ScrobbleResponse struct {
Meta ScrobbleResponseMeta `json:"meta"`
Items []ScrobbleResponseItem `json:"items"`
}
type ScrobbleRequestMeta struct {
type ScrobbleResponseMeta struct {
Count int `json:"count"`
Total int `json:"total"`
Page int `json:"page"`
}
type ScrobbleRequestItem struct {
type ScrobbleResponseItem struct {
UUID string `json:"uuid"`
Timestamp time.Time `json:"time"`
Artist string `json:"artist"`
Album string `json:"album"`
Track string `json:"track"`
Source string `json:"source"`
}
// insertScrobble - This will return if it exists or create it based on MBID > Name
func insertScrobble(user string, track string, source string, ip net.IP, tx *sql.Tx) error {
err := insertNewScrobble(user, track, source, ip, tx)
func insertScrobble(user string, track string, source string, timestamp time.Time, ip net.IP, tx *sql.Tx) error {
err := insertNewScrobble(user, track, source, timestamp, ip, tx)
if err != nil {
log.Printf("Error inserting scrobble %s %+v", user, err)
return errors.New("Failed to insert scrobble!")
@ -46,8 +48,8 @@ func insertScrobble(user string, track string, source string, ip net.IP, tx *sql
return nil
}
func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleRequest, error) {
scrobbleReq := ScrobbleRequest{}
func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleResponse, error) {
scrobbleReq := ScrobbleResponse{}
var count int
// Yeah this isn't great. But for now.. it works! Cache later
@ -59,7 +61,8 @@ func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleReques
"JOIN artists ON track_artist.artist = artists.uuid "+
"JOIN albums ON track_album.album = albums.uuid "+
"JOIN users ON scrobbles.user = users.uuid "+
"WHERE user = UUID_TO_BIN(?, true)",
"WHERE user = UUID_TO_BIN(?, true) "+
"GROUP BY scrobbles.uuid",
userUuid)
if err != nil {
@ -68,7 +71,7 @@ func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleReques
}
rows, err := db.Query(
"SELECT BIN_TO_UUID(`scrobbles`.`uuid`, true), `scrobbles`.`created_at`, `artists`.`name`, `albums`.`name`,`tracks`.`name` FROM `scrobbles` "+
"SELECT BIN_TO_UUID(`scrobbles`.`uuid`, true), `scrobbles`.`created_at`, `artists`.`name`, `albums`.`name`,`tracks`.`name`, `scrobbles`.`source` FROM `scrobbles` "+
"JOIN tracks ON scrobbles.track = tracks.uuid "+
"JOIN track_artist ON track_artist.track = tracks.uuid "+
"JOIN track_album ON track_album.track = tracks.uuid "+
@ -85,8 +88,8 @@ func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleReques
defer rows.Close()
for rows.Next() {
item := ScrobbleRequestItem{}
err := rows.Scan(&item.UUID, &item.Timestamp, &item.Artist, &item.Album, &item.Track)
item := ScrobbleResponseItem{}
err := rows.Scan(&item.UUID, &item.Timestamp, &item.Artist, &item.Album, &item.Track, &item.Source)
if err != nil {
log.Printf("Failed to fetch scrobbles: %+v", err)
return scrobbleReq, errors.New("Failed to fetch scrobbles")
@ -108,9 +111,21 @@ func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleReques
return scrobbleReq, nil
}
func insertNewScrobble(user string, track string, source string, ip net.IP, tx *sql.Tx) error {
func insertNewScrobble(user string, track string, source string, timestamp time.Time, ip net.IP, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `scrobbles` (`uuid`, `created_at`, `created_ip`, `user`, `track`, `source`) "+
"VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true), ?)", ip, user, track, source)
"VALUES (UUID_TO_BIN(UUID(), true), ?, ?, UUID_TO_BIN(?, true), UUID_TO_BIN(?, true), ?)", timestamp, ip, user, track, source)
return err
}
func checkIfScrobbleExists(userUuid string, timestamp time.Time, source string) bool {
count, err := getDbCount("SELECT COUNT(*) FROM `scrobbles` WHERE `user` = UUID_TO_BIN(?, true) AND `created_at` = ? AND `source` = ?",
userUuid, timestamp, source)
if err != nil {
fmt.Printf("Error fetching scrobble exists count: %+v", err)
return true
}
return count != 0
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/gorilla/mux"
@ -32,20 +33,22 @@ func HandleRequests(port string) {
v1 := r.PathPrefix("/api/v1").Subrouter()
// Static Token for /ingress
v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)).Methods("POST")
v1.HandleFunc("/ingress/multiscrobbler", tokenMiddleware(handleIngress)).Methods("POST")
v1.HandleFunc("/ingress/jellyfin", limitMiddleware(tokenMiddleware(handleIngress), lightLimiter)).Methods("POST")
v1.HandleFunc("/ingress/multiscrobbler", limitMiddleware(tokenMiddleware(handleIngress), lightLimiter)).Methods("POST")
// JWT Auth - PWN PROFILE ONLY.
v1.HandleFunc("/user", jwtMiddleware(fetchUser)).Methods("GET")
// JWT Auth - Own profile only (Uses uuid in JWT)
v1.HandleFunc("/user", limitMiddleware(jwtMiddleware(fetchUser), lightLimiter)).Methods("GET")
// v1.HandleFunc("/user", jwtMiddleware(fetchScrobbleResponse)).Methods("PATCH")
v1.HandleFunc("/user/spotify", limitMiddleware(jwtMiddleware(getSpotifyClientID), lightLimiter)).Methods("GET")
v1.HandleFunc("/user/spotify", limitMiddleware(jwtMiddleware(deleteSpotifyLink), lightLimiter)).Methods("DELETE")
v1.HandleFunc("/user/{uuid}/scrobbles", jwtMiddleware(fetchScrobbleResponse)).Methods("GET")
// Config auth
v1.HandleFunc("/config", adminMiddleware(fetchConfig)).Methods("GET")
v1.HandleFunc("/config", adminMiddleware(postConfig)).Methods("POST")
v1.HandleFunc("/config", limitMiddleware(adminMiddleware(fetchConfig), standardLimiter)).Methods("GET")
v1.HandleFunc("/config", limitMiddleware(adminMiddleware(postConfig), standardLimiter)).Methods("POST")
// No Auth
v1.HandleFunc("/stats", handleStats).Methods("GET")
v1.HandleFunc("/stats", limitMiddleware(handleStats, lightLimiter)).Methods("GET")
v1.HandleFunc("/profile/{username}", limitMiddleware(fetchProfile, lightLimiter)).Methods("GET")
v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST")
@ -53,6 +56,9 @@ func HandleRequests(port string) {
v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST")
v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST")
// Redirect from Spotify Oauth
v1.HandleFunc("/link/spotify", limitMiddleware(postSpotifyReponse, lightLimiter))
// This just prevents it serving frontend stuff over /api
r.PathPrefix("/api")
@ -70,6 +76,8 @@ func HandleRequests(port string) {
// Serve it up!
fmt.Printf("Goscrobble listening on port %s", port)
fmt.Println("")
log.Fatal(http.ListenAndServe(":"+port, handler))
}
@ -225,6 +233,7 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
case "jellyfin":
err := ParseJellyfinInput(userUuid, bodyJson, ip, tx)
if err != nil {
fmt.Printf("Err? %+v", err)
tx.Rollback()
throwOkError(w, err.Error())
return
@ -269,6 +278,13 @@ func fetchUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser s
throwOkError(w, "Failed to fetch user information")
return
}
//
oauth, err := getOauthToken(user.UUID, "spotify")
if err == nil {
user.SpotifyUsername = oauth.Username
}
json, _ := json.Marshal(&user)
w.WriteHeader(http.StatusOK)
@ -351,3 +367,44 @@ func fetchProfile(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(json)
}
// postSpotifyResponse - Oauth Response from Spotify
func postSpotifyReponse(w http.ResponseWriter, r *http.Request) {
err := connectSpotifyResponse(r)
if err != nil {
throwOkError(w, "Failed to connect to spotify")
return
}
http.Redirect(w, r, os.Getenv("GOSCROBBLE_DOMAIN")+"/user", 302)
}
// getSpotifyClientID - Returns public spotify APP ID
func getSpotifyClientID(w http.ResponseWriter, r *http.Request, u string, v string) {
key, err := getConfigValue("SPOTIFY_APP_ID")
if err != nil {
throwOkError(w, "Failed to get Spotify ID")
return
}
response := LoginResponse{
Token: key,
}
resp, _ := json.Marshal(&response)
w.WriteHeader(http.StatusOK)
w.Write(resp)
}
// deleteSpotifyLink - Unlinks spotify account
func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, u string, v string) {
err := removeOauthToken(u, "spotify")
if err != nil {
fmt.Println(err)
throwOkError(w, "Failed to unlink spotify account")
return
}
throwOkMessage(w, "Spotify account successfully unlinked")
}

View File

@ -5,10 +5,32 @@ import (
"time"
)
func ClearTokenTimer() {
var endTicker chan bool
func StartBackgroundWorkers() {
endTicker := make(chan bool)
hourTicker := time.NewTicker(time.Hour)
minuteTicker := time.NewTicker(time.Duration(1) * time.Minute)
go func() {
for now := range time.Tick(time.Second) {
fmt.Println(now)
for {
select {
case <-endTicker:
fmt.Println("Stopping background workers")
return
case <-hourTicker.C:
// Clear old password reset tokens
clearOldResetTokens()
case <-minuteTicker.C:
// Update playdata from spotify
updateSpotifyData()
}
}
}()
}
func EndBackgroundWorkers() {
endTicker <- true
}

View File

@ -9,44 +9,50 @@ import (
type Track struct {
Uuid string `json:"uuid"`
Name string `json:"name"`
Length int `json:"length"`
Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"`
}
// insertTrack - This will return if it exists or create it based on MBID > Name
func insertTrack(name string, mbid string, album string, artists []string, tx *sql.Tx) (Track, error) {
func insertTrack(name string, legnth int, mbid string, spotifyId string, album string, artists []string, tx *sql.Tx) (Track, error) {
track := Track{}
// Try locate our track
if mbid != "" {
track = fetchTrack("mbid", mbid, tx)
if track.Uuid == "" {
err := insertNewTrack(name, mbid, tx)
if err != nil {
log.Printf("Error inserting track via MBID %s %+v", name, err)
return track, errors.New("Failed to insert track")
}
} else if spotifyId != "" {
track = fetchTrack("spotify_id", spotifyId, tx)
}
track = fetchTrack("mbid", mbid, tx)
err = track.linkTrack(album, artists, tx)
if err != nil {
return track, err
}
}
} else {
// If it didn't match above, lookup by name
if track.Uuid == "" {
track = fetchTrack("name", name, tx)
if track.Uuid == "" {
err := insertNewTrack(name, mbid, tx)
if err != nil {
log.Printf("Error inserting track via Name %s %+v", name, err)
return track, errors.New("Failed to insert track")
}
}
// If we can't find it. Lets add it!
if track.Uuid == "" {
err := insertNewTrack(name, legnth, mbid, spotifyId, tx)
if err != nil {
return track, errors.New("Failed to insert track")
}
// Fetch the recently inserted track to get the UUID
if mbid != "" {
track = fetchTrack("mbid", mbid, tx)
} else if spotifyId != "" {
track = fetchTrack("spotify_id", spotifyId, tx)
}
if track.Uuid == "" {
track = fetchTrack("name", name, tx)
err = track.linkTrack(album, artists, tx)
if err != nil {
return track, err
}
}
err = track.linkTrack(album, artists, tx)
if err != nil {
return track, errors.New("Unable to link tracks!")
}
}
@ -72,9 +78,9 @@ func fetchTrack(col string, val string, tx *sql.Tx) Track {
return track
}
func insertNewTrack(name string, mbid string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `tracks` (`uuid`, `name`, `mbid`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?)", name, mbid)
func insertNewTrack(name string, length int, mbid string, spotifyId string, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `tracks` (`uuid`, `name`, `length`, `mbid`, `spotify_id`) "+
"VALUES (UUID_TO_BIN(UUID(), true),?,?,?,?)", name, length, mbid, spotifyId)
return err
}
@ -110,3 +116,9 @@ func (track Track) linkTrackToArtists(artists []string, tx *sql.Tx) error {
return nil
}
func updateTrack(uuid string, col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `tracks` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid)
return err
}

View File

@ -28,17 +28,20 @@ type User struct {
Verified bool `json:"verified"`
Active bool `json:"active"`
Admin bool `json:"admin"`
Timezone string `json:"timezone"`
}
type UserResponse struct {
UUID string `json:"uuid"`
CreatedAt time.Time `json:"created_at"`
CreatedIp net.IP `json:"created_ip"`
ModifiedAt time.Time `json:"modified_at"`
ModifiedIP net.IP `jsos:"modified_ip"`
Username string `json:"username"`
Email string `json:"email"`
Verified bool `json:"verified"`
UUID string `json:"uuid"`
CreatedAt time.Time `json:"created_at"`
CreatedIp net.IP `json:"created_ip"`
ModifiedAt time.Time `json:"modified_at"`
ModifiedIP net.IP `jsos:"modified_ip"`
Username string `json:"username"`
Email string `json:"email"`
Verified bool `json:"verified"`
SpotifyUsername string `json:"spotify_username"`
Timezone string `json:"timezone"`
}
// RegisterRequest - Incoming JSON
@ -208,8 +211,8 @@ func userAlreadyExists(req *RegisterRequest) bool {
func getUser(uuid string) (User, error) {
var user User
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` WHERE `uuid` = UUID_TO_BIN(?, true) AND `active` = 1",
uuid).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin)
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin`, `timezone` FROM `users` WHERE `uuid` = UUID_TO_BIN(?, true) AND `active` = 1",
uuid).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Timezone)
if err == sql.ErrNoRows {
return user, errors.New("Invalid JWT Token")
@ -220,8 +223,8 @@ func getUser(uuid string) (User, error) {
func getUserByUsername(username string) (User, error) {
var user User
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` WHERE `username` = ? AND `active` = 1",
username).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin)
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin`, `timezone` FROM `users` WHERE `username` = ? AND `active` = 1",
username).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Timezone)
if err == sql.ErrNoRows {
return user, errors.New("Invalid Username")
@ -232,8 +235,8 @@ func getUserByUsername(username string) (User, error) {
func getUserByEmail(email string) (User, error) {
var user User
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` WHERE `email` = ? AND `active` = 1",
email).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin)
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin`, `timezone` FROM `users` WHERE `email` = ? AND `active` = 1",
email).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Timezone)
if err == sql.ErrNoRows {
return user, errors.New("Invalid Email")
@ -244,11 +247,9 @@ func getUserByEmail(email string) (User, error) {
func getUserByResetToken(token string) (User, error) {
var user User
err := db.QueryRow("SELECT BIN_TO_UUID(`users`.`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` "+
err := db.QueryRow("SELECT BIN_TO_UUID(`users`.`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin`, timezone FROM `users` "+
"JOIN `resettoken` ON `resettoken`.`user` = `users`.`uuid` WHERE `resettoken`.`token` = ? AND `active` = 1",
token).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin)
fmt.Println(err)
token).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Timezone)
if err == sql.ErrNoRows {
return user, errors.New("Invalid Token")
@ -256,11 +257,12 @@ func getUserByResetToken(token string) (User, error) {
return user, nil
}
func (user *User) sendResetEmail(ip net.IP) error {
token := generateToken(16)
// 24 hours
exp := time.Now().AddDate(0, 0, 1)
// 1 hour validation
exp := time.Now().Add(time.Hour * time.Duration(1))
err := user.saveResetToken(token, exp)
if err != nil {
@ -268,7 +270,9 @@ func (user *User) sendResetEmail(ip net.IP) error {
}
content := fmt.Sprintf(
"Someone at %s has request a password reset for %s. Click the following link to reset your password: %s/reset/%s",
"Someone at %s has request a password reset for %s.\n"+
"Click the following link to reset your password: %s/reset/%s\n\n"+
"This is link is valid for 1 hour",
ip, user.Username, os.Getenv("GOSCROBBLE_DOMAIN"), token)
return sendEmail(user.Username, user.Email, "GoScrobble - Password Reset", content)
@ -316,3 +320,39 @@ func (user *User) updatePassword(newPassword string, ip net.IP) error {
return nil
}
func (user *User) getSpotifyTokens() (OauthToken, error) {
return getOauthToken(user.UUID, "spotify")
}
func getAllSpotifyUsers() ([]User, error) {
users := make([]User, 0)
rows, err := db.Query("SELECT BIN_TO_UUID(`users`.`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `users`.`username`, `email`, `password`, `verified`, `admin`, `timezone` FROM `users` " +
"JOIN `oauth_tokens` ON `oauth_tokens`.`user` = `users`.`uuid` AND `oauth_tokens`.`service` = 'spotify' WHERE `users`.`active` = 1")
if err != nil {
log.Printf("Failed to fetch spotify users: %+v", err)
return users, errors.New("Failed to fetch configs")
}
defer rows.Close()
for rows.Next() {
var user User
err := rows.Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin, &user.Timezone)
if err != nil {
log.Printf("Failed to fetch spotify user: %+v", err)
return users, errors.New("Failed to fetch users")
}
users = append(users, user)
}
err = rows.Err()
if err != nil {
log.Printf("Failed to fetch spotify users: %+v", err)
return users, errors.New("Failed to fetch users")
}
return users, nil
}

View File

@ -111,6 +111,36 @@ func Inet6_Aton(ip net.IP) string {
return ipHex
}
// calcPageOffsetString - Used to SQL paging
func calcPageOffsetString(page int, offset int) string {
return fmt.Sprintf("%d", page*offset)
}
// timestampToSeconds - Converts HH:MM:SS to (int)seconds
func timestampToSeconds(timestamp string) int {
var h, m, s int
n, err := fmt.Sscanf(timestamp, "%d:%d:%d", &h, &m, &s)
if err != nil || n != 3 {
return 0
}
return h*3600 + m*60 + s
}
func filterSlice(s []string) []string {
m := make(map[string]bool)
for _, item := range s {
if item != "" {
if _, ok := m[strings.TrimSpace(item)]; !ok {
m[strings.TrimSpace(item)] = true
}
}
}
var result []string
for item, _ := range m {
result = append(result, item)
}
fmt.Printf("RESTULS: %+v", result)
return result
}

View File

@ -0,0 +1 @@
ALTER TABLE `tracks` DROP COLUMN `length`;

View File

@ -0,0 +1 @@
ALTER TABLE `tracks` ADD COLUMN `length` INT NOT NULL DEFAULT 0;

View File

@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`active` TINYINT(1) NOT NULL DEFAULT 1,
`admin` TINYINT(1) NOT NULL DEFAULT 0,
`private` TINYINT(1) NOT NULL DEFAULT 0,
`timezone` VARCHAR(100) NOT NULL DEFAULT 'Etc/UTC',
KEY `usernameLookup` (`username`, `active`),
KEY `emailLookup` (`email`, `active`)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `oauth_tokens`;

10
migrations/8_oauth.up.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS `oauth_tokens` (
`user` BINARY(16),
`service` VARCHAR(64) NOT NULL,
`access_token` VARCHAR(255) NULL DEFAULT '',
`refresh_token` VARCHAR(255) NULL DEFAULT '',
`expiry` DATETIME NOT NULL,
`username` VARCHAR(100) NULL DEFAULT '',
`last_synced` DATETIME NOT NULL DEFAULT NOW(),
PRIMARY KEY `userService` (`user`, `service`)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -0,0 +1,8 @@
START TRANSACTION;
ALTER TABLE `tracks` DROP COLUMN `spotify_id`;
ALTER TABLE `users` DROP COLUMN `spotify_id`;
ALTER TABLE `albums` DROP COLUMN `spotify_id`;
ALTER TABLE `artists` DROP COLUMN `spotify_id`;
COMMIT;

View File

@ -0,0 +1,13 @@
START TRANSACTION;
ALTER TABLE `users` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT '';
ALTER TABLE `albums` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT '';
ALTER TABLE `artists` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT '';
ALTER TABLE `tracks` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT '';
ALTER TABLE `users` ADD INDEX `spotifyLookup` (`spotify_id`);
ALTER TABLE `albums` ADD INDEX `spotifyLookup` (`spotify_id`);
ALTER TABLE `artists` ADD INDEX `spotifyLookup` (`spotify_id`);
ALTER TABLE `tracks` ADD INDEX `spotifyLookup` (`spotify_id`);
COMMIT;

View File

@ -7,12 +7,38 @@ function getHeaders() {
const user = JSON.parse(localStorage.getItem('user'));
if (user && user.jwt) {
return { Authorization: 'Bearer ' + user.jwt };
return { Authorization: 'Bearer ' + user.jwt, changeOrigin: true };
} else {
return {};
}
}
function getUserUuid() {
// Todo: move this to use Context values instead.
const user = JSON.parse(localStorage.getItem('user'));
if (user && user.uuid) {
return user.uuid
} else {
return '';
}
}
function handleErrorResp(error) {
if (error.response) {
if (error.response.status === 401) {
toast.error('Unauthorized')
} else if (error.response.status === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('An unknown error has occurred');
}
} else {
toast.error('Failed to connect to API');
}
return {};
}
export const PostLogin = (formValues) => {
return axios.post(process.env.REACT_APP_API_URL + "login", formValues)
.then((response) => {
@ -34,7 +60,7 @@ export const PostLogin = (formValues) => {
}
}).catch((error) => {
if (error.response === 401) {
return {};
toast.error('Unauthorized')
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
@ -57,14 +83,8 @@ export const PostRegister = (formValues) => {
return Promise.reject();
}
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return Promise.resolve();
handleErrorResp(error)
return Promise.resolve();
});
};
@ -81,15 +101,9 @@ export const PostResetPassword = (formValues) => {
return Promise.reject();
}
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return Promise.resolve();
});
handleErrorResp(error)
return Promise.resolve();
});
};
export const sendPasswordReset = (values) => {
@ -97,14 +111,7 @@ export const sendPasswordReset = (values) => {
(data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
@ -113,14 +120,7 @@ export const getStats = () => {
(data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
@ -129,14 +129,7 @@ export const getRecentScrobbles = (id) => {
.then((data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
@ -145,14 +138,7 @@ export const getConfigs = () => {
.then((data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
@ -171,15 +157,8 @@ export const postConfigs = (values, toggle) => {
toast.error(data.data.error);
}
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
});
return handleErrorResp(error)
});
};
export const getProfile = (userName) => {
@ -187,14 +166,7 @@ export const getProfile = (userName) => {
.then((data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
@ -203,34 +175,56 @@ export const getUser = () => {
.then((data) => {
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
export const validateResetPassword = (tokenStr) => {
return axios.post(process.env.REACT_APP_API_URL + "resetpassword", { token: tokenStr })
return axios.get(process.env.REACT_APP_API_URL + "user/", { headers: getHeaders() })
.then((data) => {
if (data.error) {
toast.error(data.error);
return {valid: false}
}
return data.data;
}).catch((error) => {
if (error.response === 401) {
return {};
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return {};
return handleErrorResp(error)
});
};
export const getSpotifyClientId = () => {
return axios.get(process.env.REACT_APP_API_URL + "user/spotify", { headers: getHeaders() })
.then((data) => {
return data.data
}).catch((error) => {
return handleErrorResp(error)
});
}
export const spotifyConnectionRequest = () => {
return getSpotifyClientId().then((resp) => {
var scopes = 'user-read-recently-played user-read-currently-playing';
// Local, lets forward it to API
let redirectUri = window.location.origin.toString()+ "/api/v1/link/spotify";
// Stupid dev
if (window.location.origin.toString() === "http://localhost:3000") {
redirectUri = "http://localhost:42069/api/v1/link/spotify"
}
window.location = 'https://accounts.spotify.com/authorize' +
'?response_type=code' +
'&client_id=' + resp.token +
'&scope=' + encodeURIComponent(scopes) +
'&redirect_uri=' + encodeURIComponent(redirectUri) +
'&state=' + getUserUuid();
})
};
export const spotifyDisonnectionRequest = () => {
return axios.delete(process.env.REACT_APP_API_URL + "user/spotify", { headers: getHeaders() })
.then((data) => {
toast.success(data.data.message);
return true
}).catch((error) => {
return handleErrorResp(error)
});
}

View File

@ -10,6 +10,7 @@ const ScrobbleTable = (props) => {
<td>Track</td>
<td>Artist</td>
<td>Album</td>
<td>Source</td>
</tr>
</thead>
<tbody>
@ -21,6 +22,7 @@ const ScrobbleTable = (props) => {
<td>{element.track}</td>
<td>{element.artist}</td>
<td>{element.album}</td>
<td>{element.source}</td>
</tr>;
})
}

View File

@ -22,8 +22,8 @@ const Admin = () => {
if (data.configs) {
setConfigs(data.configs);
setToggle(data.configs.REGISTRATION_ENABLED === "1")
setLoading(false);
}
setLoading(false);
})
}, [])
@ -31,6 +31,14 @@ const Admin = () => {
setToggle(!toggle);
};
if (!user) {
history.push("/login")
}
if (user && !user.admin) {
history.push("/Dashboard")
}
if (loading) {
return (
<div className="pageWrapper">
@ -39,9 +47,7 @@ const Admin = () => {
)
}
if (!user || !user.admin) {
history.push("/login")
}
return (
<div className="pageWrapper">

View File

@ -8,7 +8,7 @@ import ScrobbleTable from "../Components/ScrobbleTable";
import AuthContext from '../Contexts/AuthContext';
const Dashboard = () => {
// const history = useHistory();
const history = useHistory();
let { user } = useContext(AuthContext);
let [loading, setLoading] = useState(true);
let [dashboardData, setDashboardData] = useState({});
@ -24,6 +24,10 @@ const Dashboard = () => {
})
}, [user])
if (!user) {
history.push("/login")
}
if (loading) {
return (
<div className="pageWrapper">
@ -37,10 +41,12 @@ const Dashboard = () => {
<h1>
{user.username}'s Dashboard!
</h1>
<div className="dashboardBody">
{loading
? <ScaleLoader color="#6AD7E5" size={60} />
: <ScrobbleTable data={dashboardData.items} />
}
</div>
</div>
);
}

View File

@ -63,7 +63,7 @@ const Login = () => {
className="loginButton"
onClick={redirectReset}
disabled={loading}
>{loading ? <ScaleLoader color="#FFF" size={35} /> : "Reset Password"}</Button>
>Reset Password</Button>
</Form>
</Formik>
</div>

View File

@ -5,6 +5,8 @@ import { useHistory } from "react-router";
import AuthContext from '../Contexts/AuthContext';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getUser } from '../Api/index'
import { Button } from 'reactstrap';
import { spotifyConnectionRequest, spotifyDisonnectionRequest } from '../Api/index'
const User = () => {
const history = useHistory();
@ -12,6 +14,7 @@ const User = () => {
const [loading, setLoading] = useState(true);
const [userdata, setUserdata] = useState({});
useEffect(() => {
if (!user) {
return
@ -24,6 +27,10 @@ const User = () => {
})
}, [user])
if (!user) {
history.push("/login")
}
if (loading) {
return (
<div className="pageWrapper">
@ -32,10 +39,6 @@ const User = () => {
)
}
if (!user) {
history.push("/login")
}
return (
<div className="pageWrapper">
<h1>
@ -44,7 +47,25 @@ const User = () => {
<p className="userBody">
Created At: {userdata.created_at}<br/>
Email: {userdata.email}<br/>
Verified: {userdata.verified ? '✓' : '✖'}
Verified: {userdata.verified ? '✓' : '✖'}<br/>
{userdata.spotify_username
? <div>Spotify Account: {userdata.spotify_username}<br/><br/>
<Button
color="secondary"
type="button"
className="loginButton"
onClick={spotifyDisonnectionRequest}
>Disconnect Spotify</Button></div>
: <div>
<br/>
<Button
color="primary"
type="button"
className="loginButton"
onClick={spotifyConnectionRequest}
>Connect To Spotify</Button>
</div>
}
</p>
</div>
);