From 9866dea36bfe58517e55a2550ef8907e9d84a755 Mon Sep 17 00:00:00 2001 From: Daniel Mason Date: Fri, 2 Apr 2021 22:24:00 +1300 Subject: [PATCH] 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) - Fixed timezone issues --- .env.example | 2 - cmd/go-scrobble/main.go | 10 +- docs/changelog.md | 7 + docs/config.md | 5 +- go.mod | 2 + go.sum | 9 ++ internal/goscrobble/album.go | 68 ++++---- internal/goscrobble/artist.go | 55 ++++--- internal/goscrobble/config.go | 15 ++ internal/goscrobble/db.go | 8 +- internal/goscrobble/ingress_jellyfin.go | 24 +-- internal/goscrobble/ingress_spotify.go | 202 ++++++++++++++++++++++++ internal/goscrobble/oauth_tokens.go | 44 ++++++ internal/goscrobble/profile.go | 6 +- internal/goscrobble/scrobble.go | 45 ++++-- internal/goscrobble/server.go | 71 ++++++++- internal/goscrobble/timers.go | 28 +++- internal/goscrobble/track.go | 66 ++++---- internal/goscrobble/user.go | 82 +++++++--- internal/goscrobble/utils.go | 30 ++++ migrations/10_tracklength.down.sql | 1 + migrations/10_tracklength.up.sql | 1 + migrations/2_users.up.sql | 1 + migrations/8_oauth.down.sql | 1 + migrations/8_oauth.up.sql | 10 ++ migrations/9_spotify.down.sql | 8 + migrations/9_spotify.up.sql | 13 ++ web/src/Api/index.js | 172 ++++++++++---------- web/src/Components/ScrobbleTable.js | 2 + web/src/Pages/Admin.js | 14 +- web/src/Pages/Dashboard.js | 8 +- web/src/Pages/Login.js | 2 +- web/src/Pages/User.js | 31 +++- 33 files changed, 795 insertions(+), 248 deletions(-) create mode 100644 internal/goscrobble/ingress_spotify.go create mode 100644 internal/goscrobble/oauth_tokens.go create mode 100644 migrations/10_tracklength.down.sql create mode 100644 migrations/10_tracklength.up.sql create mode 100644 migrations/8_oauth.down.sql create mode 100644 migrations/8_oauth.up.sql create mode 100644 migrations/9_spotify.down.sql create mode 100644 migrations/9_spotify.up.sql diff --git a/.env.example b/.env.example index 9c662394..88f43140 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,6 @@ REDIS_DB= REDIS_PREFIX="gs:" REDIS_AUTH="" -TIMEZONE=Etc/UTC - JWT_SECRET= JWT_EXPIRY=86400 diff --git a/cmd/go-scrobble/main.go b/cmd/go-scrobble/main.go index 381c506c..18c56e8c 100644 --- a/cmd/go-scrobble/main.go +++ b/cmd/go-scrobble/main.go @@ -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) diff --git a/docs/changelog.md b/docs/changelog.md index 7fa35a13..02e77cdd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/docs/config.md b/docs/config.md index f7b42548..2d4f67d9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/go.mod b/go.mod index 7ed44571..903723b7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e97573db..430b4d2b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/goscrobble/album.go b/internal/goscrobble/album.go index e77d3b80..c4fa70e9 100644 --- a/internal/goscrobble/album.go +++ b/internal/goscrobble/album.go @@ -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 +} diff --git a/internal/goscrobble/artist.go b/internal/goscrobble/artist.go index 6058311d..39109346 100644 --- a/internal/goscrobble/artist.go +++ b/internal/goscrobble/artist.go @@ -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 } diff --git a/internal/goscrobble/config.go b/internal/goscrobble/config.go index 951f01a7..a62f0068 100644 --- a/internal/goscrobble/config.go +++ b/internal/goscrobble/config.go @@ -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 +} diff --git a/internal/goscrobble/db.go b/internal/goscrobble/db.go index f413800a..6481f64a 100644 --- a/internal/goscrobble/db.go +++ b/internal/goscrobble/db.go @@ -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) } diff --git a/internal/goscrobble/ingress_jellyfin.go b/internal/goscrobble/ingress_jellyfin.go index 002c035d..50711080 100644 --- a/internal/goscrobble/ingress_jellyfin.go +++ b/internal/goscrobble/ingress_jellyfin.go @@ -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 } diff --git a/internal/goscrobble/ingress_spotify.go b/internal/goscrobble/ingress_spotify.go new file mode 100644 index 00000000..4a219d89 --- /dev/null +++ b/internal/goscrobble/ingress_spotify.go @@ -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 +} diff --git a/internal/goscrobble/oauth_tokens.go b/internal/goscrobble/oauth_tokens.go new file mode 100644 index 00000000..ed2cf016 --- /dev/null +++ b/internal/goscrobble/oauth_tokens.go @@ -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 +} diff --git a/internal/goscrobble/profile.go b/internal/goscrobble/profile.go index d8469b18..3cfc43f6 100644 --- a/internal/goscrobble/profile.go +++ b/internal/goscrobble/profile.go @@ -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) { diff --git a/internal/goscrobble/scrobble.go b/internal/goscrobble/scrobble.go index 37b1b920..42ce51ec 100644 --- a/internal/goscrobble/scrobble.go +++ b/internal/goscrobble/scrobble.go @@ -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 +} diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 791a0439..b6bd2e92 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -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") +} diff --git a/internal/goscrobble/timers.go b/internal/goscrobble/timers.go index 59f05e19..dd242776 100644 --- a/internal/goscrobble/timers.go +++ b/internal/goscrobble/timers.go @@ -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 +} diff --git a/internal/goscrobble/track.go b/internal/goscrobble/track.go index a5aa3340..a64809cd 100644 --- a/internal/goscrobble/track.go +++ b/internal/goscrobble/track.go @@ -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 +} diff --git a/internal/goscrobble/user.go b/internal/goscrobble/user.go index 4fa4cf63..43781ca0 100644 --- a/internal/goscrobble/user.go +++ b/internal/goscrobble/user.go @@ -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 +} diff --git a/internal/goscrobble/utils.go b/internal/goscrobble/utils.go index 14b6651d..b379e0c5 100644 --- a/internal/goscrobble/utils.go +++ b/internal/goscrobble/utils.go @@ -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 +} diff --git a/migrations/10_tracklength.down.sql b/migrations/10_tracklength.down.sql new file mode 100644 index 00000000..a70f3dcd --- /dev/null +++ b/migrations/10_tracklength.down.sql @@ -0,0 +1 @@ +ALTER TABLE `tracks` DROP COLUMN `length`; \ No newline at end of file diff --git a/migrations/10_tracklength.up.sql b/migrations/10_tracklength.up.sql new file mode 100644 index 00000000..27463fc9 --- /dev/null +++ b/migrations/10_tracklength.up.sql @@ -0,0 +1 @@ +ALTER TABLE `tracks` ADD COLUMN `length` INT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/migrations/2_users.up.sql b/migrations/2_users.up.sql index 0d85c2a0..2747fd30 100644 --- a/migrations/2_users.up.sql +++ b/migrations/2_users.up.sql @@ -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; \ No newline at end of file diff --git a/migrations/8_oauth.down.sql b/migrations/8_oauth.down.sql new file mode 100644 index 00000000..16638fe1 --- /dev/null +++ b/migrations/8_oauth.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `oauth_tokens`; \ No newline at end of file diff --git a/migrations/8_oauth.up.sql b/migrations/8_oauth.up.sql new file mode 100644 index 00000000..4a4a15a3 --- /dev/null +++ b/migrations/8_oauth.up.sql @@ -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; diff --git a/migrations/9_spotify.down.sql b/migrations/9_spotify.down.sql new file mode 100644 index 00000000..90d19757 --- /dev/null +++ b/migrations/9_spotify.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/9_spotify.up.sql b/migrations/9_spotify.up.sql new file mode 100644 index 00000000..6c828d98 --- /dev/null +++ b/migrations/9_spotify.up.sql @@ -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; \ No newline at end of file diff --git a/web/src/Api/index.js b/web/src/Api/index.js index a80f8e0c..d61b0d15 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -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) + }); +} + diff --git a/web/src/Components/ScrobbleTable.js b/web/src/Components/ScrobbleTable.js index 3d62de58..3a97a800 100644 --- a/web/src/Components/ScrobbleTable.js +++ b/web/src/Components/ScrobbleTable.js @@ -10,6 +10,7 @@ const ScrobbleTable = (props) => { Track Artist Album + Source @@ -21,6 +22,7 @@ const ScrobbleTable = (props) => { {element.track} {element.artist} {element.album} + {element.source} ; }) } diff --git a/web/src/Pages/Admin.js b/web/src/Pages/Admin.js index ffdb546b..d52035b5 100644 --- a/web/src/Pages/Admin.js +++ b/web/src/Pages/Admin.js @@ -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 (
@@ -39,9 +47,7 @@ const Admin = () => { ) } - if (!user || !user.admin) { - history.push("/login") - } + return (
diff --git a/web/src/Pages/Dashboard.js b/web/src/Pages/Dashboard.js index f2cb9ac8..1cfcde59 100644 --- a/web/src/Pages/Dashboard.js +++ b/web/src/Pages/Dashboard.js @@ -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 (
@@ -37,10 +41,12 @@ const Dashboard = () => {

{user.username}'s Dashboard!

+
{loading ? : } +
); } diff --git a/web/src/Pages/Login.js b/web/src/Pages/Login.js index 452ad1d6..61e271c7 100644 --- a/web/src/Pages/Login.js +++ b/web/src/Pages/Login.js @@ -63,7 +63,7 @@ const Login = () => { className="loginButton" onClick={redirectReset} disabled={loading} - >{loading ? : "Reset Password"} + >Reset Password
diff --git a/web/src/Pages/User.js b/web/src/Pages/User.js index 4e8bf2b4..16465512 100644 --- a/web/src/Pages/User.js +++ b/web/src/Pages/User.js @@ -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 (
@@ -32,10 +39,6 @@ const User = () => { ) } - if (!user) { - history.push("/login") - } - return (

@@ -44,7 +47,25 @@ const User = () => {

Created At: {userdata.created_at}
Email: {userdata.email}
- Verified: {userdata.verified ? '✓' : '✖'} + Verified: {userdata.verified ? '✓' : '✖'}
+ {userdata.spotify_username + ?

Spotify Account: {userdata.spotify_username}

+
+ :
+
+ +
+ }

);