- Clean up login/redirect flow
- Add redirect when not authed on other endpoints
- Add GET /stats endpoint for overal stats
This commit is contained in:
Daniel Mason 2021-03-30 15:02:04 +13:00
parent 5fd9d41069
commit 038823055a
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
23 changed files with 413 additions and 178 deletions

View File

@ -3,7 +3,7 @@ stages:
- bundle
variables:
VERSION: 0.0.2
VERSION: 0.0.3
build-go:
image: golang:1.16.2

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
}

2
web/.env.example Normal file
View File

@ -0,0 +1,2 @@
REACT_APP_API_URL=http://127.0.0.1:42069/api/v1/
REACT_APP_REGISTRATION_DISABLED=false

29
web/src/Actions/api.js Normal file
View File

@ -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();
}
);
};

View File

@ -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_FAIL,
});
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();
};

View File

@ -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

View File

@ -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() {

View File

View File

@ -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 (
<div className="container">
</div>
);
}
}
export default HomeBanner;

View File

@ -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 <div className="navLinkLogin">
<Link to="/profile" className="navLink">Profile</Link>
<Link
to="/profile"
style={this.state.active === "profile" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "profile")}
className="navLink"
>Profile</Link>
<Link to="/" className="navLink" onClick={logout()}>Logout</Link>
</div>;
} else {
return <div className="navLinkLogin">
<Link to="/login" className="navLink">Login</Link>
<Link to="/register" className="navLink" history={this.props.history}>Register</Link>
<Link
to="/login"
style={this.state.active === "login" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "login")}
className="navLink"
>Login</Link>
<Link
to="/register"
className="navLink"
style={this.state.active === "register" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "register")}
history={this.props.history}
>Register</Link>
</div>;
}
}
@ -89,7 +125,7 @@ class Navigation extends Component {
return (
<div>
<Navbar color="dark" dark fixed="top">
<NavbarBrand href="/" className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
<NavbarBrand className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
{renderMenuButtons()}
{renderAuthButtons()}
</Navbar>

View File

@ -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 (
<div className="pageWrapper">
<h1>
Hai Dashboard!
Dashboard!
</h1>
</div>
);

View File

@ -1,7 +1,10 @@
import logo from '../logo.png';
import '../App.css';
import HomeBanner from '../Components/HomeBanner';
import React from 'react';
function Home() {
class Home extends React.Component {
render() {
return (
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
@ -16,8 +19,10 @@ function Home() {
>
gitlab.com/idanoo/go-scrobble
</a>
<HomeBanner />
</div>
);
}
}
export default Home;

View File

@ -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
};
}

View File

@ -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()
}
}

View File

@ -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;
if (isLoggedIn) {
history.push("/dashboard")
}
}
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 (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,47 +54,46 @@ class Register extends React.Component {
Register
</h1>
<div className="loginBody">
<form onSubmit={this.handleSubmit}>
<Formik
initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }}
onSubmit={async values => this.handleRegister(values)}>
<Form>
<label>
Username*<br/>
<input
<Field
name="username"
type="text"
required={trueBool}
className="loginFields"
value={this.state.username}
onChange={this.handleUsernameChange}
/>
</label>
<br/>
<label>
Email<br/>
<input
<Field
name="email"
type="email"
className="loginFields"
value={this.state.email}
onChange={this.handleEmailChange}
/>
</label>
<br/>
<label>
Password*<br/>
<input
<Field
name="password"
type="password"
required={trueBool}
className="loginFields"
value={this.state.password}
onChange={this.handlePasswordChange}
/>
</label>
<br/>
<label>
Password*<br/>
<input
Confirm Password*<br/>
<Field
name="passwordconfirm"
type="password"
required={trueBool}
className="loginFields"
value={this.state.passwordconfirm}
onChange={this.handlePasswordConfirmChange}
/>
</label>
<br/><br/>
@ -148,7 +103,8 @@ class Register extends React.Component {
className="loginButton"
disabled={this.state.loading}
>{this.state.loading ? <ScaleLoader color="#FFF" size={35} /> : "Register"}</Button>
</form>
</Form>
</Formik>
</div>
</div>
}
@ -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);

View File

@ -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 {

View File

@ -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();

View File

@ -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 {};
}

View File

@ -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) {

View File

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