- 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

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