diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index faf455d2..71fd887e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: - bundle variables: - VERSION: 0.0.25 + VERSION: 0.0.26 build-go: image: golang:1.16.2 diff --git a/docs/changelog.md b/docs/changelog.md index a689004a..9d5b2ab5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,9 @@ +# 0.0.26 +- Make email required +- Add basic navidrome/subsonic connection +- Tidy up request/response structure in backend +- Tidy Settings page + # 0.0.25 - Images now pull from spotify if setup! - Show top artists/album diff --git a/internal/goscrobble/ingress_multiscrobbler.go b/internal/goscrobble/ingress_multiscrobbler.go index 9fe205e1..eb71e08a 100644 --- a/internal/goscrobble/ingress_multiscrobbler.go +++ b/internal/goscrobble/ingress_multiscrobbler.go @@ -20,8 +20,7 @@ type MultiScrobblerRequest struct { // ParseMultiScrobblerInput - Transform API data func ParseMultiScrobblerInput(userUUID string, data MultiScrobblerRequest, ip net.IP, tx *sql.Tx) error { // Cache key - json := fmt.Sprintf("%s:%s:%s:%s", data.PlayedAt, data.Track, data.Album, userUUID) - fmt.Printf(json) + json := fmt.Sprintf("%s:%s:%s", data.PlayedAt, data.Track, userUUID) redisKey := getMd5(json) if getRedisKeyExists(redisKey) { return nil @@ -62,7 +61,7 @@ func ParseMultiScrobblerInput(userUUID string, data MultiScrobblerRequest, ip ne return errors.New("Failed to map track") } - ttl := time.Duration(30) * time.Minute + ttl := time.Duration(24) * time.Hour setRedisValTtl(redisKey, "1", ttl) return nil diff --git a/internal/goscrobble/ingress_navidrome.go b/internal/goscrobble/ingress_navidrome.go new file mode 100644 index 00000000..927f4135 --- /dev/null +++ b/internal/goscrobble/ingress_navidrome.go @@ -0,0 +1,60 @@ +package goscrobble + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" +) + +type NavidromeResponse struct { + Response struct { + Status string `json:"status"` + Version string `json:"version"` + Type string `json:"type"` + Serverversion string `json:"serverVersion"` + } `json:"subsonic-response"` +} + +// updateSpotifyData - Pull data for all users +func updateNavidromeData() { + // Get all active users with a spotify token + users, err := getAllNavidromeUsers() + if err != nil { + fmt.Printf("Failed to fetch navidrome users") + return + } + + for _, user := range users { + user.updateNavidromePlaydata() + } +} + +func (user *User) updateNavidromePlaydata() { + _, err := user.getNavidromeTokens() + if err != nil { + fmt.Printf("No Navidrome token for user: %+v %+v", user.Username, err) + return + } +} + +func validateNavidromeConnection(url string, username string, hash string, salt string) error { + fmt.Printf("url:%s, username:%s, hash:%s, salt:%s", url, username, hash, salt) + resp, err := http.Get(url + "/rest/ping.view?u=" + username + "&t=" + hash + "&s=" + salt + "&c=GoScrobble&v=1.16.1&f=json") + if err != nil { + return err + } + + response := NavidromeResponse{} + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&response) + if err != nil { + return err + } + + if response.Response.Status == "ok" { + return nil + } + + return errors.New("Failed to validate") +} diff --git a/internal/goscrobble/ingress_spotify.go b/internal/goscrobble/ingress_spotify.go index 9a89657d..ec190635 100644 --- a/internal/goscrobble/ingress_spotify.go +++ b/internal/goscrobble/ingress_spotify.go @@ -71,7 +71,7 @@ func connectSpotifyResponse(r *http.Request) error { // 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) + err = insertOauthToken(userUuid, "spotify", token.AccessToken, token.RefreshToken, token.Expiry, spotifyUser.DisplayName, time, "") if err != nil { return err } @@ -127,7 +127,7 @@ func (user *User) updateSpotifyPlaydata() { // 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) + 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") } diff --git a/internal/goscrobble/oauth_tokens.go b/internal/goscrobble/oauth_tokens.go index ed2cf016..b3754d64 100644 --- a/internal/goscrobble/oauth_tokens.go +++ b/internal/goscrobble/oauth_tokens.go @@ -14,14 +14,15 @@ type OauthToken struct { Expiry time.Time `json:"expiry"` Username string `json:"username"` LastSynced time.Time `json:"last_synced"` + URL string `json:"url"` } 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` "+ + err := db.QueryRow("SELECT BIN_TO_UUID(`user`, true), `service`, `access_token`, `refresh_token`, `expiry`, `username`, `last_synced`, `url` 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) + userUuid, service).Scan(&oauth.UserUUID, &oauth.Service, &oauth.AccessToken, &oauth.RefreshToken, &oauth.Expiry, &oauth.Username, &oauth.LastSynced, &oauth.URL) if err == sql.ErrNoRows { return oauth, errors.New("No token for user") @@ -30,9 +31,9 @@ func getOauthToken(userUuid string, service string) (OauthToken, error) { 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) +func insertOauthToken(userUuid string, service string, token string, refresh string, expiry time.Time, username string, lastSynced time.Time, url string) error { + _, err := db.Exec("REPLACE INTO `oauth_tokens` (`user`, `service`, `access_token`, `refresh_token`, `expiry`, `username`, `last_synced`, `url`) "+ + "VALUES (UUID_TO_BIN(?, true),?,?,?,?,?,?,?)", userUuid, service, token, refresh, expiry, username, lastSynced, url) return err } diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 02def728..29b7276d 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/gorilla/mux" "github.com/rs/cors" @@ -21,6 +22,21 @@ type jsonResponse struct { // List of Reverse proxies var ReverseProxies []string +// RequestRequest - Incoming JSON! +type RequestRequest struct { + URL string `json:"url"` + Token string `json:"token"` + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type RequestResponse struct { + Token string `json:"token,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + func enableCors(w *http.ResponseWriter) { (*w).Header().Set("Access-Control-Allow-Origin", "*") } @@ -39,8 +55,10 @@ func HandleRequests(port string) { // JWT Auth - Own profile only (Uses uuid in JWT) v1.HandleFunc("/user", limitMiddleware(jwtMiddleware(getUser), lightLimiter)).Methods("GET") v1.HandleFunc("/user", limitMiddleware(jwtMiddleware(patchUser), lightLimiter)).Methods("PATCH") + v1.HandleFunc("/user/navidrome", limitMiddleware(jwtMiddleware(postNavidrome), lightLimiter)).Methods("POST") + v1.HandleFunc("/user/navidrome", limitMiddleware(jwtMiddleware(deleteNavidrome), lightLimiter)).Methods("DELETE") v1.HandleFunc("/user/spotify", limitMiddleware(jwtMiddleware(getSpotifyClientID), lightLimiter)).Methods("GET") - v1.HandleFunc("/user/spotify", limitMiddleware(jwtMiddleware(deleteSpotifyLink), lightLimiter)).Methods("DELETE") + v1.HandleFunc("/user/spotify", limitMiddleware(jwtMiddleware(deleteSpotify), lightLimiter)).Methods("DELETE") v1.HandleFunc("/user/{uuid}/scrobbles", jwtMiddleware(getScrobbles)).Methods("GET") // Config auth @@ -108,7 +126,7 @@ func handleRegister(w http.ResponseWriter, r *http.Request) { return } - regReq := RegisterRequest{} + regReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(®Req) if err != nil { @@ -128,7 +146,7 @@ func handleRegister(w http.ResponseWriter, r *http.Request) { // handleLogin - Does as it says! func handleLogin(w http.ResponseWriter, r *http.Request) { - logReq := LoginRequest{} + logReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&logReq) if err != nil { @@ -149,7 +167,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { // handleTokenRefresh - Refresh access token based on refresh token func handleTokenRefresh(w http.ResponseWriter, r *http.Request) { - logReq := LoginResponse{} + logReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&logReq) user, err := isValidRefreshToken(logReq.Token) @@ -165,7 +183,7 @@ func handleTokenRefresh(w http.ResponseWriter, r *http.Request) { return } - loginResp := LoginResponse{ + loginResp := RequestResponse{ Token: token, } @@ -189,7 +207,7 @@ func handleStats(w http.ResponseWriter, r *http.Request) { // handleSendReset - Does as it says! func handleSendReset(w http.ResponseWriter, r *http.Request) { - req := RegisterRequest{} + req := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&req) if err != nil { @@ -342,10 +360,14 @@ func getUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUse return } - // - oauth, err := getOauthToken(user.UUID, "spotify") + oauthNavi, err := getOauthToken(user.UUID, "navidrome") if err == nil { - user.SpotifyUsername = oauth.Username + user.NavidromeURL = oauthNavi.URL + } + + oauthSpotify, err := getOauthToken(user.UUID, "spotify") + if err == nil { + user.SpotifyUsername = oauthSpotify.Username } json, _ := json.Marshal(&user) @@ -631,7 +653,7 @@ func getSpotifyClientID(w http.ResponseWriter, r *http.Request, claims CustomCla return } - response := LoginResponse{ + response := RequestResponse{ Token: key, } @@ -640,8 +662,8 @@ func getSpotifyClientID(w http.ResponseWriter, r *http.Request, claims CustomCla w.Write(resp) } -// deleteSpotifyLink - Unlinks spotify account -func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { +// deleteSpotify - Unlinks spotify account +func deleteSpotify(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { jwtUser := claims.Subject err := removeOauthToken(jwtUser, "spotify") if err != nil { @@ -653,6 +675,52 @@ func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, claims CustomClai throwOkMessage(w, "Spotify account successfully unlinked") } +// postNavidrome - Submits data for navidrome URL/User/Password +func postNavidrome(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { + jwtUser := claims.Subject + + request := RequestRequest{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&request) + if err != nil { + throwOkError(w, "Invalid JSON") + return + } + + // hash password with salt + salt := generateToken(32) + hash := getMd5(request.Password + salt) + + err = validateNavidromeConnection(request.URL, request.Username, hash, salt) + if err != nil { + throwOkError(w, "Failed to validate credentials") + return + } + + // Lets set this back 30min + time := time.Now().UTC().Add(-(time.Duration(30) * time.Minute)) + err = insertOauthToken(jwtUser, "navidrome", hash, salt, time, request.Username, time, request.URL) + if err != nil { + throwOkError(w, "Failed to save Navidome token") + return + } + + throwOkMessage(w, "Successfully saved!") +} + +// deleteNavidrome - Unlinks Navidrome account +func deleteNavidrome(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { + jwtUser := claims.Subject + err := removeOauthToken(jwtUser, "navidrome") + if err != nil { + fmt.Println(err) + throwOkError(w, "Failed to unlink navidrome account") + return + } + + throwOkMessage(w, "Navidrome account successfully unlinked") +} + func getServerInfo(w http.ResponseWriter, r *http.Request) { cachedRegistrationEnabled := getRedisVal("REGISTRATION_ENABLED") if cachedRegistrationEnabled == "" { @@ -665,7 +733,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) { } info := ServerInfo{ - Version: "0.0.25", + Version: "0.0.26", RegistrationEnabled: cachedRegistrationEnabled, } diff --git a/internal/goscrobble/timers.go b/internal/goscrobble/timers.go index 61c6d09b..b2425275 100644 --- a/internal/goscrobble/timers.go +++ b/internal/goscrobble/timers.go @@ -22,14 +22,17 @@ func StartBackgroundWorkers() { return case <-hourTicker.C: // Clear old password reset tokens - clearOldResetTokens() + go clearOldResetTokens() // Attempt to pull missing images from spotify - hackerino version! user, _ := getUserByUsername("idanoo") - user.updateImageDataFromSpotify() + go user.updateImageDataFromSpotify() case <-minuteTicker.C: - // Update playdata from spotify - updateSpotifyData() + // Update playdata from Spotify + go updateSpotifyData() + + // Update playdate from Navidrome + go updateNavidromeData() } } }() diff --git a/internal/goscrobble/user.go b/internal/goscrobble/user.go index b90d2595..5b31272f 100644 --- a/internal/goscrobble/user.go +++ b/internal/goscrobble/user.go @@ -44,28 +44,11 @@ type UserResponse struct { SpotifyUsername string `json:"spotify_username"` Timezone string `json:"timezone"` Token string `json:"token"` -} - -// RegisterRequest - Incoming JSON -type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` -} - -// RegisterRequest - Incoming JSON -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// LoginResponse - JWT issued -type LoginResponse struct { - Token string `json:"token"` + NavidromeURL string `json:"navidrome_server"` } // createUser - Called from API -func createUser(req *RegisterRequest, ip net.IP) error { +func createUser(req *RequestRequest, ip net.IP) error { // Check if user already exists.. if len(req.Password) < 8 { return errors.New("Password must be at least 8 characters") @@ -102,7 +85,7 @@ func createUser(req *RegisterRequest, ip net.IP) error { return insertUser(req.Username, req.Email, hash, ip) } -func loginUser(logReq *LoginRequest, ip net.IP) ([]byte, error) { +func loginUser(logReq *RequestRequest, ip net.IP) ([]byte, error) { var resp []byte var user User @@ -141,7 +124,7 @@ func loginUser(logReq *LoginRequest, ip net.IP) ([]byte, error) { return resp, errors.New("Error logging in") } - loginResp := LoginResponse{ + loginResp := RequestResponse{ Token: token, } @@ -187,7 +170,7 @@ func isValidPassword(password string, user User) bool { // userAlreadyExists - Returns bool indicating if a record exists for either username or email // Using two look ups to make use of DB indexes. -func userAlreadyExists(req *RegisterRequest) bool { +func userAlreadyExists(req *RequestRequest) bool { count, err := getDbCount("SELECT COUNT(*) FROM users WHERE username = ?", req.Username) if err != nil { fmt.Printf("Error querying for duplicate users: %v", err) @@ -327,6 +310,10 @@ func (user *User) getSpotifyTokens() (OauthToken, error) { return getOauthToken(user.UUID, "spotify") } +func (user *User) getNavidromeTokens() (OauthToken, error) { + return getOauthToken(user.UUID, "navidrome") +} + 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` " + @@ -358,3 +345,35 @@ func getAllSpotifyUsers() ([]User, error) { return users, nil } + +func getAllNavidromeUsers() ([]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` = 'navidrome' WHERE `users`.`active` = 1") + + if err != nil { + log.Printf("Failed to fetch navidrome 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 navidrome 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 navidrome users: %+v", err) + return users, errors.New("Failed to fetch users") + } + + return users, nil +} diff --git a/migrations/8_oauth.up.sql b/migrations/8_oauth.up.sql index 4a4a15a3..913e5366 100644 --- a/migrations/8_oauth.up.sql +++ b/migrations/8_oauth.up.sql @@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS `oauth_tokens` ( `service` VARCHAR(64) NOT NULL, `access_token` VARCHAR(255) NULL DEFAULT '', `refresh_token` VARCHAR(255) NULL DEFAULT '', + `url` VARCHAR(255) NULL DEFAULT '', `expiry` DATETIME NOT NULL, `username` VARCHAR(100) NULL DEFAULT '', `last_synced` DATETIME NOT NULL DEFAULT NOW(), diff --git a/web/src/Api/index.js b/web/src/Api/index.js index 64754e49..c4c85169 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -281,6 +281,26 @@ export const spotifyDisonnectionRequest = () => { }); } +export const navidromeConnectionRequest = (values) => { + return axios.post(process.env.REACT_APP_API_URL + "user/navidrome", values, { headers: getHeaders() }) + .then((data) => { + toast.success(data.data.message); + return true + }).catch((error) => { + return handleErrorResp(error) + }); +}; + +export const navidromeDisonnectionRequest = () => { + return axios.delete(process.env.REACT_APP_API_URL + "user/navidrome", { headers: getHeaders() }) + .then((data) => { + toast.success(data.data.message); + return true + }).catch((error) => { + return handleErrorResp(error) + }); +} + export const getServerInfo = () => { return axios.get(process.env.REACT_APP_API_URL + "serverinfo") diff --git a/web/src/Components/ScrobbleTable.js b/web/src/Components/ScrobbleTable.js index 6253fdf1..f6857b6a 100644 --- a/web/src/Components/ScrobbleTable.js +++ b/web/src/Components/ScrobbleTable.js @@ -3,15 +3,14 @@ import { Link } from 'react-router-dom'; const ScrobbleTable = (props) => { return ( -
Timestamp | Track | Artist | Album | -Source | {element.artist} | {element.album} | -{element.source} | ; }) } diff --git a/web/src/Pages/Profile.js b/web/src/Pages/Profile.js index d6c8d22c..55eb0af4 100644 --- a/web/src/Pages/Profile.js +++ b/web/src/Pages/Profile.js @@ -71,7 +71,7 @@ const Profile = (route) => {