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 (
- GoScrobble
+ 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 (
@@ -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(