package goscrobble import ( "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "github.com/gorilla/mux" "github.com/rs/cors" ) type jsonResponse struct { Err string `json:"error,omitempty"` Msg string `json:"message,omitempty"` Valid bool `json:"valid,omitempty"` } // List of Reverse proxies var ReverseProxies []string // Directories var FrontendDirectory string var DataDirectory string var ApiDocsDirectory 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", "*") } // HandleRequests - Boot HTTP! func HandleRequests(port string) { // Create a new router r := mux.NewRouter().StrictSlash(true) v1 := r.PathPrefix("/api/v1").Subrouter() // Static Token for /ingress v1.HandleFunc("/ingress/jellyfin", limitMiddleware(tokenMiddleware(handleIngress), lightLimiter)).Methods("POST") v1.HandleFunc("/ingress/multiscrobbler", limitMiddleware(tokenMiddleware(handleIngress), lightLimiter)).Methods("POST") // 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(deleteSpotify), lightLimiter)).Methods("DELETE") v1.HandleFunc("/user/{uuid}/scrobbles", jwtMiddleware(getScrobbles)).Methods("GET") // Config auth v1.HandleFunc("/config", limitMiddleware(adminMiddleware(getConfig), standardLimiter)).Methods("GET") v1.HandleFunc("/config", limitMiddleware(adminMiddleware(postConfig), standardLimiter)).Methods("POST") // No Auth v1.HandleFunc("/stats", limitMiddleware(handleStats, lightLimiter)).Methods("GET") v1.HandleFunc("/profile/{username}", limitMiddleware(getProfile, lightLimiter)).Methods("GET") v1.HandleFunc("/artists/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET") v1.HandleFunc("/artists/{uuid}", limitMiddleware(getArtist, lightLimiter)).Methods("GET") v1.HandleFunc("/albums/top/{uuid}", limitMiddleware(getArtists, lightLimiter)).Methods("GET") v1.HandleFunc("/albums/{uuid}", limitMiddleware(getAlbum, lightLimiter)).Methods("GET") v1.HandleFunc("/tracks/top/{uuid}", limitMiddleware(getTracks, lightLimiter)).Methods("GET") // User UUID - Top Tracks v1.HandleFunc("/tracks/{uuid}", limitMiddleware(getTrack, lightLimiter)).Methods("GET") // Track UUID v1.HandleFunc("/tracks/{uuid}/top", limitMiddleware(getTopUsersForTrack, lightLimiter)).Methods("GET") // TrackUUID - Top Listeners v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST") v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST") v1.HandleFunc("/serverinfo", getServerInfo).Methods("GET") v1.HandleFunc("/refresh", limitMiddleware(handleTokenRefresh, standardLimiter)).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") // Serve Images spaStatic := spaStaticHandler{staticPath: DataDirectory} r.PathPrefix("/img").Handler(spaStatic) // Serve API Docs apiDocs := apiDocHandler{staticPath: ApiDocsDirectory, indexPath: "index.html"} r.PathPrefix("/docs/").Handler(apiDocs) // This is a really terrible work around to Slate using relative paths and not // picking up the css/img files when you don't have a trailing slash\ apiDocRedirect := apiDocRedirectHandler{} r.PathPrefix("/docs").Handler(apiDocRedirect) // Serve Frontend spa := spaHandler{staticPath: FrontendDirectory + string(os.PathSeparator) + "build", indexPath: "index.html"} r.PathPrefix("/").Handler(spa) // Setup CORS rules c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowCredentials: true, AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE"}, AllowedHeaders: []string{"*"}, }) handler := c.Handler(r) // Serve it! fmt.Printf("Goscrobble listening on port %s", port) fmt.Println("") log.Fatal(http.ListenAndServe(":"+port, handler)) } // API ENDPOINT HANDLING // handleRegister - Does as it says! func handleRegister(w http.ResponseWriter, r *http.Request) { registrationEnabled, err := getConfigValue("REGISTRATION_ENABLED") if err != nil { log.Printf("%+v", err) throwOkError(w, "Registration is currently disabled") return } if registrationEnabled == "0" { throwOkError(w, "Registration is currently disabled") return } regReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err = decoder.Decode(®Req) if err != nil { throwBadReq(w, err.Error()) return } ip := getUserIp(r) err = createUser(®Req, ip) if err != nil { throwOkError(w, err.Error()) return } throwOkMessage(w, "User created succesfully. You may now login") } // handleLogin - Does as it says! func handleLogin(w http.ResponseWriter, r *http.Request) { logReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&logReq) if err != nil { throwBadReq(w, err.Error()) return } ip := getUserIp(r) data, err := loginUser(&logReq, ip) if err != nil { throwOkError(w, err.Error()) return } w.WriteHeader(http.StatusOK) w.Write(data) } // handleTokenRefresh - Refresh access token based on refresh token func handleTokenRefresh(w http.ResponseWriter, r *http.Request) { logReq := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&logReq) user, err := isValidRefreshToken(logReq.Token) if err != nil { throwOkError(w, "Invalid refresh token") return } // Issue JWT + Response token, err := generateJWTToken(user, logReq.Token) if err != nil { throwOkError(w, "Failed to refresh Token") return } loginResp := RequestResponse{ Token: token, } resp, _ := json.Marshal(&loginResp) w.WriteHeader(http.StatusOK) w.Write(resp) } // handleStats - Returns stats for homepage func handleStats(w http.ResponseWriter, r *http.Request) { stats, err := getStats() if err != nil { throwOkError(w, err.Error()) return } js, _ := json.Marshal(&stats) w.WriteHeader(http.StatusOK) w.Write(js) } // handleSendReset - Does as it says! func handleSendReset(w http.ResponseWriter, r *http.Request) { req := RequestRequest{} decoder := json.NewDecoder(r.Body) err := decoder.Decode(&req) if err != nil { throwBadReq(w, err.Error()) return } if req.Email == "" { throwOkError(w, "Invalid Email") return } _ = getUserIp(r) user, err := getUserByEmail(req.Email) if err != nil { throwOkError(w, err.Error()) return } ip := getUserIp(r) err = user.sendResetEmail(ip) if err != nil { throwOkError(w, err.Error()) return } throwOkMessage(w, "Password reset email sent") } // handleSendReset - Does as it says! func handleResetPassword(w http.ResponseWriter, r *http.Request) { bodyJson, err := decodeJson(r.Body) if err != nil { throwInvalidJson(w) return } if bodyJson["password"] == nil { // validating valid, err := checkResetToken(fmt.Sprintf("%s", bodyJson["token"])) if err != nil { throwOkError(w, err.Error()) return } jr := jsonResponse{ Valid: valid, } msg, _ := json.Marshal(&jr) w.WriteHeader(http.StatusOK) w.Write(msg) return } else { // resetting token := fmt.Sprintf("%s", bodyJson["token"]) pw := fmt.Sprintf("%s", bodyJson["password"]) if len(pw) < 8 { throwOkError(w, "Password must be at least 8 characters") return } ip := getUserIp(r) user, err := getUserByResetToken(token) if err != nil { throwOkError(w, err.Error()) return } err = user.updatePassword(pw, ip) if err != nil { throwOkError(w, err.Error()) return } throwOkMessage(w, "Password updated successfully!") return } } // serveEndpoint - API stuffs func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) { var err error ip := getUserIp(r) tx, _ := db.Begin() ingressType := strings.Replace(r.URL.Path, "/api/v1/ingress/", "", 1) switch ingressType { case "jellyfin": jfInput := JellyfinRequest{} err := json.NewDecoder(r.Body).Decode(&jfInput) if err != nil { throwInvalidJson(w) return } err = ParseJellyfinInput(userUuid, jfInput, ip, tx) if err != nil { fmt.Println(err) tx.Rollback() throwOkError(w, err.Error()) return } case "multiscrobbler": msInput := MultiScrobblerRequest{} err := json.NewDecoder(r.Body).Decode(&msInput) if err != nil { fmt.Println(err) tx.Rollback() throwOkError(w, err.Error()) return } err = ParseMultiScrobblerInput(userUuid, msInput, ip, tx) if err != nil { fmt.Println(err) tx.Rollback() throwOkError(w, err.Error()) return } default: tx.Rollback() throwBadReq(w, "Unknown ingress type") } err = tx.Commit() if err != nil { throwOkError(w, err.Error()) return } throwOkMessage(w, "success") return } // getUser - Return personal userprofile func getUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { jwtUser := claims.Subject userFull, err := getUserByUUID(jwtUser) if err != nil { throwOkError(w, "Failed to fetch user information") return } jsonFull, _ := json.Marshal(&userFull) // Lets strip out vars we don't want to send. user := UserResponse{} err = json.Unmarshal(jsonFull, &user) if err != nil { throwOkError(w, "Failed to fetch user information") return } oauthNavi, err := getOauthToken(user.UUID, "navidrome") if err == nil { user.NavidromeURL = oauthNavi.URL } oauthSpotify, err := getOauthToken(user.UUID, "spotify") if err == nil { user.SpotifyUsername = oauthSpotify.Username } json, _ := json.Marshal(&user) w.WriteHeader(http.StatusOK) w.Write(json) } // patchUser - Update specific values func patchUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { jwtUser := claims.Subject userFull, err := getUserByUUID(jwtUser) if err != nil { throwOkError(w, "Failed to fetch user information") return } bodyJson, _ := decodeJson(r.Body) ip := getUserIp(r) for k, v := range bodyJson { val := fmt.Sprintf("%s", v) if k == "timezone" { if isValidTimezone(val) { userFull.updateUser("timezone", val, ip) } } else if k == "token" { token := generateToken(32) userFull.updateUser("token", token, ip) } } throwOkMessage(w, "User updated successfully") } // getScrobbles - Return an array of scrobbles func getScrobbles(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { resp, err := getScrobblesForUser(reqUser, 100, 1) if err != nil { throwOkError(w, "Failed to fetch scrobbles") return } // Fetch last 500 scrobbles json, _ := json.Marshal(&resp) w.WriteHeader(http.StatusOK) w.Write(json) } // getConfig - Return an array of scrobbles func getConfig(w http.ResponseWriter, r *http.Request, jwtUser string) { config, err := getAllConfigs() if err != nil { throwOkError(w, "Failed to fetch scrobbles") return } json, _ := json.Marshal(&config) w.WriteHeader(http.StatusOK) w.Write(json) } // postConfig - Return an array of scrobbles func postConfig(w http.ResponseWriter, r *http.Request, jwtUser string) { bodyJson, err := decodeJson(r.Body) if err != nil { throwInvalidJson(w) return } for k, v := range bodyJson { val := fmt.Sprintf("%s", v) err = updateConfigValue(k, val) if err != nil { throwOkError(w, err.Error()) return } setRedisVal(k, val) } throwOkMessage(w, "Config updated successfully") } // getProfile - Returns public user profile data func getProfile(w http.ResponseWriter, r *http.Request) { var username string for k, v := range mux.Vars(r) { if k == "username" { username = v } } if username == "" { throwOkError(w, "Invalid Username") return } user, err := getUserByUsername(username) if err != nil { throwOkError(w, err.Error()) return } resp, err := getProfileForUser(user) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&resp) w.WriteHeader(http.StatusOK) w.Write(json) } // getArtist - Returns artist data func getArtist(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } artist, err := getArtistByUUID(uuid) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&artist) w.WriteHeader(http.StatusOK) w.Write(json) } // getAlbum - Returns album data func getAlbum(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } album, err := getAlbumByUUID(uuid) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&album) w.WriteHeader(http.StatusOK) w.Write(json) } // getTrack - Returns track data func getTrack(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } // Load track obj track, err := getTrackByUUID(uuid) if err != nil { throwOkError(w, err.Error()) return } // Load in Album/Artist info err = track.loadExtraTrackInfo() if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&track) w.WriteHeader(http.StatusOK) w.Write(json) } // getArtists - Returns artist data for a user func getArtists(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } track, err := getTopArtists(uuid) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&track) w.WriteHeader(http.StatusOK) w.Write(json) } // getAlbums - Returns album data for a user func getAlbums(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } album, err := getAlbumByUUID(uuid) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&album) w.WriteHeader(http.StatusOK) w.Write(json) } // getTracks - Returns track data for a user func getTracks(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } track, err := getTopTracks(uuid) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&track) w.WriteHeader(http.StatusOK) w.Write(json) } // getTopUsersForTrack - I suck at naming. Returns top users that have scrobbled this track. func getTopUsersForTrack(w http.ResponseWriter, r *http.Request) { var uuid string for k, v := range mux.Vars(r) { if k == "uuid" { uuid = v } } if uuid == "" { throwOkError(w, "Invalid UUID") return } userList, err := getTopUsersForTrackUUID(uuid, 10, 1) if err != nil { throwOkError(w, err.Error()) return } json, _ := json.Marshal(&userList) 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 { log.Printf("Post Spotify Response: %s", err) 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, claims CustomClaims, v string) { key, err := getConfigValue("SPOTIFY_API_ID") if err != nil { throwOkError(w, "Failed to get Spotify ID") return } response := RequestResponse{ Token: key, } resp, _ := json.Marshal(&response) w.WriteHeader(http.StatusOK) w.Write(resp) } // 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 { fmt.Println(err) throwOkError(w, "Failed to unlink spotify account") return } 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) { registrationEnabled, err := getConfigValue("REGISTRATION_ENABLED") if err != nil { log.Printf("%+v", err) registrationEnabled = "0" } info := ServerInfo{ Version: "0.1.1", RegistrationEnabled: registrationEnabled, } js, _ := json.Marshal(&info) w.WriteHeader(http.StatusOK) w.Write(js) }