From 038823055a976e75c9d53e584c990f466b23f66f Mon Sep 17 00:00:00 2001 From: Daniel Mason Date: Tue, 30 Mar 2021 15:02:04 +1300 Subject: [PATCH] 0.0.3 - Clean up login/redirect flow - Add redirect when not authed on other endpoints - Add GET /stats endpoint for overal stats --- .gitlab-ci.yml | 2 +- README.md | 3 +- docs/changelog.md | 8 ++ internal/goscrobble/server.go | 14 +++ internal/goscrobble/stats.go | 94 ++++++++++++++ web/.env.example | 2 + web/src/Actions/api.js | 29 +++++ web/src/Actions/auth.js | 43 ++++--- web/src/Actions/eventBus.js | 13 ++ web/src/App.js | 6 - web/src/Components/HomeBanner.css | 0 web/src/Components/HomeBanner.js | 44 +++++++ web/src/Components/Navigation.js | 48 +++++++- web/src/Pages/Dashboard.js | 3 +- web/src/Pages/Home.js | 11 +- web/src/Pages/Login.js | 18 ++- web/src/Pages/Profile.js | 6 +- web/src/Pages/Register.js | 197 ++++++++++++------------------ web/src/Reducers/auth.js | 15 ++- web/src/Services/api.service.js | 13 ++ web/src/Services/auth-header.js | 6 +- web/src/Services/auth.service.js | 15 +-- web/src/index.js | 1 - 23 files changed, 413 insertions(+), 178 deletions(-) create mode 100644 internal/goscrobble/stats.go create mode 100644 web/.env.example create mode 100644 web/src/Actions/api.js create mode 100644 web/src/Actions/eventBus.js create mode 100644 web/src/Components/HomeBanner.css create mode 100644 web/src/Components/HomeBanner.js create mode 100644 web/src/Services/api.service.js diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c332231..bbfb430f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: - bundle variables: - VERSION: 0.0.2 + VERSION: 0.0.3 build-go: image: golang:1.16.2 diff --git a/README.md b/README.md index ca2f2a76..6fbca279 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Copy .env.example to .env and set variables. You can use https://www.grc.com/pas cp .env.example .env # Fill in the blanks go mod tidy CGO_ENABLED=0 go run cmd/go-scrobble/*.go - # In another terminal set web/.env.development + # In another terminal cp web/.env.example web/.env.development and set vars cd web && npm install && npm start --env development @@ -32,6 +32,7 @@ Access dev frontend @ http://127.0.0.1:3000 + API @ http://127.0.0.1:42069/api/v We need to build NPM package, and then ship web/build with the binary. cp .env.example .env # Fill in the blanks + cp web/.env.example web/.env.production cd web npm install --production && npm run build --env production go build -o goscrobble cmd/go-scrobble/*.go ./goscrobble diff --git a/docs/changelog.md b/docs/changelog.md index b60495cf..7a5cee20 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,11 @@ +# 0.0.4 +- Display stats on homepage + +# 0.0.3 +- Clean up login/redirect flow +- Add redirect when not authed on other endpoints +- Add GET /stats endpoint for overal stats + # 0.0.2 - Login flow working.. - Jellyfin scrobble working diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index fb8007ce..60d7ab4f 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -54,6 +54,7 @@ func HandleRequests(port string) { // No Auth v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") + v1.HandleFunc("/stats", handleStats).Methods("GET") // This just prevents it serving frontend stuff over /api r.PathPrefix("/api") @@ -233,6 +234,19 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { w.Write(data) } +// 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) +} + // serveEndpoint - API stuffs func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) { bodyJson, err := decodeJson(r.Body) diff --git a/internal/goscrobble/stats.go b/internal/goscrobble/stats.go new file mode 100644 index 00000000..94524ef7 --- /dev/null +++ b/internal/goscrobble/stats.go @@ -0,0 +1,94 @@ +package goscrobble + +import ( + "encoding/json" + "errors" + "log" + "time" +) + +type StatsRequest struct { + Users int `json:"users"` + Scrobbles int `json:"scrobbles"` + Tracks int `json:"songs"` + Artists int `json:"artists"` + LastUpdated time.Time `json:"last_updated"` +} + +func getStats() (StatsRequest, error) { + js := getRedisVal("stats") + statsReq := StatsRequest{} + var err error + if js != "" { + // If cached, deserialize and return + err = json.Unmarshal([]byte(js), &statsReq) + if err != nil { + log.Printf("Error unmarshalling stats json: %+v", err) + return statsReq, errors.New("Error fetching stats") + } + + // Check if older than 5 min - we want to update async for the next caller + now := time.Now() + if now.Sub(statsReq.LastUpdated) > time.Duration(5)*time.Minute { + go goFetchStats() + } + } else { + // If not cached, pull data then return + statsReq, err = fetchStats() + if err != nil { + log.Printf("Error fetching stats: %+v", err) + return statsReq, errors.New("Error fetching stats") + } + } + + return statsReq, nil +} + +// goFetchStats - Async call +func goFetchStats() { + _, _ = fetchStats() +} + +func fetchStats() (StatsRequest, error) { + statsReq := StatsRequest{} + var err error + + statsReq.Users, err = getDbCount("SELECT COUNT(*) FROM `users` WHERE `active` = 1") + if err != nil { + log.Printf("Failed to fetch user count: %+v", err) + return statsReq, errors.New("Failed to fetch stats") + } + + statsReq.Scrobbles, err = getDbCount("SELECT COUNT(*) FROM `scrobbles`") + if err != nil { + log.Printf("Failed to fetch scrobble count: %+v", err) + return statsReq, errors.New("Failed to fetch stats") + } + + statsReq.Tracks, err = getDbCount("SELECT COUNT(*) FROM `tracks`") + if err != nil { + log.Printf("Failed to fetch track count: %+v", err) + return statsReq, errors.New("Failed to fetch stats") + } + + statsReq.Artists, err = getDbCount("SELECT COUNT(*) FROM `artists`") + if err != nil { + log.Printf("Failed to fetch artist count: %+v", err) + return statsReq, errors.New("Failed to fetch stats") + } + + // Mark the time this was last updated + statsReq.LastUpdated = time.Now() + + b, err := json.Marshal(statsReq) + if err != nil { + return statsReq, errors.New("Failed to fetch stats") + } + + err = setRedisVal("stats", string(b)) + if err != nil { + return statsReq, errors.New("Failed to fetch stats") + } + + return statsReq, nil +} diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..cfed93ef --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +REACT_APP_API_URL=http://127.0.0.1:42069/api/v1/ +REACT_APP_REGISTRATION_DISABLED=false \ No newline at end of file diff --git a/web/src/Actions/api.js b/web/src/Actions/api.js new file mode 100644 index 00000000..572e9126 --- /dev/null +++ b/web/src/Actions/api.js @@ -0,0 +1,29 @@ +import { toast } from 'react-toastify'; +import ApiService from "../Services/api.service"; + +export const getStats = () => () => { + return ApiService.getStats().then( + (data) => { + console.log(data); + if (data.error) { + toast.error(data.error) + return Promise.reject(); + } + + return Promise.resolve(); + }, + (error) => { + const message = + (error.response && + error.response.data && + error.response.data.message) || + error.message || + error.toString(); + console.log(message); + + toast.error(message); + return Promise.reject(); + } + ); +}; + diff --git a/web/src/Actions/auth.js b/web/src/Actions/auth.js index d33e8c9c..606e31ee 100644 --- a/web/src/Actions/auth.js +++ b/web/src/Actions/auth.js @@ -3,20 +3,30 @@ import { REGISTER_FAIL, LOGIN_SUCCESS, LOGIN_FAIL, - SET_MESSAGE, } from "./types"; - import { toast } from 'react-toastify' + import { toast } from 'react-toastify'; + import jwt from 'jwt-decode' import AuthService from "../Services/auth.service"; export const register = (username, email, password) => (dispatch) => { return AuthService.register(username, email, password).then( - (response) => { + (data) => { + if (data.message) { + toast.success('Successfully registered. Please sign in'); + dispatch({ + type: REGISTER_SUCCESS, + }); + + return Promise.resolve(); + } + + toast.error(data.error ? data.error: 'An Unknown Error has occurred') dispatch({ - type: REGISTER_SUCCESS, + type: REGISTER_FAIL, }); - return Promise.resolve(); + return Promise.reject(); }, (error) => { const message = @@ -26,13 +36,10 @@ import { error.message || error.toString(); - dispatch({ - type: REGISTER_FAIL, - }); + toast.error(message); dispatch({ - type: SET_MESSAGE, - payload: message, + type: REGISTER_FAIL, }); return Promise.reject(); @@ -45,9 +52,11 @@ import { (data) => { if (data.token) { toast.success('Login Success'); + let user = jwt(data.token) + dispatch({ type: LOGIN_SUCCESS, - payload: { user: data }, + payload: { jwt: data.token, sub: user.sub, exp: user.exp }, }); return Promise.resolve(); } @@ -71,17 +80,17 @@ import { type: LOGIN_FAIL, }); - // dispatch({ - // type: SET_MESSAGE, - // payload: message, - // }); - return Promise.reject(); } ); }; - export const logout = () => () => { + export const logout = () => (dispatch) => { AuthService.logout(); + + // dispatch({ + // type: LOGOUT, + // }); + window.location.reload(); }; diff --git a/web/src/Actions/eventBus.js b/web/src/Actions/eventBus.js new file mode 100644 index 00000000..17bfcd26 --- /dev/null +++ b/web/src/Actions/eventBus.js @@ -0,0 +1,13 @@ +const eventBus = { + on(event, callback) { + document.addEventListener(event, (e) => callback(e.detail)); + }, + dispatch(event, data) { + document.dispatchEvent(new CustomEvent(event, { detail: data })); + }, + remove(event, callback) { + document.removeEventListener(event, callback); + }, +}; + +export default eventBus \ No newline at end of file diff --git a/web/src/App.js b/web/src/App.js index c31fdac6..23237477 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -11,8 +11,6 @@ import Register from './Pages/Register'; import Navigation from './Components/Navigation'; import { logout } from './Actions/auth'; -import { clearMessage } from './Actions/message'; -import { history } from './Helpers/history'; import { Route, Switch, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { Component } from 'react'; @@ -37,10 +35,6 @@ class App extends Component { // exact="true".. it has to be a bool :| true: true, }; - - history.listen((location) => { - props.dispatch(clearMessage()); // clear message when changing location - }); } componentDidMount() { diff --git a/web/src/Components/HomeBanner.css b/web/src/Components/HomeBanner.css new file mode 100644 index 00000000..e69de29b diff --git a/web/src/Components/HomeBanner.js b/web/src/Components/HomeBanner.js new file mode 100644 index 00000000..39f751df --- /dev/null +++ b/web/src/Components/HomeBanner.js @@ -0,0 +1,44 @@ +import React from 'react'; +import '../App.css'; +import './HomeBanner.css'; +import { getStats } from '../Actions/api'; + +class HomeBanner extends React.Component { + constructor(props) { + super(props); + this.state = { + isLoading: true, + userCount: 0, + scrobbleCount: 0, + trackCount: 0, + artistCount: 0, + }; + } + + componentDidMount() { + getStats() + // .then((data) => { + // this.setState({ + // loading: false, + // userCount: data.users, + // scrobbleCount: data.scrobbles, + // trackCount: data.tracks, + // artistCount: data.artists, + // }); + // }) + // .catch(() => { + // this.setState({ + // loading: false + // }); + // }); + } + + render() { + return ( +
+
+ ); + } +} + +export default HomeBanner; diff --git a/web/src/Components/Navigation.js b/web/src/Components/Navigation.js index 0514b4c5..8a3e6dce 100644 --- a/web/src/Components/Navigation.js +++ b/web/src/Components/Navigation.js @@ -5,6 +5,12 @@ import logo from '../logo.png'; import './Navigation.css'; import { connect } from 'react-redux'; import { logout } from '../Actions/auth'; +import eventBus from "../Actions/eventBus"; + +import { + LOGIN_SUCCESS, + LOGOUT, +} from "../Actions/types"; const menuItems = [ 'Home', @@ -24,32 +30,62 @@ class Navigation extends Component { } componentDidMount() { - const isLoggedIn = this.props.isLoggedIn; - + const { isLoggedIn } = this.props; if (isLoggedIn) { this.setState({ isLoggedIn: true, }); } + + eventBus.on(LOGIN_SUCCESS, () => + this.setState({ isLoggedIn: true }) + ); + + eventBus.on(LOGOUT, () => + this.setState({ isLoggedIn: false }) + ); } + componentWillUnmount() { + eventBus.remove(LOGIN_SUCCESS); + eventBus.remove(LOGOUT); + } + + _handleClick(menuItem) { this.setState({ active: menuItem }); } + render() { const activeStyle = { color: '#FFFFFF' }; const renderAuthButtons = () => { if (this.state.isLoggedIn) { return
- Profile + Profile Logout
; } else { return
- Login - Register + Login + Register
; } } @@ -89,7 +125,7 @@ class Navigation extends Component { return (
- logo GoScrobble + logo GoScrobble {renderMenuButtons()} {renderAuthButtons()} diff --git a/web/src/Pages/Dashboard.js b/web/src/Pages/Dashboard.js index 2b2ee512..b56fa434 100644 --- a/web/src/Pages/Dashboard.js +++ b/web/src/Pages/Dashboard.js @@ -10,7 +10,6 @@ class Dashboard extends React.Component { if (!isLoggedIn) { history.push("/login") - window.location.reload() } } @@ -18,7 +17,7 @@ class Dashboard extends React.Component { return (

- Hai Dashboard! + Dashboard!

); diff --git a/web/src/Pages/Home.js b/web/src/Pages/Home.js index 11b22fd8..1e1f3573 100644 --- a/web/src/Pages/Home.js +++ b/web/src/Pages/Home.js @@ -1,8 +1,11 @@ import logo from '../logo.png'; import '../App.css'; +import HomeBanner from '../Components/HomeBanner'; +import React from 'react'; -function Home() { - return ( +class Home extends React.Component { + render() { + return (
logo

@@ -16,8 +19,10 @@ function Home() { > gitlab.com/idanoo/go-scrobble +

- ); + ); + } } export default Home; diff --git a/web/src/Pages/Login.js b/web/src/Pages/Login.js index 274297c6..34e17791 100644 --- a/web/src/Pages/Login.js +++ b/web/src/Pages/Login.js @@ -6,6 +6,8 @@ import { Formik, Form, Field } from 'formik'; import ScaleLoader from 'react-spinners/ScaleLoader'; import { connect } from 'react-redux'; import { login } from '../Actions/auth'; +import eventBus from "../Actions/eventBus"; +import { LOGIN_SUCCESS } from '../Actions/types'; class Login extends React.Component { constructor(props) { @@ -13,6 +15,14 @@ class Login extends React.Component { this.state = {username: '', password: '', loading: false}; } + componentDidMount() { + const { history, isLoggedIn } = this.props; + + if (isLoggedIn) { + history.push("/dashboard") + } + } + handleLogin(values) { this.setState({loading: true}); @@ -22,9 +32,11 @@ class Login extends React.Component { .then(() => { this.setState({ loading: false, + isLoggedIn: true }); + + eventBus.dispatch(LOGIN_SUCCESS, { isLoggedIn: true }); history.push("/dashboard"); - window.location.reload(); }) .catch(() => { this.setState({ @@ -82,10 +94,8 @@ class Login extends React.Component { function mapStateToProps(state) { const { isLoggedIn } = state.auth; - const { message } = state.message; return { - isLoggedIn, - message + isLoggedIn }; } diff --git a/web/src/Pages/Profile.js b/web/src/Pages/Profile.js index 66901513..f87559b7 100644 --- a/web/src/Pages/Profile.js +++ b/web/src/Pages/Profile.js @@ -5,12 +5,10 @@ import { connect } from 'react-redux'; class Profile extends React.Component { componentDidMount() { - const { history } = this.props; - const isLoggedIn = this.props.isLoggedIn; - + const { history, isLoggedIn } = this.props; + if (!isLoggedIn) { history.push("/login") - window.location.reload() } } diff --git a/web/src/Pages/Register.js b/web/src/Pages/Register.js index f81a9454..026a1311 100644 --- a/web/src/Pages/Register.js +++ b/web/src/Pages/Register.js @@ -1,86 +1,42 @@ import React from 'react'; import '../App.css'; import './Login.css'; -import { Button } from 'reactstrap'; +import { Button, Form } from 'reactstrap'; import ScaleLoader from "react-spinners/ScaleLoader"; -import { withRouter } from 'react-router-dom' +import { register } from '../Actions/auth'; +import { Formik, Field } from 'formik'; +import { connect } from 'react-redux'; class Register extends React.Component { constructor(props) { super(props); this.state = {username: '', email: '', password: '', passwordconfirm: '', loading: false}; - this.handleUsernameChange = this.handleUsernameChange.bind(this); - this.handleEmailChange = this.handleEmailChange.bind(this); - this.handlePasswordChange = this.handlePasswordChange.bind(this); - this.handlePasswordConfirmChange = this.handlePasswordConfirmChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); } - handleUsernameChange(event) { - this.setState({username: event.target.value}); - } + componentDidMount() { + const { history, isLoggedIn } = this.props; - handleEmailChange(event) { - this.setState({email: event.target.value}); - } - - handlePasswordChange(event) { - this.setState({password: event.target.value}); - } - - handlePasswordConfirmChange(event) { - this.setState({passwordconfirm: event.target.value}); - } - - handleSubmit(event) { - event.preventDefault(); - - if (this.state.password !== this.state.passwordconfirm) { - this.props.addToast('Passwords do not match', { appearance: 'error' }); - return + if (isLoggedIn) { + history.push("/dashboard") } + } - // if (this.state.password.len < 8) { - // this.props.addToast('Password must be at least 8 characters', { appearance: 'error' }); - // return - // } - + handleRegister(values) { this.setState({loading: true}); - const requestOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - timeout: 5000, - body: JSON.stringify({ - username: this.state.username, - email: this.state.email, - password: this.state.password, - }) - }; - const apiUrl = process.env.REACT_APP_API_URL + '/api/v1/register'; - console.log(apiUrl); - fetch(apiUrl, requestOptions) - .then((response) => { - if (response.status === 429) { - this.props.addToast("Rate limited. Please try again soon", { appearance: 'error' }); - return "{}" - } else { - return response.json() - } + const { dispatch, history } = this.props; + + dispatch(register(values.username, values.email, values.password)) + .then(() => { + this.setState({ + loading: false, + }); + history.push("/login"); }) - .then((function(data) { - console.log(data); - if (data.error) { - this.props.addToast(data.error, { appearance: 'error' }); - } else if (data.message) { - this.props.addToast(data.message, { appearance: 'success' }); - this.props.history.push('/login') - } - this.setState({loading: false}); - }).bind(this)) .catch(() => { - this.props.addToast('Error submitting form. Please try again', { appearance: 'error' }); - this.setState({loading: false}); + this.setState({ + loading: false + }); }); } @@ -98,57 +54,57 @@ class Register extends React.Component { Register
-
- -
- -
- -
- -

- -
+ this.handleRegister(values)}> +
+ +
+ +
+ +
+ +

+ +
+
} @@ -157,4 +113,11 @@ class Register extends React.Component { } } -export default withRouter(Register); +function mapStateToProps(state) { + const { isLoggedIn } = state.auth; + return { + isLoggedIn + }; +} + +export default connect(mapStateToProps)(Register); diff --git a/web/src/Reducers/auth.js b/web/src/Reducers/auth.js index 68b6ff77..df75c1e6 100644 --- a/web/src/Reducers/auth.js +++ b/web/src/Reducers/auth.js @@ -6,11 +6,11 @@ import { LOGOUT, } from "../Actions/types"; - const jwt = localStorage.getItem("jwt"); + const user = JSON.parse(localStorage.getItem('user')); - const initialState = jwt - ? { isLoggedIn: true, jwt } - : { isLoggedIn: false, jwt }; + const initialState = user + ? { isLoggedIn: true, user: user } + : { isLoggedIn: false, user: null }; export default function authReducer(state = initialState, action) { const { type, payload } = action; @@ -30,13 +30,16 @@ import { return { ...state, isLoggedIn: true, - user: payload.user, + user: { + jwt: payload.jwt, + uuid: payload.sub, + exp: payload.exp, + } }; case LOGIN_FAIL: return { ...state, isLoggedIn: false, - user: null, }; case LOGOUT: return { diff --git a/web/src/Services/api.service.js b/web/src/Services/api.service.js new file mode 100644 index 00000000..35887fe0 --- /dev/null +++ b/web/src/Services/api.service.js @@ -0,0 +1,13 @@ +import axios from "axios"; + +class ApiService { + async getStats() { + return axios.get(process.env.REACT_APP_API_URL + "stats") + .then((response) => { + return response.data; + } + ); + } +} + +export default new ApiService(); \ No newline at end of file diff --git a/web/src/Services/auth-header.js b/web/src/Services/auth-header.js index 16eea59e..e2103afc 100644 --- a/web/src/Services/auth-header.js +++ b/web/src/Services/auth-header.js @@ -1,8 +1,8 @@ export default function authHeader() { - const token = JSON.parse(localStorage.getItem('jwt')); + const auth = localStorage.getItem('user'); - if (token) { - return { Authorization: 'Bearer ' + token }; + if (auth && auth.jwt) { + return { Authorization: 'Bearer ' + auth.jwt }; } else { return {}; } diff --git a/web/src/Services/auth.service.js b/web/src/Services/auth.service.js index 40321255..c0b1f3f7 100644 --- a/web/src/Services/auth.service.js +++ b/web/src/Services/auth.service.js @@ -7,10 +7,13 @@ class AuthService { .post(process.env.REACT_APP_API_URL + "login", { username, password }) .then((response) => { if (response.data.token) { - let user = jwt(response.data.token) - localStorage.setItem("jwt", response.data.token); - localStorage.setItem("uuid", user.sub); - localStorage.setItem("exp", user.exp); + let expandedUser = jwt(response.data.token) + let user = { + jwt: response.data.token, + uuid: expandedUser.sub, + exp: expandedUser.exp, + } + localStorage.setItem('user', JSON.stringify(user)) } return response.data; @@ -18,9 +21,7 @@ class AuthService { } logout() { - localStorage.removeItem("jwt"); - localStorage.removeItem("uuid"); - localStorage.removeItem("exp"); + localStorage.removeItem("user"); } register(username, email, password) { diff --git a/web/src/index.js b/web/src/index.js index 516e9ac7..03553c32 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -6,7 +6,6 @@ import { HashRouter } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.min.css' import { Provider } from 'react-redux'; - import store from "./store"; ReactDOM.render(