diff --git a/.env.example b/.env.example index 653ef4d4..aa3cbdb0 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,4 @@ MAIL_FROM_ADDRESS= MAIL_FROM_NAME= GOSCROBBLE_DOMAIN="" +STATIC_DIR="web" diff --git a/.gitignore b/.gitignore index eda2b7ed..88083e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ web/.env.production web/.env.development +web/img/* + # Test binary, built with `go test -c` *.test diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ed95d39..3300a85c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: - bundle variables: - VERSION: 0.0.28 + VERSION: 0.0.29 build-go: image: golang:1.16.2 diff --git a/cmd/go-scrobble/main.go b/cmd/go-scrobble/main.go index 801c98fd..d7dfcf0b 100644 --- a/cmd/go-scrobble/main.go +++ b/cmd/go-scrobble/main.go @@ -51,6 +51,12 @@ func main() { goscrobble.RefereshExpiry = time.Duration(i) * time.Second } + goscrobble.StaticDirectory = "web" + staticDirectoryStr := os.Getenv("STATIC_DIR") + if staticDirectoryStr != "" { + goscrobble.StaticDirectory = staticDirectoryStr + } + // Ignore reverse proxies goscrobble.ReverseProxies = strings.Split(os.Getenv("REVERSE_PROXIES"), ",") diff --git a/docs/changelog.md b/docs/changelog.md index 6d498664..c0c967a9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,7 @@ +# 0.0.29 +- Add image handler +- Store images locally + # 0.0.28 - Fix mobile view on user pages - Fix favicon issue diff --git a/docs/config.md b/docs/config.md index d61d7670..ac9b09cc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -31,3 +31,4 @@ These are stored in `web/.env.production` and `web/.env.development` MAIL_FROM_NAME= // FROM name GOSCROBBLE_DOMAIN="" // Full domain for email links (https://goscrobble.com)) + STATIC_DIR="web" // Location to store images (This will serve from web/static) diff --git a/internal/goscrobble/artist.go b/internal/goscrobble/artist.go index 136befc2..9b82feb4 100644 --- a/internal/goscrobble/artist.go +++ b/internal/goscrobble/artist.go @@ -129,7 +129,7 @@ func getArtistByUUID(uuid string) (Artist, error) { func getTopArtists(userUuid string) (TopArtists, error) { var topArtist TopArtists - rows, err := db.Query("SELECT BIN_TO_UUID(`artists`.`uuid`, true), `artists`.`name`, IFNULL(`artists`.`img`,''), count(*) "+ + rows, err := db.Query("SELECT BIN_TO_UUID(`artists`.`uuid`, true), `artists`.`name`, IFNULL(BIN_TO_UUID(`artists`.`uuid`, true),''), count(*) "+ "FROM `scrobbles` "+ "JOIN `tracks` ON `tracks`.`uuid` = `scrobbles`.`track` "+ "JOIN track_artist ON track_artist.track = tracks.uuid "+ diff --git a/internal/goscrobble/image.go b/internal/goscrobble/image.go new file mode 100644 index 00000000..369cb9eb --- /dev/null +++ b/internal/goscrobble/image.go @@ -0,0 +1,46 @@ +package goscrobble + +import ( + "fmt" + "io" + "net/http" + "os" +) + +func importImage(uuid string, url string) error { + // Create the file + path, err := os.Getwd() + if err != nil { + return err + } + + out, err := os.Create(path + string(os.PathSeparator) + StaticDirectory + string(os.PathSeparator) + "img" + string(os.PathSeparator) + uuid + "_full.jpg") + if err != nil { + return err + } + defer out.Close() + + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check server response + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Bad response status: %s", resp.Status) + } + + // Writer the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + return nil +} + +func resizeImage(uuid string) error { + return nil +} diff --git a/internal/goscrobble/ingress_navidrome.go b/internal/goscrobble/ingress_navidrome.go index 26583c8d..f928ef30 100644 --- a/internal/goscrobble/ingress_navidrome.go +++ b/internal/goscrobble/ingress_navidrome.go @@ -84,7 +84,6 @@ func (user *User) updateNavidromePlaydata(tx *sql.Tx) error { } response, err := getNavidromeNowPlaying(&tokens) - fmt.Println(response) if err != nil { return errors.New(fmt.Sprintf("Failed to fetch Navidrome Tokens %+v", err)) } diff --git a/internal/goscrobble/ingress_spotify.go b/internal/goscrobble/ingress_spotify.go index ec190635..0f938d41 100644 --- a/internal/goscrobble/ingress_spotify.go +++ b/internal/goscrobble/ingress_spotify.go @@ -238,7 +238,7 @@ func (user *User) updateImageDataFromSpotify() error { client := auth.NewClient(token) client.AutoRetry = true - rows, err := db.Query("SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `artists` WHERE IFNULL(`img`,'') = '' LIMIT 50") + rows, err := db.Query("SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `artists` WHERE IFNULL(`img`,'') NOT IN ('pending', 'complete') LIMIT 50") if err != nil { log.Printf("Failed to fetch config: %+v", err) return errors.New("Failed to fetch artists") @@ -271,11 +271,18 @@ func (user *User) updateImageDataFromSpotify() error { if err != nil { continue } - _ = artist.updateArtist("img", img, tx) + + err = importImage(uuid, img) + if err != nil { + fmt.Printf("Failed to import image: %+v", err) + continue + } + + _ = artist.updateArtist("img", "pending", tx) } tx.Commit() - rows, err = db.Query("SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `albums` WHERE IFNULL(`img`,'') = '' LIMIT 50") + rows, err = db.Query("SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `albums` WHERE IFNULL(`img`,'') NOT IN ('pending', 'complete') LIMIT 50") if err != nil { log.Printf("Failed to fetch config: %+v", err) return errors.New("Failed to fetch artists") @@ -305,10 +312,12 @@ func (user *User) updateImageDataFromSpotify() error { tx, _ = db.Begin() for uuid, img := range toUpdate { album, err = getAlbumByUUID(uuid) + err = importImage(uuid, img) + if err != nil { continue } - _ = album.updateAlbum("img", img, tx) + _ = album.updateAlbum("img", "pending", tx) } tx.Commit() return nil diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 61c64d44..4716e04b 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -22,6 +22,9 @@ type jsonResponse struct { // List of Reverse proxies var ReverseProxies []string +// Static image directory +var StaticDirectory string + // RequestRequest - Incoming JSON! type RequestRequest struct { URL string `json:"url"` @@ -88,6 +91,10 @@ func HandleRequests(port string) { // This just prevents it serving frontend stuff over /api r.PathPrefix("/api") + // SERVE STATIC FILES - NO AUTH + spaStatic := spaStaticHandler{staticPath: StaticDirectory} + r.PathPrefix("/img").Handler(spaStatic) + // SERVE FRONTEND - NO AUTH spa := spaHandler{staticPath: "web/build", indexPath: "index.html"} r.PathPrefix("/").Handler(spa) @@ -733,7 +740,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) { } info := ServerInfo{ - Version: "0.0.28", + Version: "0.0.29", RegistrationEnabled: cachedRegistrationEnabled, } diff --git a/internal/goscrobble/server_static.go b/internal/goscrobble/server_static.go index 7e404244..f420d03f 100644 --- a/internal/goscrobble/server_static.go +++ b/internal/goscrobble/server_static.go @@ -6,12 +6,43 @@ import ( "path/filepath" ) +// spaStaticHandler - Handles static imges +type spaStaticHandler struct { + staticPath string + indexPath string +} + // spaHandler - Handles Single Page Applications (React) type spaHandler struct { staticPath string indexPath string } +// ServerHTTP - Frontend React server +func (h spaStaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get the absolute path to prevent directory traversal + path, err := filepath.Abs(r.URL.Path) + if err != nil { + http.ServeFile(w, r, filepath.Join(h.staticPath, "img/placeholder.jpg")) + return + } + + path = filepath.Join(h.staticPath, path) + + _, err = os.Stat(path) + if os.IsNotExist(err) { + // file does not exist, serve placeholder + http.ServeFile(w, r, filepath.Join(h.staticPath, "img/placeholder.jpg")) + return + } else if err != nil { + http.ServeFile(w, r, filepath.Join(h.staticPath, "img/placeholder.jpg")) + return + } + + // otherwise, use http.FileServer to serve the static images + http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r) +} + // ServerHTTP - Frontend React server func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Get the absolute path to prevent directory traversal diff --git a/internal/goscrobble/timers.go b/internal/goscrobble/timers.go index 070e2bcb..90b9c267 100644 --- a/internal/goscrobble/timers.go +++ b/internal/goscrobble/timers.go @@ -8,6 +8,9 @@ import ( var endTicker chan bool func StartBackgroundWorkers() { + user, _ := getUserByUsername("idanoo") + go user.updateImageDataFromSpotify() + endTicker := make(chan bool) hourTicker := time.NewTicker(time.Duration(1) * time.Hour) diff --git a/internal/goscrobble/track.go b/internal/goscrobble/track.go index 75487891..85a12838 100644 --- a/internal/goscrobble/track.go +++ b/internal/goscrobble/track.go @@ -166,7 +166,7 @@ func (track *Track) updateTrack(col string, val string, tx *sql.Tx) error { func getTrackByUUID(uuid string) (Track, error) { var track Track - err := db.QueryRow("SELECT BIN_TO_UUID(`tracks`.`uuid`, true), `tracks`.`name`, IFNULL(`albums`.`desc`,''), IFNULL(`albums`.`img`,''), `tracks`.`length`, `tracks`.`mbid`, `tracks`.`spotify_id` "+ + err := db.QueryRow("SELECT BIN_TO_UUID(`tracks`.`uuid`, true), `tracks`.`name`, IFNULL(`albums`.`desc`,''), IFNULL(BIN_TO_UUID(`albums`.`uuid`, true),''), `tracks`.`length`, `tracks`.`mbid`, `tracks`.`spotify_id` "+ "FROM `tracks` "+ "LEFT JOIN track_album ON track_album.track = tracks.uuid "+ "LEFT JOIN albums ON track_album.album = albums.uuid "+ @@ -184,7 +184,7 @@ func getTrackByUUID(uuid string) (Track, error) { func getTopTracks(userUuid string) (TopTracks, error) { var topTracks TopTracks - rows, err := db.Query("SELECT BIN_TO_UUID(`tracks`.`uuid`, true), `tracks`.`name`, IFNULL(`albums`.`img`,''), count(*) "+ + rows, err := db.Query("SELECT BIN_TO_UUID(`tracks`.`uuid`, true), `tracks`.`name`, IFNULL(BIN_TO_UUID(`albums`.`uuid`, true),''), count(*) "+ "FROM `scrobbles` "+ "JOIN `tracks` ON `tracks`.`uuid` = `scrobbles`.`track` "+ "JOIN track_album ON track_album.track = tracks.uuid "+ diff --git a/web/.env.example b/web/.env.example index fbedc8f2..3150c75a 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1 +1 @@ -REACT_APP_API_URL=http://127.0.0.1:42069/api/v1/ +REACT_APP_API_URL=http://127.0.0.1:42069 diff --git a/web/img/placeholder.jpg b/web/img/placeholder.jpg new file mode 100644 index 00000000..ff8ff816 Binary files /dev/null and b/web/img/placeholder.jpg differ diff --git a/web/src/Api/index.js b/web/src/Api/index.js index c4c85169..3114a660 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -49,7 +49,7 @@ function handleErrorResp(error) { } export const PostLogin = (formValues) => { - return axios.post(process.env.REACT_APP_API_URL + "login", formValues) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/login", formValues) .then((response) => { if (response.data.token) { let expandedUser = jwt(response.data.token) @@ -82,7 +82,7 @@ export const PostLogin = (formValues) => { }; export const PostRefreshToken = (refreshToken) => { - return axios.post(process.env.REACT_APP_API_URL + "refresh", {token: refreshToken}) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/refresh", {token: refreshToken}) .then((response) => { if (response.data.token) { let expandedUser = jwt(response.data.token) @@ -115,7 +115,7 @@ export const PostRefreshToken = (refreshToken) => { export const PostRegister = (formValues) => { - return axios.post(process.env.REACT_APP_API_URL + "register", formValues) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/register", formValues) .then((response) => { if (response.data.message) { toast.success(response.data.message); @@ -133,7 +133,7 @@ export const PostRegister = (formValues) => { }; export const PostResetPassword = (formValues) => { - return axios.post(process.env.REACT_APP_API_URL + "resetpassword", formValues) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/resetpassword", formValues) .then((response) => { if (response.data.message) { toast.success(response.data.message); @@ -151,7 +151,7 @@ export const PostResetPassword = (formValues) => { }; export const sendPasswordReset = (values) => { - return axios.post(process.env.REACT_APP_API_URL + "sendreset", values).then( + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/sendreset", values).then( (data) => { return data.data; }).catch((error) => { @@ -160,7 +160,7 @@ export const sendPasswordReset = (values) => { }; export const getStats = () => { - return axios.get(process.env.REACT_APP_API_URL + "stats").then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/stats").then( (data) => { return data.data; }).catch((error) => { @@ -169,7 +169,7 @@ export const getStats = () => { }; export const getRecentScrobbles = (id) => { - return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/" + id + "/scrobbles", { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -178,7 +178,7 @@ export const getRecentScrobbles = (id) => { }; export const getConfigs = () => { - return axios.get(process.env.REACT_APP_API_URL + "config", { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/config", { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -193,7 +193,7 @@ export const postConfigs = (values, toggle) => { values.REGISTRATION_ENABLED = "0" } - return axios.post(process.env.REACT_APP_API_URL + "config", values, { headers: getHeaders() }) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/config", values, { headers: getHeaders() }) .then((data) => { if (data.data && data.data.message) { toast.success(data.data.message); @@ -206,7 +206,7 @@ export const postConfigs = (values, toggle) => { }; export const getProfile = (userName) => { - return axios.get(process.env.REACT_APP_API_URL + "profile/" + userName, { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/profile/" + userName, { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -215,7 +215,7 @@ export const getProfile = (userName) => { }; export const getUser = () => { - return axios.get(process.env.REACT_APP_API_URL + "user", { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user", { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -224,7 +224,7 @@ export const getUser = () => { }; export const patchUser = (values) => { - return axios.patch(process.env.REACT_APP_API_URL + "user", values, { headers: getHeaders() }) + return axios.patch(process.env.REACT_APP_API_URL + "/api/v1/user", values, { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -233,7 +233,7 @@ export const patchUser = (values) => { }; export const validateResetPassword = (tokenStr) => { - return axios.get(process.env.REACT_APP_API_URL + "user/", { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/", { headers: getHeaders() }) .then((data) => { return data.data; }).catch((error) => { @@ -242,7 +242,7 @@ export const validateResetPassword = (tokenStr) => { }; export const getSpotifyClientId = () => { - return axios.get(process.env.REACT_APP_API_URL + "user/spotify", { headers: getHeaders() }) + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/spotify", { headers: getHeaders() }) .then((data) => { return data.data }).catch((error) => { @@ -272,7 +272,7 @@ export const spotifyConnectionRequest = () => { }; export const spotifyDisonnectionRequest = () => { - return axios.delete(process.env.REACT_APP_API_URL + "user/spotify", { headers: getHeaders() }) + return axios.delete(process.env.REACT_APP_API_URL + "/api/v1/user/spotify", { headers: getHeaders() }) .then((data) => { toast.success(data.data.message); return true @@ -282,7 +282,7 @@ export const spotifyDisonnectionRequest = () => { } export const navidromeConnectionRequest = (values) => { - return axios.post(process.env.REACT_APP_API_URL + "user/navidrome", values, { headers: getHeaders() }) + return axios.post(process.env.REACT_APP_API_URL + "/api/v1/user/navidrome", values, { headers: getHeaders() }) .then((data) => { toast.success(data.data.message); return true @@ -292,7 +292,7 @@ export const navidromeConnectionRequest = (values) => { }; export const navidromeDisonnectionRequest = () => { - return axios.delete(process.env.REACT_APP_API_URL + "user/navidrome", { headers: getHeaders() }) + return axios.delete(process.env.REACT_APP_API_URL + "/api/v1/user/navidrome", { headers: getHeaders() }) .then((data) => { toast.success(data.data.message); return true @@ -303,7 +303,7 @@ export const navidromeDisonnectionRequest = () => { export const getServerInfo = () => { - return axios.get(process.env.REACT_APP_API_URL + "serverinfo") + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/serverinfo") .then((data) => { return data.data }).catch((error) => { @@ -312,7 +312,7 @@ export const getServerInfo = () => { } export const getArtist = (uuid) => { - return axios.get(process.env.REACT_APP_API_URL + "artists/" + uuid).then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/artists/" + uuid).then( (data) => { return data.data; }).catch((error) => { @@ -321,7 +321,7 @@ export const getArtist = (uuid) => { }; export const getAlbum = (uuid) => { - return axios.get(process.env.REACT_APP_API_URL + "albums/" + uuid).then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/albums/" + uuid).then( (data) => { return data.data; }).catch((error) => { @@ -330,7 +330,7 @@ export const getAlbum = (uuid) => { }; export const getTrack = (uuid) => { - return axios.get(process.env.REACT_APP_API_URL + "tracks/" + uuid).then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/" + uuid).then( (data) => { return data.data; }).catch((error) => { @@ -339,7 +339,7 @@ export const getTrack = (uuid) => { }; export const getTopTracks = (uuid) => { - return axios.get(process.env.REACT_APP_API_URL + "tracks/top/" + uuid).then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/top/" + uuid).then( (data) => { return data.data; }).catch((error) => { @@ -348,7 +348,7 @@ export const getTopTracks = (uuid) => { } export const getTopArtists = (uuid) => { - return axios.get(process.env.REACT_APP_API_URL + "artists/top/" + uuid).then( + return axios.get(process.env.REACT_APP_API_URL + "/api/v1/artists/top/" + uuid).then( (data) => { return data.data; }).catch((error) => { diff --git a/web/src/Components/TopTable.js b/web/src/Components/TopTable.js index b44a2891..abc64f1a 100644 --- a/web/src/Components/TopTable.js +++ b/web/src/Components/TopTable.js @@ -21,7 +21,7 @@ const TopTable = (props) => { number="1" title={tracks[1].name} link={"/" + props.type + "/" + tracks[1].uuid} - img={tracks[1].img} + uuid={tracks[1].img} /> { Object.keys(props.items).length > 5 && @@ -31,28 +31,28 @@ const TopTable = (props) => { number="2" title={tracks[2].name} link={"/" + props.type + "/" + tracks[2].uuid} - img={tracks[2].img} + uuid={tracks[2].img} /> } @@ -63,63 +63,63 @@ const TopTable = (props) => { number="6" title={tracks[6].name} link={"/" + props.type + "/" + tracks[6].uuid} - img={tracks[6].img} + uuid={tracks[6].img} /> } diff --git a/web/src/Components/TopTableBox.js b/web/src/Components/TopTableBox.js index a2b7fb76..dab8ef80 100644 --- a/web/src/Components/TopTableBox.js +++ b/web/src/Components/TopTableBox.js @@ -3,17 +3,12 @@ import { Link } from 'react-router-dom'; import './TopTableBox.css' const TopTableBox = (props) => { - let img = 'https://www.foot.com/wp-content/uploads/2017/06/placeholder-square-300x300.jpg'; - if (props.img && props.img !== '') { - img = props.img - } - return (