Merge branch 'dev' into main

Resolved #3
This commit is contained in:
2021-03-11 11:18:39 +11:00
48 changed files with 781 additions and 3300 deletions
+2 -13
View File
@@ -1,20 +1,9 @@
WEB_PROTOCOL=http
WEB_ADDRESS=localhost
WEB_PORT=3000 WEB_PORT=3000
MAIL_SMTP=smtp.example.com DB_HOSTNAME=database
MAIL_USERNAME=foobar@example.com
MAIL_PASSWORD=foobar
MAIL_PHYSICAL=42 Placeholder Ave, Placeholder, 0000, USA
DB_HOSTNAME=127.0.0.1
DB_DATABASE=template DB_DATABASE=template
DB_USERNAME=template DB_USERNAME=template
DB_PASSWORD=pikachu DB_PASSWORD=pikachu
DB_TIMEZONE=Australia/Sydney DB_TIMEZONE=Australia/Sydney
CHAT_URI=http://example.com:3200/chat SECRET_ACCESS=access
CHAT_KEY=chattychattybangbang
SESSION_SECRET=secret
SESSION_ADMIN=adminsecret
+2
View File
@@ -1,3 +1,5 @@
//TODO: update this README
# MERN-template # MERN-template
A website template using the MERN stack. A website template using the MERN stack.
+3 -4
View File
@@ -1,16 +1,15 @@
//polyfills //polyfills
import 'core-js/stable';
import 'regenerator-runtime/runtime'; import 'regenerator-runtime/runtime';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { CookiesProvider } from 'react-cookie';
import App from './components/app'; import App from './components/app';
import TokenProvider from './components/utilities/token-provider';
ReactDOM.render( ReactDOM.render(
<CookiesProvider> <TokenProvider>
<App /> <App />
</CookiesProvider>, </TokenProvider>,
document.querySelector('#root') document.querySelector('#root')
); );
+1 -21
View File
@@ -1,7 +1,6 @@
//react //react
import React, { useState } from 'react'; import React from 'react';
import { BrowserRouter, Switch } from 'react-router-dom'; import { BrowserRouter, Switch } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//library components //library components
import LazyRoute from './lazy-route'; import LazyRoute from './lazy-route';
@@ -15,24 +14,6 @@ import Header from './panels/header.jsx';
import Footer from './panels/footer.jsx'; import Footer from './panels/footer.jsx';
const App = props => { const App = props => {
//handle cookies prompt
const [cookies, setCookie] = useCookies();
if (!cookies['accept-cookies']) {
const accept = confirm('This website uses cookies to operate correctly. By clicking "ok", you agree to accept said cookies.');
if (accept) {
setCookie('accept-cookies', true);
} else {
return (
<div>
<p>This website won't operate correctly without cookies.</p>
<button onClick={() => window.location.reload()}>Reload Page</button>
</div>
);
}
}
//default render //default render
return ( return (
<BrowserRouter> <BrowserRouter>
@@ -43,7 +24,6 @@ const App = props => {
<LazyRoute path='/signup' component={() => import('./pages/signup')} /> <LazyRoute path='/signup' component={() => import('./pages/signup')} />
<LazyRoute path='/login' component={() => import('./pages/login')} /> <LazyRoute path='/login' component={() => import('./pages/login')} />
<LazyRoute path='/account' component={() => import('./pages/account')} /> <LazyRoute path='/account' component={() => import('./pages/account')} />
<LazyRoute path='/chat' component={() => import('./pages/chat')} />
<LazyRoute path='/admin' component={() => import('./pages/admin')} /> <LazyRoute path='/admin' component={() => import('./pages/admin')} />
+51 -33
View File
@@ -1,53 +1,66 @@
import React, { useEffect } from 'react'; import React, { useEffect, useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
import { TokenContext } from '../utilities/token-provider';
import DeleteAccount from '../panels/delete-account'; import DeleteAccount from '../panels/delete-account';
const Account = props => { const Account = props => {
const [cookies, setCookie] = useCookies(); //context
const authTokens = useContext(TokenContext);
//check for logged in redirect //misplaced?
if (!cookies['loggedin']) { if (!authTokens.accessToken) {
return <Redirect to='/' />; return <Redirect to='/' />;
} }
//refs //refs
let contactElement, passwordElement, retypeElement; const passwordRef = useRef();
const retypeRef = useRef();
const contactRef = useRef();
//once before render //grab the user's info
useEffect(() => { useEffect(() => {
fetch('/api/accounts') authTokens.tokenFetch(`${process.env.AUTH_URI}/account`, {
method: 'GET',
headers: {
'Access-Control-Allow-Origin': '*'
}
})
.then(blob => blob.json()) .then(blob => blob.json())
.then(json => { .then(json => contactRef.current.checked = json.contact)
contactElement.checked = json.contact;
})
.catch(e => console.error(e)) .catch(e => console.error(e))
; ;
}, []); }, []);
//render the thing
return ( return (
<div className='page'> <div className='page'>
<h1 className='centered'>Account</h1> <h1 className='centered'>Account</h1>
<form className='constricted' onSubmit={async evt => { <form className='constricted' onSubmit={async evt => {
evt.preventDefault(); evt.preventDefault();
await update(contactElement.checked, passwordElement.value, retypeElement.value); const [err, result] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch);
passwordElement.value = retypeElement.value = '';
if (err) {
alert(err);
return;
}
passwordRef.current.value = retypeRef.current.value = '';
}}> }}>
<div> <div>
<div>
<label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={e => contactElement = e} />
</div>
<div> <div>
<label htmlFor='password'>Change Password:</label> <label htmlFor='password'>Change Password:</label>
<input type='password' name='password' ref={e => passwordElement = e} /> <input type='password' name='password' ref={passwordRef} />
</div> </div>
<div> <div>
<label htmlFor='retype'>Retype Password:</label> <label htmlFor='retype'>Retype Password:</label>
<input type='password' name='retype' ref={e => retypeElement = e} /> <input type='password' name='retype' ref={retypeRef} />
</div>
<div>
<label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={contactRef} />
</div> </div>
</div> </div>
@@ -59,26 +72,31 @@ const Account = props => {
); );
}; };
const update = async (contact, password, retype) => { const update = async (password, retype, contact, tokenFetch) => {
if (password != retype) { if (password != retype) {
alert('Passwords do not match'); return ['Passwords do not match'];
} }
//generate a new formdata payload if (password && password.length < 8) {
let formData = new FormData(); return ['Password is too short'];
formData.append('contact', contact);
if (password) {
formData.append('password', password);
} }
const result = await fetch('/api/accounts', { method: 'PATCH', body: formData }); const result = await tokenFetch(`${process.env.AUTH_URI}/update`, {
method: 'PATCH',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: password ? password : null,
contact
})
});
if (result.ok) { if (!result.ok) {
alert(await result.text()); return [`${await result.status}: ${await result.text()}`];
} else { } else {
alert(await result.text()); return [null];
} }
} }
+9 -8
View File
@@ -1,24 +1,25 @@
import React from 'react'; import React, { useContext } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//import BannedEmails from '../panels/banned-emails'; import { TokenContext } from '../utilities/token-provider';
import NewsPublisher from '../panels/news-publisher'; import NewsPublisher from '../panels/news-publisher';
import NewsEditor from '../panels/news-editor'; import NewsEditor from '../panels/news-editor';
const Admin = props => { const Admin = props => {
const [cookies, setCookie] = useCookies(); //context
const authTokens = useContext(TokenContext);
//check for logged in redirect //misplaced? (admin only)
if (!cookies['admin']) { if (!authTokens.accessToken || !authTokens.getPayload().privilege == 'administrator') {
return <Redirect to='/' />; return <Redirect to='/' />;
} }
return ( return (
<div className='page'> <div className='page'>
<h1 className='centered'>Administration</h1> <h1 className='centered'>Administration</h1>
<NewsPublisher uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} /> <NewsPublisher />
<NewsEditor uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} /> <NewsEditor />
</div> </div>
); );
}; };
-23
View File
@@ -1,23 +0,0 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
import Chat from '../panels/chat';
//Temporary chat page
const ChatPage = props => {
const [cookies, setCookie] = useCookies();
//check for logged in redirect
if (!cookies['loggedin']) {
return <Redirect to='/' />;
}
return (
<div className='page'>
<Chat uri={process.env.CHAT_URI} />
</div>
);
};
export default ChatPage;
+47 -33
View File
@@ -1,46 +1,50 @@
import React from 'react'; import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//utilities import { TokenContext } from '../utilities/token-provider';
const validateEmail = require('../../../common/utilities/validate-email.js');
const LogIn = props => { const LogIn = props => {
const [cookies, setCookie] = useCookies(); //context
const authTokens = useContext(TokenContext);
//check for logged in redirect //misplaced?
if (cookies['loggedin']) { if (authTokens.accessToken) {
return <Redirect to='/' />; return <Redirect to='/' />;
} }
//refs //refs
let emailElement, passwordElement; const emailRef = useRef();
const passwordRef = useRef();
return ( return (
<div className='page'> <div className='page'>
<h1 className='centered'>Login</h1> <h1 className='centered'>Login</h1>
<form className='constricted' onSubmit={ <form className='constricted' onSubmit={
evt => { async evt => {
//on submit
evt.preventDefault(); evt.preventDefault();
handleSubmit(emailElement.value, passwordElement.value) const [err, newTokens] = await handleSubmit(emailRef.current.value, passwordRef.current.value);
.then(([res, ok]) => { if (err) {
alert(res); alert(err);
if (ok) { }
window.location.reload(true); //BUFGIX: force reload of the header element
} //save auth tokens and redirect
}) if (newTokens) {
.catch(e => console.error(e)) authTokens.setAccessToken(newTokens.accessToken);
; authTokens.setRefreshToken(newTokens.refreshToken);
props.history.push('/');
}
} }
}> }>
<div> <div>
<label htmlFor="email">Email:</label> <label htmlFor="email">Email:</label>
<input type="email" name="email" ref={e => emailElement = e} /> <input type="email" name="email" ref={emailRef} />
</div> </div>
<div> <div>
<label htmlFor="password">Password:</label> <label htmlFor="password">Password:</label>
<input type="password" name="password" ref={e => passwordElement = e} /> <input type="password" name="password" ref={passwordRef} />
</div> </div>
<button type='submit'>Login</button> <button type='submit'>Login</button>
@@ -49,23 +53,33 @@ const LogIn = props => {
); );
}; };
//DOCS: returns two values: response and OK //DOCS: returns two values: err and authTokens
const handleSubmit = async (email, password) => { const handleSubmit = async (email, password) => {
email = email.trim(); email = email.trim(); //TODO: validate email on login
//generate a new formdata payload //send to the auth server
let formData = new FormData(); const result = await fetch(`${process.env.AUTH_URI}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
email,
password,
})
});
formData.append('email', email); //handle errors
formData.append('password', password); if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
const result = await fetch('/api/accounts/login', { method: 'POST', body: formData }); console.error(err);
return [err, false];
if (result.ok) {
return [await result.text(), true];
} else {
return [await result.text(), false];
} }
//return the new auth tokens
const newTokens = await result.json();
return [null, newTokens];
}; };
export default LogIn; export default LogIn;
+1 -1
View File
@@ -3,7 +3,7 @@ import React from 'react';
const NotFound = props => { const NotFound = props => {
return ( return (
<div className='page'> <div className='page'>
<h1 className='middle centered'>Not Found</h1> <h1 className='middle centered'>Page Not Found</h1>
</div> </div>
); );
}; };
+47 -32
View File
@@ -1,60 +1,68 @@
import React from 'react'; import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
import { TokenContext } from '../utilities/token-provider';
//utilities //utilities
const validateEmail = require('../../../common/utilities/validate-email.js'); const validateEmail = require('../../../common/utilities/validate-email.js');
const validateUsername = require('../../../common/utilities/validate-username.js'); const validateUsername = require('../../../common/utilities/validate-username.js');
const SignUp = props => { const SignUp = props => {
const [cookies, setCookie] = useCookies(); //context
const authTokens = useContext(TokenContext);
//check for logged in redirect //misplaced?
if (cookies['loggedin']) { if (authTokens.accessToken) {
return <Redirect to='/' />; return <Redirect to='/' />;
} }
//refs //refs
let emailElement, usernameElement, passwordElement, retypeElement, contactElement; const emailRef = useRef();
const usernameRef = useRef();
const passwordRef = useRef();
const retypeRef = useRef();
const contactRef = useRef();
return ( return (
<div className='page'> <div className='page'>
<h1 className='centered'>Signup</h1> <h1 className='centered'>Signup</h1>
<form className='constricted' onSubmit={ <form className='constricted' onSubmit={
evt => { async evt => { //on submit
evt.preventDefault(); evt.preventDefault();
handleSubmit(emailElement.value, usernameElement.value, passwordElement.value, retypeElement.value, contactElement.checked) const [result, redirect] = await handleSubmit(emailRef.current.value, usernameRef.current.value, passwordRef.current.value, retypeRef.current.value, contactRef.current.checked);
.then(res => res ? alert(res) : null) if (result) {
.then(() => emailElement.value = usernameElement.value = passwordElement.value = retypeElement.value = '') //clear input alert(result);
.then(() => contactElement.checked = false) }
.then(() => props.history.push('/'))
.catch(e => console.error(e)) //redirect
; if (redirect) {
props.history.push('/');
}
} }
}> }>
<div> <div>
<label htmlFor='email'>Email:</label> <label htmlFor='email'>Email:</label>
<input type='email' name='email' ref={e => emailElement = e} /> <input type='email' name='email' ref={emailRef} />
</div> </div>
<div> <div>
<label htmlFor='username'>Username:</label> <label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={e => usernameElement = e} /> <input type='text' name='username' ref={usernameRef} />
</div> </div>
<div> <div>
<label htmlFor='password'>Password:</label> <label htmlFor='password'>Password:</label>
<input type='password' name='password' ref={e => passwordElement = e} /> <input type='password' name='password' ref={passwordRef} />
</div> </div>
<div> <div>
<label htmlFor='retype'>Retype Password:</label> <label htmlFor='retype'>Retype Password:</label>
<input type='password' name='retype' ref={e => retypeElement = e} /> <input type='password' name='retype' ref={retypeRef} />
</div> </div>
<div> <div>
<label htmlFor='contact'>Allow Promotional Emails:</label> <label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={e => contactElement = e} /> <input type='checkbox' name='contact' ref={contactRef} />
</div> </div>
<button type='submit'>Signup</button> <button type='submit'>Signup</button>
@@ -73,21 +81,28 @@ const handleSubmit = async (email, username, password, retype, contact) => {
return err; return err;
} }
//generate a new formdata payload //send to the auth server
let formData = new FormData(); const result = await fetch(`${process.env.AUTH_URI}/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
email,
username,
password,
contact
})
});
formData.append('email', email); if (!result.ok) {
formData.append('username', username); const err = `${result.status}: ${await result.text()}`;
formData.append('password', password); console.error(err);
formData.append('contact', contact) return [err, false];
const result = await fetch('/api/accounts/signup', { method: 'POST', body: formData });
if (result.ok) {
return result.text();
} else {
return result.text();
} }
return [await result.text(), true];
}; };
//returns an error message, or null on success //returns an error message, or null on success
-108
View File
@@ -1,108 +0,0 @@
import React, { useState, useEffect } from 'react';
const BannedEmails = props => {
const [data, setData] = useState(null);
let usernameElement, emailElement, expiryElement, reasonElement;
let unbanElement;
fetch('/api/admin/banned', { method: 'GET' })
.then(banned => banned.json())
.then(banned => !data ? setData(banned) : null)
.catch(e => console.error(e))
;
return (
<div>
<h2>Banned Accounts</h2>
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Privilege</th>
<th>Expiry</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{(data || []).map((entry, index) =>
<tr key={index}>
<td>{entry.username}</td>
<td>{entry.email}</td>
<td>{entry.privilege}</td>
<td>{entry.expiry ? (new Date(entry.expiry)).toISOString() : null}</td>
<td>{entry.reason}</td>
</tr>
)}
</tbody>
</table>
<h2>Ban</h2>
<form onSubmit={async e => { e.preventDefault(); await handleBan(usernameElement.value, emailElement.value, expiryElement.value, reasonElement.value); }}>
<div>
<label htmlFor='username'>Username: </label>
<input type='text' name='username' ref={e => usernameElement = e} />
</div>
<div>
<label htmlFor='email'>Email: </label>
<input type='email' name='email' ref={e => emailElement = e} />
</div>
<div>
<label htmlFor='expiry'>Expiry: </label>
<input type='date' name='expiry' ref={e => expiryElement = e} />
</div>
<div>
<label htmlFor='reason'>Reason: </label>
<textarea rows='4' cols='50' name='reason' ref={e => reasonElement = e} />
</div>
<button type='submit'>Drop The Banhammer</button>
</form>
<h2>Unban</h2>
<form onSubmit={async e => { e.preventDefault(); await handleUnban(unbanElement.value); }}>
<div>
<label htmlFor='entry'>Unban User: </label>
<input type='text' name='entry' ref={e => unbanElement = e} />
</div>
<button type='submit'>Release From Horny Jail</button>
</form>
</div>
);
};
const handleBan = async (username, email, expiry, reason) => {
username = username.trim();
email = email.trim();
reason = reason.trim();
//generate a new formdata payload
let formData = new FormData();
formData.append('username', username);
formData.append('email', email);
formData.append('expiry', expiry);
formData.append('reason', reason);
const result = await fetch('/api/admin/ban', { method: 'POST', body: formData });
alert(await result.text());
};
const handleUnban = async (entry) => {
entry = entry.trim();
let formData = new FormData();
formData.append('entry', entry);
const result = await fetch('/api/admin/unban', { method: 'POST', body: formData });
alert(await result.text());
};
export default BannedEmails;
-32
View File
@@ -1,32 +0,0 @@
import React from 'react';
import { useCookies } from 'react-cookie';
const Chat = props => {
requestPseudonym();
return (
<div className='chat'>
<p>Chat URI: {props.uri}</p>
<p>Chat Paragraph TODO</p>
</div>
);
};
const requestPseudonym = () => {
const [cookies, setCookie] = useCookies();
//if your username hasn't been reserved
if (!cookies['pseudonym']) {
fetch('/api/chat/reserve', { method: 'POST' })
.then(msg => msg.json())
.then(json => {
if (!json.ok) { //I don't like doing this
console.error(json.error);
}
})
.catch(e => console.error(e))
;
}
};
export default Chat;
+45 -23
View File
@@ -1,51 +1,73 @@
import React, { useState } from 'react'; import React, { useState, useContext, useRef } from 'react';
import { TokenContext } from '../utilities/token-provider';
//DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed //DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed
const DeleteAccount = props => { const DeleteAccount = props => {
const authTokens = useContext(TokenContext);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const passwordRef = useRef();
if (!open) { if (!open) {
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button> return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button>
} }
let passwordElement;
return ( return (
<form className={props.className} onSubmit={async evt => { <form className={props.className} onSubmit={async evt => {
evt.preventDefault(); evt.preventDefault();
const password = passwordElement.value; const [err] = await handleSubmit(passwordRef.current.value, authTokens);
passwordElement.value = ''; if (err) {
await handleSubmit(password); alert(err);
}
}}> }}>
<div> <div>
<label htmlFor="password">Password:</label> <label htmlFor="password">Password:</label>
<input type="password" name="password" ref={e => passwordElement = e} /> <input type="password" name="password" ref={passwordRef} />
</div> </div>
<button type='submit'>Delete Account</button> <button type='submit'>Delete Account</button>
<button type='cancel' onClick={() => { passwordElement.value = ''; setOpen(false); }}>Cancel</button> <button type='cancel' onClick={() => { passwordRef.current.value = ''; setOpen(false); }}>Cancel</button>
</form> </form>
); );
}; };
const handleSubmit = async (password) => { const handleSubmit = async (password, authTokens) => {
//generate a new formdata payload //schedule a deletion
let formData = new FormData(); const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/deletion`, {
method: 'DELETE',
formData.append('password', password); headers: {
'Access-Control-Allow-Origin': '*',
const result = await fetch('/api/accounts/deletion', { method: 'DELETE', body: formData }); 'Content-Type': 'application/json'
},
body: JSON.stringify({
password
})
});
if (!result.ok) { if (!result.ok) {
alert(await result.text()); return [`${await result.status}: ${await result.text()}`];
} else {
//force logout
fetch('/api/accounts/logout', { method: 'POST' })
.then(alert(await result.text()))
.then(() => window.location.reload(true)) //BUFGIX: force reload of the header element
.catch(e => console.error(e))
;
} }
//force a logout
const result2 = await authTokens.tokenFetch(`${process.env.AUTH_URI}/logout`, {
method: 'DELETE',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: authTokens.refreshToken
})
});
if (!result2.ok) {
return [`${await result2.status}: ${await result2.text()}`];
}
authTokens.setAccessToken('');
authTokens.setRefreshToken('');
return [null];
}; };
export default DeleteAccount; export default DeleteAccount;
+33 -24
View File
@@ -1,48 +1,57 @@
import React from 'react'; import React, { useContext } from 'react';
import { useCookies } from 'react-cookie'; import { Link } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
const Visitor = () => { const Visitor = () => {
return ( return (
<div> <div>
<a href='/signup'>Sign Up</a> <Link to='/signup'>Sign Up</Link>
<em> - </em> <em> - </em>
<a href='/login'>Log In</a> <Link to='/login'>Log In</Link>
</div> </div>
); );
}; };
const Member = () => { const Member = () => {
const authTokens = useContext(TokenContext);
return ( return (
<div> <div>
<a href='/account'>Account</a> <Link to='/account'>Account</Link>
<em> - </em> <em> - </em>
<a href='/' onClick={logout}>Log out</a> { /* Logout button logs you out of the server too */ }
<Link to='/' onClick={async () => {
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/logout`, { //NOTE: this gets overwritten as a bugfix
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
token: authTokens.refreshToken
})
});
//any problems?
if (!result.ok) {
console.error(await result.text());
} else {
authTokens.setAccessToken('');
authTokens.setRefreshToken('');
}
}}>Log out</Link>
</div> </div>
); );
}; };
const logout = async () => {
await fetch('/api/accounts/logout', { method: 'POST' })
.catch(e => console.error(e))
;
};
const Header = () => { const Header = () => {
const [cookies, setCookie] = useCookies(['loggedin']); const authTokens = useContext(TokenContext);
let Options;
//check for logged in/out status
if (cookies['loggedin']) {
Options = Member;
} else {
Options = Visitor;
}
return ( return (
<header> <header>
<h1><a href='/'>MERN Template</a></h1> <h1><Link to='/'>MERN Template</Link></h1>
<Options /> { authTokens.accessToken ? <Member /> : <Visitor /> }
</header> </header>
); );
}; };
+80 -68
View File
@@ -1,31 +1,39 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useContext, useRef } from 'react';
import Select from 'react-dropdown-select'; import Select from 'react-dropdown-select';
//DOCS: props.uri is the address of a live news-server import { TokenContext } from '../utilities/token-provider';
//DOCS: props.newsKey is the key of the live news-server
const NewsEditor = props => { const NewsEditor = props => {
let titleElement, authorElement, bodyElement; //context
const [articles, setArticles] = useState(null); const authTokens = useContext(TokenContext);
//refs
const titleRef = useRef();
const authorRef = useRef();
const bodyRef = useRef();
//state
const [articles, setArticles] = useState([]);
const [index, setIndex] = useState(null); const [index, setIndex] = useState(null);
if (!articles) { //run once
fetch(`${props.uri}/titles?limit=999`, { useEffect(async () => {
const result = await fetch(`${process.env.NEWS_URI}/metadata?limit=999`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
}, },
}) });
.then(a => {
if (!a.ok) { if (!result.ok) {
throw `Network error ${a.status}: ${a.statusText} ${a.url}`; const err = `${result.status}: ${await result.text()}`;
} console.log(err);
return a.json(); alert(err);
}) } else {
.then(a => setArticles(a)) setArticles(await result.json());
.catch(e => console.error(e)) }
; }, []);
}
return ( return (
<div> <div>
@@ -33,28 +41,56 @@ const NewsEditor = props => {
<div> <div>
<label htmlFor='article'>Article: </label> <label htmlFor='article'>Article: </label>
<Select <Select
options={(articles || []).map(article => { return { label: article.title, value: article.index }; })} options={(articles).map(article => { return { label: article.title, value: article.index }; })}
onChange={values => setIndex(fetchSelection(values[0].value, titleElement, authorElement, bodyElement, props.uri))} onChange={async values => {
//fetch this article
const index = values[0].value;
const result = await fetch(`${process.env.NEWS_URI}/archive/${index}`, {
headers: {
'Access-Control-Allow-Origin': '*'
}
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
const article = await result.json();
titleRef.current.value = article.title;
authorRef.current.value = article.author;
bodyRef.current.value = article.body;
setIndex(index);
}
}}
/> />
</div> </div>
<form onSubmit={async e => {
e.preventDefault(); <form onSubmit={async evt => {
await handleSubmit(index, titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey); //onSubmit
titleElement.value = authorElement.value = bodyElement.value = ''; evt.preventDefault();
const [err] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, index, authTokens.tokenFetch);
if (err) {
alert(err);
} else {
titleRef.current.value = authorRef.current.value = bodyRef.current.value = '';
alert(`Edited as article index ${index}`);
}
}}> }}>
<div> <div>
<label htmlFor='title'>Title: </label> <label htmlFor='title'>Title: </label>
<input type='text' name='title' ref={ e => titleElement = e } /> <input type='text' name='title' ref={titleRef} />
</div> </div>
<div> <div>
<label htmlFor='author'>Author: </label> <label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={ e => authorElement = e } /> <input type='text' name='author' ref={authorRef} />
</div> </div>
<div> <div>
<label htmlFor='body'>Body: </label> <label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } /> <textarea name='body' rows='10' cols='150' ref={bodyRef} />
</div> </div>
<button type='submit'>Update</button> <button type='submit'>Update</button>
@@ -63,54 +99,30 @@ const NewsEditor = props => {
); );
}; };
const fetchSelection = (index, titleElement, authorElement, bodyElement, uri) => { const handleSubmit = async (title, author, body, index, tokenFetch) => {
fetch(`${uri}/archive/${index}`, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
})
.then(blob => blob.json())
.then(article => {
titleElement.value = article.title;
authorElement.value = article.author;
bodyElement.value = article.body;
})
.catch(e => console.error(e))
;
return index; //this is admittedly odd
};
const handleSubmit = async (index, title, author, body, uri, newsKey) => {
title = title.trim(); title = title.trim();
author = author.trim(); author = author.trim();
body = body.trim(); body = body.trim();
uri = uri.trim();
newsKey = newsKey.trim();
//fetch POST json data //fetch POST json data
const raw = await fetch( const result = await tokenFetch(`${process.env.NEWS_URI}/${index}`, {
`${uri}/${index}`, method: 'PATCH',
{ headers: {
method: 'PATCH', 'Content-Type': 'application/json',
headers: { 'Access-Control-Allow-Origin': '*'
'Content-Type': 'application/json', },
'Access-Control-Allow-Origin': '*' body: JSON.stringify({
}, title,
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey }) author,
} body
); })
});
if (raw.ok) { if (!result.ok) {
const result = await raw.json(); return [`${result.status}: ${await result.text()}`];
if (result.ok) {
alert(`Updated article index ${index}`);
} else {
alert(result.error);
}
} else {
alert(raw.statusText);
} }
return [null];
}; };
export default NewsEditor; export default NewsEditor;
+1 -6
View File
@@ -13,12 +13,7 @@ const NewsFeed = props => {
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
}, },
}) })
.then(a => { .then(blob => blob.json())
if (!a.ok) {
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
}
return a.json();
})
.then(a => setArticles(a)) .then(a => setArticles(a))
.catch(e => console.error(e)) .catch(e => console.error(e))
; ;
+37 -27
View File
@@ -1,31 +1,43 @@
import React from 'react'; import React, { useContext, useRef } from 'react';
import { TokenContext } from '../utilities/token-provider';
//DOCS: props.uri is the address of a live news-server
//DOCS: props.newsKey is the key of the live news-server
const NewsPublisher = props => { const NewsPublisher = props => {
let titleElement, authorElement, bodyElement; //context
const authTokens = useContext(TokenContext);
//refs
const titleRef = useRef();
const authorRef = useRef();
const bodyRef = useRef();
return ( return (
<div> <div>
<h2 className='centered'>News Publisher</h2> <h2 className='centered'>News Publisher</h2>
<form onSubmit={async e => { <form onSubmit={async evt => {
e.preventDefault(); //on submit
await handleSubmit(titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey); evt.preventDefault();
titleElement.value = authorElement.value = bodyElement.value = ''; const [err, index] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, authTokens.tokenFetch);
if (err) {
alert(err);
} else {
titleRef.current.value = authorRef.current.value = bodyRef.current.value = '';
alert(`Published as article index ${index}`);
}
}}> }}>
<div> <div>
<label htmlFor='title'>Title: </label> <label htmlFor='title'>Title: </label>
<input type='text' name='title' ref={ e => titleElement = e } /> <input type='text' name='title' ref={titleRef} />
</div> </div>
<div> <div>
<label htmlFor='author'>Author: </label> <label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={ e => authorElement = e } /> <input type='text' name='author' ref={authorRef} />
</div> </div>
<div> <div>
<label htmlFor='body'>Body: </label> <label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } /> <textarea name='body' rows='10' cols='150' ref={bodyRef} />
</div> </div>
<button type='submit'>Publish</button> <button type='submit'>Publish</button>
@@ -34,37 +46,35 @@ const NewsPublisher = props => {
); );
}; };
const handleSubmit = async (title, author, body, uri, newsKey) => { const handleSubmit = async (title, author, body, tokenFetch) => {
title = title.trim(); title = title.trim();
author = author.trim(); author = author.trim();
body = body.trim(); body = body.trim();
uri = uri.trim();
newsKey = newsKey.trim();
//fetch POST json data //fetch POST json data
const raw = await fetch( const result = await tokenFetch(
uri, `${process.env.NEWS_URI}`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
}, },
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey }) body: JSON.stringify({
title,
author,
body
})
} }
); );
if (raw.ok) { if (!result.ok) {
const result = await raw.json(); return [`${result.status}: ${await result.text()}`];
if (result.ok) {
alert(`Published article index ${result.index}`);
} else {
alert(result.error);
}
} else {
alert(raw.statusText);
} }
const json = await result.json();
return [null, json.index];
}; };
export default NewsPublisher; export default NewsPublisher;
@@ -0,0 +1,87 @@
import React, { useState, useEffect, createContext } from 'react';
import decode from 'jwt-decode';
export const TokenContext = createContext();
const TokenProvider = props => {
const [accessToken, setAccessToken] = useState('');
const [refreshToken, setRefreshToken] = useState('');
//make the access and refresh tokens persist between reloads
useEffect(() => {
setAccessToken(localStorage.getItem("accessToken") || '');
setRefreshToken(localStorage.getItem("refreshToken") || '');
}, [])
useEffect(() => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
}, [accessToken, refreshToken]);
//wrap the default fetch function
const tokenFetch = async (url, options) => {
//use this?
let bearer = accessToken;
//if expired (10 minutes, normally)
const expired = new Date(decode(accessToken).exp * 1000) < Date.now();
if (expired) {
//ping the auth server for a new token
const response = await fetch(`${process.env.AUTH_URI}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
token: refreshToken
})
});
//any errors, throw them
if (!response.ok) {
throw `${response.status}: ${await response.text()}`;
}
//save the new auth stuff (setting bearer as well)
const newAuth = await response.json();
setAccessToken(newAuth.accessToken);
setRefreshToken(newAuth.refreshToken);
bearer = newAuth.accessToken;
//BUGFIX: logging out correctly requires the new refresh token
if (url == `${process.env.AUTH_URI}/logout`) {
return fetch(`${process.env.AUTH_URI}/logout`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Authorization': `Bearer ${bearer}`
},
body: JSON.stringify({
token: newAuth.refreshToken
})
});
}
}
//finally, delegate to fetch
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${bearer}`
}
});
};
return (
<TokenContext.Provider value={{ accessToken, refreshToken, setAccessToken, setRefreshToken, tokenFetch, getPayload: () => decode(accessToken) }}>
{props.children}
</TokenContext.Provider>
)
};
export default TokenProvider;
+2
View File
@@ -3,3 +3,5 @@
MERN Template developed by Kayne Ruse, KR Game Studios MERN Template developed by Kayne Ruse, KR Game Studios
[https://github.com/krgamestudios/MERN-template](https://github.com/krgamestudios/MERN-template) [https://github.com/krgamestudios/MERN-template](https://github.com/krgamestudios/MERN-template)
TODO: generate the credits using config script
+1
View File
@@ -1,2 +1,3 @@
# Privacy Policy # Privacy Policy
TODO: generate the privacy policy using config script
+4 -2
View File
@@ -1,3 +1,5 @@
//TODO: update this file
//setup //setup
const readline = require('readline'); const readline = require('readline');
const fs = require('fs'); const fs = require('fs');
@@ -107,12 +109,12 @@ services:
- "traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)" - "traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)"
- "traefik.http.routers.${newsName}router.entrypoints=websecure" - "traefik.http.routers.${newsName}router.entrypoints=websecure"
- "traefik.http.routers.${newsName}router.tls.certresolver=myresolver" - "traefik.http.routers.${newsName}router.tls.certresolver=myresolver"
- "traefik.http.routers.${newsName}router.service=newsservice@docker" - "traefik.http.routers.${newsName}router.service=${newsName}service@docker"
- "traefik.http.services.${newsName}service.loadbalancer.server.port=3100" - "traefik.http.services.${newsName}service.loadbalancer.server.port=3100"
environment: environment:
- WEB_PORT=3100 - WEB_PORT=3100
- DB_HOSTNAME=database - DB_HOSTNAME=database
- DB_DATABASE=news - DB_DATABASE=${newsName}
- DB_USERNAME=${newsDBUser} - DB_USERNAME=${newsDBUser}
- DB_PASSWORD=${newsDBPass} - DB_PASSWORD=${newsDBPass}
- DB_TIMEZONE=${databaseTimeZone} - DB_TIMEZONE=${databaseTimeZone}
+3 -1
View File
@@ -1,6 +1,8 @@
//TODO: move this to the wiki?
# Setup Tutorial # Setup Tutorial
Last Updated February 15th 2020 Last Updated February 15th 2021
Hello! This is the tutorial for setting up the MERN-template. If you haven't already, I recommend you download the MERN-template from here: Hello! This is the tutorial for setting up the MERN-template. If you haven't already, I recommend you download the MERN-template from here:
+293 -2052
View File
File diff suppressed because it is too large Load Diff
+13 -25
View File
@@ -4,8 +4,6 @@
"description": "A website template using the MERN stack.", "description": "A website template using the MERN stack.",
"main": "server/server.js", "main": "server/server.js",
"scripts": { "scripts": {
"configure": "node configure-script.js",
"clean": "rm docker-compose.yml; rm Dockerfile; rm startup.sql",
"start": "npm run build && node server/server.js", "start": "npm run build && node server/server.js",
"build": "npm run build:server && npm run build:client", "build": "npm run build:server && npm run build:client",
"build:server": "exit 0", "build:server": "exit 0",
@@ -26,44 +24,34 @@
}, },
"homepage": "https://github.com/KRGameStudios/MERN-template#readme", "homepage": "https://github.com/KRGameStudios/MERN-template#readme",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3",
"connect-session-sequelize": "^7.1.0",
"cookie-parser": "^1.4.5",
"core-js": "^3.8.3",
"dateformat": "^4.5.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-formidable": "^1.2.0",
"express-session": "^1.17.1",
"form-data": "^4.0.0",
"mariadb": "^2.5.2",
"node-cron": "^2.0.3",
"node-fetch": "^2.6.1",
"nodemailer": "^6.4.17",
"react-cookie": "^4.0.3",
"react-dropdown-select": "^4.7.3",
"react-markdown": "^5.0.3",
"regenerator-runtime": "^0.13.7",
"sequelize": "^6.4.0"
},
"devDependencies": {
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11", "@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10", "@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",
"dateformat": "^4.5.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"html-webpack-plugin": "^5.0.0-alpha.14", "html-webpack-plugin": "^5.0.0-alpha.14",
"nodemon": "^2.0.7", "jwt-decode": "^3.1.2",
"mariadb": "^2.5.2",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-dropdown-select": "^4.7.4",
"react-loadable": "^5.5.0", "react-loadable": "^5.5.0",
"react-markdown": "^5.0.3",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"sequelize": "^6.4.0",
"universal-cookie": "^4.0.4",
"webpack": "^5.15.0", "webpack": "^5.15.0",
"webpack-cli": "^4.3.1"
},
"devDependencies": {
"nodemon": "^2.0.7",
"webpack-bundle-analyzer": "^4.3.0", "webpack-bundle-analyzer": "^4.3.0",
"webpack-cli": "^4.3.1",
"webpack-dev-server": "^3.11.2" "webpack-dev-server": "^3.11.2"
} }
} }
-51
View File
@@ -1,51 +0,0 @@
//libraries
const utils = require('util');
const bcrypt = require('bcryptjs');
var cron = require('node-cron');
const Sequelize = require('sequelize');
const Op = Sequelize.Op;
const { accounts } = require('../database/models');
//api/accounts/deletion
const route = async (req, res) => {
//make sure the account is logged in
if (req.cookies['loggedin'] !== process.env.WEB_ADDRESS) {
return res.status(401).send('invalid session status');
}
//compare the user's password
const compare = utils.promisify(bcrypt.compare);
const match = await compare(req.fields.password, req.session.account.hash);
if (!match) {
return res.status(401).send('incorrect password');
}
//set the deletion time (2 days from now)
const interval = new Date(new Date().setDate(new Date().getDate() + 2)); //wow
await accounts.update({
deletion: interval
},
{
where: {
id: req.session.account.id
}
});
//finally
return res.status(200).send('account will be deleted in two days - log in to cancel');
};
//actually delete the accounts
cron.schedule('0 * * * *', () => {
accounts.destroy({
where: {
deletion: {
[Op.lt]: Sequelize.fn('NOW')
}
}
});
});
module.exports = route;
-17
View File
@@ -1,17 +0,0 @@
const express = require('express');
const router = express.Router();
//basic account management
router.get('/', require('./query'));
router.patch('/', require('./update'));
//signup -> login -> logout
router.post('/signup', require('./signup'));
router.get('/validation', require('./validation'));
router.post('/login', require('./login'));
router.post('/logout', require('./logout'));
//account deletion
router.delete('/deletion', require('./deletion'));
module.exports = router;
-86
View File
@@ -1,86 +0,0 @@
//libraries
const utils = require('util');
const bcrypt = require('bcryptjs');
const Sequelize = require('sequelize');
const Op = Sequelize.Op;
const { bannedEmails, accounts } = require('../database/models');
//utilities
const validateEmail = require('../../common/utilities/validate-email.js');
//api/accounts/login
const route = async (req, res) => {
//validate the given details
const validateErr = await validateDetails(req.fields);
if (validateErr) {
return res.status(401).send(validateErr);
}
//get the existing account
const account = await accounts.findOne({
where: {
email: req.fields.email
}
});
if (!account) {
return res.status(401).send('incorrect email or password');
}
//compare passwords
const compare = utils.promisify(bcrypt.compare);
const match = await compare(req.fields.password, account.hash);
if (!match) {
return res.status(401).send('incorrect email or password');
}
//save the session and cookie data
req.session.account = JSON.parse(JSON.stringify(account.dataValues));
res.cookie('loggedin', process.env.WEB_ADDRESS);
if (account.privilege == 'administrator') {
res.cookie('admin', process.env.SESSION_ADMIN);
}
//cancel deletion if any
await accounts.update({ deletion: null }, {
where: {
id: account.id
}
});
//finally
res.status(200).send('login succeeded');
};
const validateDetails = async (fields) => {
//basic formatting (with an exception for the default admin account)
if (!validateEmail(fields.email) && fields.email != `admin@${process.env.WEB_ADDRESS}`) {
return 'invalid email';
}
//check for existing (banned)
const banned = await bannedEmails.findAll({
where: {
[Op.and]: {
email: fields.email,
expiry: {
[Op.or]: {
[Op.gt]: Sequelize.fn('NOW'),
[Op.eq]: null
}
}
}
}
});
if (banned.length > 0) {
return 'banned email';
}
return null;
}
module.exports = route;
-11
View File
@@ -1,11 +0,0 @@
const route = (req, res) => {
//clear cookies and stored data
req.session.account = null;
res.clearCookie('loggedin');
res.clearCookie('admin');
res.clearCookie('pseudonym');
return res.status(200).end();
};
module.exports = route;
-21
View File
@@ -1,21 +0,0 @@
const { accounts } = require('../database/models');
const route = async (req, res) => {
if (!req.session.account || !req.session.account.id) {
res.status(401).send('Unknown account');
}
//update the reference
req.session.account = (await accounts.findOne({
where: {
id: req.session.account.id
}
})).dataValues;
//respond with the private-facing data
res.status(200).json({
contact: req.session.account.contact
});
};
module.exports = route;
-159
View File
@@ -1,159 +0,0 @@
//libraries
const bcrypt = require('bcryptjs');
const nodemailer = require('nodemailer');
const Sequelize = require('sequelize');
const Op = Sequelize.Op;
const { bannedEmails, accounts, pendingSignups } = require('../database/models');
//utilities
const validateEmail = require('../../common/utilities/validate-email.js');
const validateUsername = require('../../common/utilities/validate-username.js');
//api/accounts/signup
const route = async (req, res) => {
//validate the given details
const validateErr = await validateDetails(req.fields);
if (validateErr) {
return res.status(401).send(validateErr);
}
//generate the password hash
const salt = await bcrypt.genSalt(11);
const hash = await bcrypt.hash(req.fields.password, salt);
//generate the validation field
const token = Math.floor(Math.random() * 2000000000);
//register signup
const signupErr = await registerPendingSignup(req.fields, hash, token);
if (signupErr) {
return res.status(500).send(signupErr);
}
//send the validation email
const emailErr = await sendValidationEmail(req.fields.email, req.fields.username, token);
if (emailErr) {
return res.status(500).send(emailErr);
}
//finally
res.status(200).send("Validation email sent!");
return null;
}
const validateDetails = async (fields) => {
//basic formatting
if (!validateEmail(fields.email)) {
return 'invalid email';
}
if (!validateUsername(fields.username)) {
return 'invalid username';
}
//check for existing (banned)
const banned = await bannedEmails.findAll({
where: {
[Op.and]: {
email: fields.email,
expiry: {
[Op.or]: {
[Op.gt]: Sequelize.fn('NOW'),
[Op.eq]: null
}
}
}
}
});
if (banned.length > 0) {
return 'banned email';
}
//check for existing email
const email = await accounts.findOne({
where: {
email: fields.email
}
});
if (email) {
return 'email already exists';
}
//check for existing username
const username = await accounts.findOne({
where: {
username: fields.username
}
});
if (username) {
return 'username already exists';
}
return null;
};
const registerPendingSignup = async (fields, hash, token) => {
const record = await pendingSignups.upsert({
email: fields.email,
username: fields.username,
hash: hash,
contact: fields.contact,
token: token
});
return null;
};
const sendValidationEmail = async (email, username, token) => {
const addr = `${process.env.WEB_PROTOCOL}://${process.env.WEB_ADDRESS}/api/accounts/validation?username=${username}&token=${token}`;
const msg = `Hello ${username}!
Please visit the following link to validate your account: ${addr}
You can contact us directly at our physical mailing address here: ${process.env.MAIL_PHYSICAL}
`;
let transporter, info;
//what exactly is a transport?
try {
transporter = nodemailer.createTransport({
host: process.env.MAIL_SMTP,
port: 465,
secure: true,
auth: {
user: process.env.MAIL_USERNAME,
pass: process.env.MAIL_PASSWORD
},
});
}
catch(e) {
return `failed to create transport: ${e}`;
}
// send mail with defined transport object
try {
info = await transporter.sendMail({
from: `signup@${process.env.WEB_ADDRESS}`, //WARNING: google overwrites this
to: email,
subject: 'Email Validation',
text: msg
});
}
catch(e) {
return `failed to send mail ${e}`;
}
if (info.accepted[0] != email) {
return 'validation email failed to send';
}
return null;
};
module.exports = route;
-34
View File
@@ -1,34 +0,0 @@
const bcrypt = require('bcryptjs');
const { accounts } = require('../database/models');
const route = async (req, res) => {
if (!req.session.account.id) {
return res.status(500).send('missing account data');
}
//generate the password hash
const salt = await bcrypt.genSalt(11);
const hash = await bcrypt.hash(req.fields.password, salt);
//update the account
await accounts.update({
contact: req.fields.contact,
hash: hash
}, {
where: {
id: req.session.account.id
}
});
//update the reference
req.session.account = (await accounts.findOne({
where: {
id: req.session.account.id
}
})).dataValues;
//respond with an OK
res.status(200).send('Information updated');
};
module.exports = route;
-40
View File
@@ -1,40 +0,0 @@
const { pendingSignups, accounts } = require('../database/models');
//api/accounts/validation
const route = async (req, res) => {
//get the existing pending signup
const info = await pendingSignups.findOne({
where: {
username: req.query.username
}
});
//check the given info
if (!info) {
return res.status(401).send('validation failed');
}
if (info.token != req.query.token) {
return res.status(401).send('tokens do not match');
}
//delete the pending signup
pendingSignups.destroy({
where: {
username: req.query.username
}
});
//move data to the accounts table
accounts.create({
email: info.email,
username: info.username,
hash: info.hash,
contact: info.contact
});
//finally
res.status(200).send('Validation succeeded!');
};
module.exports = route;
-40
View File
@@ -1,40 +0,0 @@
const { Op } = require('sequelize');
const { bannedEmails, accounts } = require('../database/models');
const route = async (req, res) => {
//fetch the account based on the email or username
const account = await accounts.findOne({
attrubutes: ['username', 'email'],
where: {
[Op.or]: {
username: {
[Op.eq]: req.fields.username,
},
email: {
[Op.eq]: req.fields.email
}
}
}
});
//just in case
if (account && account.privilege == 'administrator') {
return res.status(401).send('Couldn\'t ban an admin');
}
//need either an email or an account
if (!account && !req.fields.email) {
return res.status(401).send('Couldn\'t determine the ban info');
}
//apply the ban
await bannedEmails.upsert({
email: (account || req.fields).email,
reason: req.fields.reason ? req.fields.reason : null,
expiry: req.fields.expiry ? new Date(Date.parse(req.fields.expiry)) : null
});
return res.status(200).send(`Email ${(account || req.fields).email} banned (username ${account ? account.username : 'not found'})`);
};
module.exports = route;
-34
View File
@@ -1,34 +0,0 @@
const { Op } = require('sequelize');
const { bannedEmails, accounts } = require('../database/models');
const route = async (req, res) => {
//merge the banned accounts with the account data, if any
const data = await bannedEmails.findAll()
.then(bans => bans.map(async ban => {
//find a matching account
const account = await accounts.findOne({
attrubutes: ['username', 'privilege'],
where: {
email: {
[Op.eq]: ban.email
}
}
}) || {};
//merge the data and return (becomes a promise)
return {
username: account.username,
email: ban.email,
privilege: account.privilege,
expiry: ban.expiry,
reason: ban.reason
};
}))
.then(promises => Promise.all(promises)) //resolve promises
.catch(e => console.error(e))
;
return res.status(200).json(data);
};
module.exports = route;
-29
View File
@@ -1,29 +0,0 @@
//DOCS: this whole file is just a big bugfix
//DOCS: ensure that there is at least one administration account
const bcrypt = require('bcryptjs');
const sequelize = require('../database');
const { accounts } = require('../database/models');
const defaultAdminAccount = async () => {
await sequelize.sync(); //this whole file is just one big BUGFIX
const admin = await accounts.findOne({
where: {
privilege: 'administrator'
}
});
if (admin == null) {
await accounts.create({
privilege: 'administrator',
email: `admin@${process.env.WEB_ADDRESS}`,
username: `admin`,
hash: await bcrypt.hash('password', await bcrypt.genSalt(11))
});
//TODO: (1) Replace this default admin account password with UUID
console.log(`Created default admin account (email: admin@${process.env.WEB_ADDRESS}; password: password)`);
}
};
module.exports = defaultAdminAccount;
-19
View File
@@ -1,19 +0,0 @@
const express = require('express');
const router = express.Router();
//middleware
router.use((req, res, next) => {
//make sure the account is an admin
if (req.cookies['admin'] !== process.env.SESSION_ADMIN) { //TODO: Eew not good.
return res.status(401).send('invalid admin status');
} else {
next();
}
});
//basic account ban management
router.get('/banned', require('./banned'));
router.post('/ban', require('./ban'));
router.post('/unban', require('./unban'));
module.exports = router;
-46
View File
@@ -1,46 +0,0 @@
const Sequelize = require('sequelize');
const Op = Sequelize.Op;
const { bannedEmails, accounts } = require('../database/models');
var cron = require('node-cron');
const route = async (req, res) => {
console.log(req.fields.entry)
//get the account, if one is found
const account = await accounts.findOne({
where: {
[Op.or]: {
email: {
[Op.eq]: req.fields.entry
},
username: {
[Op.eq]: req.fields.entry
}
}
},
});
//accept either email or username
const affectedRows = await bannedEmails.destroy({
where: {
email: {
[Op.eq]: account?.email || req.fields.entry || ''
}
}
});
return res.status(200).send(`${affectedRows} emails unbanned`);
};
//delete any expired bans
cron.schedule('0 * * * *', () => {
bannedEmails.destroy({
where: {
expiry: {
[Op.lt]: Sequelize.fn('NOW'),
[Op.not]: null
}
}
});
});
module.exports = route;
-7
View File
@@ -1,7 +0,0 @@
const express = require('express');
const router = express.Router();
//reserve the name on the chat server (then get out of the way)
router.post('/reserve', require('./reserve'));
module.exports = router;
-32
View File
@@ -1,32 +0,0 @@
const fetch = require('node-fetch');
const FormData = require('form-data');
const route = async (req, res) => {
if (!req.session.account) {
return status(403).send('No account detected');
}
//build the fake form data object
let form = new FormData();
form.append('username', req.session?.account?.username);
form.append('key', process.env.CHAT_KEY);
try {
//reserve the UUID with the chat server (hop 1)
const result = await fetch(`http${process.env.PRODUCTION ? 's' : ''}://${process.env.CHAT_URI}/reserve`, { method: 'POST', body: form });
if (result.status == 200) {
const json = await result.json();
res.cookie('pseudonym', json.pseudonym);
res.status(200).send({ ok: true });
} else {
throw await result.text();
}
} catch(e) {
console.error(`Chat server error: ${e}`);
res.cookie('pseudonym', '.null');
res.status(200).send({ ok: false, error: `Chat server error ${e}` });
}
};
module.exports = route;
+1 -1
View File
@@ -4,7 +4,7 @@ const sequelize = new Sequelize(process.env.DB_DATABASE, process.env.DB_USERNAME
host: process.env.DB_HOSTNAME, host: process.env.DB_HOSTNAME,
dialect: 'mariadb', dialect: 'mariadb',
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
// logging: false logging: false
}); });
sequelize.sync(); sequelize.sync();
-42
View File
@@ -1,42 +0,0 @@
const Sequelize = require('sequelize');
const sequelize = require('..');
module.exports = sequelize.define('accounts', {
id: {
type: Sequelize.INTEGER(11),
allowNull: false,
autoIncrement: true,
primaryKey: true,
unique: true
},
privilege: {
type: Sequelize.ENUM,
values: ['administrator', 'moderator', 'alpha', 'beta', 'gamma', 'normal'],
defaultValue: 'normal'
},
email: {
type: 'varchar(320)',
unique: true
},
username: {
type: 'varchar(320)',
unique: true
},
hash: 'varchar(100)', //for passwords
contact: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
deletion: {
type: 'DATETIME',
allowNull: true,
defaultValue: null
}
});
-25
View File
@@ -1,25 +0,0 @@
const Sequelize = require('sequelize');
const sequelize = require('..');
module.exports = sequelize.define('bannedEmails', {
id: {
type: Sequelize.INTEGER(11),
allowNull: false,
autoIncrement: true,
primaryKey: true,
unique: true
},
email: {
type: 'varchar(320)',
unique: true
},
reason: Sequelize.TEXT,
expiry: {
type: 'DATETIME',
allowNull: true,
defaultValue: null
}
});
+1 -3
View File
@@ -1,5 +1,3 @@
module.exports = { module.exports = {
bannedEmails: require('./banned-emails'), //TODO: models
accounts: require('./accounts'),
pendingSignups: require('./pending-signups')
} }
-24
View File
@@ -1,24 +0,0 @@
const Sequelize = require('sequelize');
const sequelize = require('..');
module.exports = sequelize.define('pendingSignups', {
email: {
type: 'varchar(320)',
unique: true
},
username: {
type: 'varchar(320)',
unique: true
},
hash: 'varchar(100)', //for passwords
contact: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
token: Sequelize.INTEGER(11)
});
+6 -31
View File
@@ -1,46 +1,21 @@
//environment variables //environment variables
require('dotenv').config(); require('dotenv').config();
//libraries
const path = require('path');
//create the server //create the server
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const server = require('http').Server(app); const server = require('http').Server(app);
const bodyParser = require('body-parser');
//libraries used here //config
const path = require('path'); app.use(bodyParser.json());
const formidable = require('express-formidable');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const SequelizeStore = require("connect-session-sequelize")(session.Store);
//database connection //database connection
const database = require('./database'); const database = require('./database');
//setup the app middleware
app.use(formidable());
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: true,
store: new SequelizeStore({
db: database
})
}));
//invoke all models
const models = require('./database/models');
//account management
app.use('/api/accounts', require('./accounts'));
//chat management
app.use('/api/chat', require('./chat'));
//administration
app.use('/api/admin', require('./admin'));
require('./admin/bookkeeper')(); //BUGFIX
//send static files //send static files
app.use('/', express.static(path.resolve(__dirname, '..', 'public'))); app.use('/', express.static(path.resolve(__dirname, '..', 'public')));
-2
View File
@@ -1,2 +0,0 @@
#This file should be used for altering the database in production - make sure it works!
+2
View File
@@ -1,3 +1,5 @@
#TODO: move this into configure-script.js
#This file only needs to be run once, during initial development setup #This file only needs to be run once, during initial development setup
#This file isnt needed for actual deployment #This file isnt needed for actual deployment
+3 -4
View File
@@ -51,10 +51,9 @@ module.exports = ({ production, analyzer }) => {
new DefinePlugin({ new DefinePlugin({
'process.env': { 'process.env': {
'PRODUCTION': production, 'PRODUCTION': production,
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"http://dev-news.eggtrainer.com:3100/news"', 'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"https://dev-news.eggtrainer.com/news"',
/* TODO: (1) NEWS_KEY needs to be set in the server, and auth'd via admin accounts, NOT embedded in the client */ 'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : '"https://dev-auth.eggtrainer.com/auth"',
'NEWS_KEY': production ? `"${process.env.NEWS_KEY}"` : '"key"', // 'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : '"https://dev-chat.eggtrainer.com/chat"',
'CHAT_URI': production ? `"${process.env.NEWS_URI}"` : '"http://dev-chat.eggtrainer.com:3200/chat"',
} }
}), }),
new CleanWebpackPlugin({ new CleanWebpackPlugin({