Stripped out a whole bunch of pages, read more

The purpose of this branch is to bring this project in line with the JWT
protcol that the microservice is using. For the time being, it's easier
to get a stripped-down and stable build and replace the lost parts, one-
by-one.
This commit is contained in:
2021-03-08 12:34:41 +11:00
parent e3e5af4af0
commit 7c09ac46da
46 changed files with 310 additions and 4150 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
+1 -5
View File
@@ -1,16 +1,12 @@
//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';
ReactDOM.render( ReactDOM.render(
<CookiesProvider> <App />,
<App />
</CookiesProvider>,
document.querySelector('#root') document.querySelector('#root')
); );
+1 -27
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>
@@ -40,13 +21,6 @@ const App = props => {
<Switch> <Switch>
<LazyRoute exact path='/' component={() => import('./pages/homepage')} /> <LazyRoute exact path='/' component={() => import('./pages/homepage')} />
<LazyRoute path='/signup' component={() => import('./pages/signup')} />
<LazyRoute path='/login' component={() => import('./pages/login')} />
<LazyRoute path='/account' component={() => import('./pages/account')} />
<LazyRoute path='/chat' component={() => import('./pages/chat')} />
<LazyRoute path='/admin' component={() => import('./pages/admin')} />
<LazyRoute path='/privacypolicy' component={async () => () => <Markdown content={require('../markdown/privacy-policy.md').default} />} /> <LazyRoute path='/privacypolicy' component={async () => () => <Markdown content={require('../markdown/privacy-policy.md').default} />} />
<LazyRoute path='/credits' component={async () => () => <Markdown content={require('../markdown/credits.md').default} />} /> <LazyRoute path='/credits' component={async () => () => <Markdown content={require('../markdown/credits.md').default} />} />
-85
View File
@@ -1,85 +0,0 @@
import React, { useEffect } from 'react';
import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
import DeleteAccount from '../panels/delete-account';
const Account = props => {
const [cookies, setCookie] = useCookies();
//check for logged in redirect
if (!cookies['loggedin']) {
return <Redirect to='/' />;
}
//refs
let contactElement, passwordElement, retypeElement;
//once before render
useEffect(() => {
fetch('/api/accounts')
.then(blob => blob.json())
.then(json => {
contactElement.checked = json.contact;
})
.catch(e => console.error(e))
;
}, []);
return (
<div className='page'>
<h1 className='centered'>Account</h1>
<form className='constricted' onSubmit={async evt => {
evt.preventDefault();
await update(contactElement.checked, passwordElement.value, retypeElement.value);
passwordElement.value = retypeElement.value = '';
}}>
<div>
<div>
<label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={e => contactElement = e} />
</div>
<div>
<label htmlFor='password'>Change Password:</label>
<input type='password' name='password' ref={e => passwordElement = e} />
</div>
<div>
<label htmlFor='retype'>Retype Password:</label>
<input type='password' name='retype' ref={e => retypeElement = e} />
</div>
</div>
<button type='submit'>Update Information</button>
</form>
<DeleteAccount className='constricted' />
</div>
);
};
const update = async (contact, password, retype) => {
if (password != retype) {
alert('Passwords do not match');
}
//generate a new formdata payload
let formData = new FormData();
formData.append('contact', contact);
if (password) {
formData.append('password', password);
}
const result = await fetch('/api/accounts', { method: 'PATCH', body: formData });
if (result.ok) {
alert(await result.text());
} else {
alert(await result.text());
}
}
export default Account;
-26
View File
@@ -1,26 +0,0 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//import BannedEmails from '../panels/banned-emails';
import NewsPublisher from '../panels/news-publisher';
import NewsEditor from '../panels/news-editor';
const Admin = props => {
const [cookies, setCookie] = useCookies();
//check for logged in redirect
if (!cookies['admin']) {
return <Redirect to='/' />;
}
return (
<div className='page'>
<h1 className='centered'>Administration</h1>
<NewsPublisher uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
<NewsEditor uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
</div>
);
};
export default Admin;
-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;
-71
View File
@@ -1,71 +0,0 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//utilities
const validateEmail = require('../../../common/utilities/validate-email.js');
const LogIn = props => {
const [cookies, setCookie] = useCookies();
//check for logged in redirect
if (cookies['loggedin']) {
return <Redirect to='/' />;
}
//refs
let emailElement, passwordElement;
return (
<div className='page'>
<h1 className='centered'>Login</h1>
<form className='constricted' onSubmit={
evt => {
evt.preventDefault();
handleSubmit(emailElement.value, passwordElement.value)
.then(([res, ok]) => {
alert(res);
if (ok) {
window.location.reload(true); //BUFGIX: force reload of the header element
}
})
.catch(e => console.error(e))
;
}
}>
<div>
<label htmlFor="email">Email:</label>
<input type="email" name="email" ref={e => emailElement = e} />
</div>
<div>
<label htmlFor="password">Password:</label>
<input type="password" name="password" ref={e => passwordElement = e} />
</div>
<button type='submit'>Login</button>
</form>
</div>
);
};
//DOCS: returns two values: response and OK
const handleSubmit = async (email, password) => {
email = email.trim();
//generate a new formdata payload
let formData = new FormData();
formData.append('email', email);
formData.append('password', password);
const result = await fetch('/api/accounts/login', { method: 'POST', body: formData });
if (result.ok) {
return [await result.text(), true];
} else {
return [await result.text(), false];
}
};
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>
); );
}; };
-114
View File
@@ -1,114 +0,0 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useCookies } from 'react-cookie';
//utilities
const validateEmail = require('../../../common/utilities/validate-email.js');
const validateUsername = require('../../../common/utilities/validate-username.js');
const SignUp = props => {
const [cookies, setCookie] = useCookies();
//check for logged in redirect
if (cookies['loggedin']) {
return <Redirect to='/' />;
}
//refs
let emailElement, usernameElement, passwordElement, retypeElement, contactElement;
return (
<div className='page'>
<h1 className='centered'>Signup</h1>
<form className='constricted' onSubmit={
evt => {
evt.preventDefault();
handleSubmit(emailElement.value, usernameElement.value, passwordElement.value, retypeElement.value, contactElement.checked)
.then(res => res ? alert(res) : null)
.then(() => emailElement.value = usernameElement.value = passwordElement.value = retypeElement.value = '') //clear input
.then(() => contactElement.checked = false)
.then(() => props.history.push('/'))
.catch(e => console.error(e))
;
}
}>
<div>
<label htmlFor='email'>Email:</label>
<input type='email' name='email' ref={e => emailElement = e} />
</div>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={e => usernameElement = e} />
</div>
<div>
<label htmlFor='password'>Password:</label>
<input type='password' name='password' ref={e => passwordElement = e} />
</div>
<div>
<label htmlFor='retype'>Retype Password:</label>
<input type='password' name='retype' ref={e => retypeElement = e} />
</div>
<div>
<label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={e => contactElement = e} />
</div>
<button type='submit'>Signup</button>
</form>
</div>
);
};
const handleSubmit = async (email, username, password, retype, contact) => {
email = email.trim();
username = username.trim();
const err = handleValidation(email, username, password, retype);
if (err) {
return err;
}
//generate a new formdata payload
let formData = new FormData();
formData.append('email', email);
formData.append('username', username);
formData.append('password', password);
formData.append('contact', contact)
const result = await fetch('/api/accounts/signup', { method: 'POST', body: formData });
if (result.ok) {
return result.text();
} else {
return result.text();
}
};
//returns an error message, or null on success
const handleValidation = (email, username, password, retype) => {
if (!validateEmail(email)) {
return 'invalid email';
}
if (!validateUsername(username)) {
return 'invalid username';
}
if (password.length < 8) {
return 'invalid password (Must be at least 8 characters long)';
}
if (password !== retype) {
return 'passwords do not match';
}
return null;
};
export default SignUp;
-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;
@@ -1,51 +0,0 @@
import React, { useState } from 'react';
//DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed
const DeleteAccount = props => {
const [open, setOpen] = useState(false);
if (!open) {
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button>
}
let passwordElement;
return (
<form className={props.className} onSubmit={async evt => {
evt.preventDefault();
const password = passwordElement.value;
passwordElement.value = '';
await handleSubmit(password);
}}>
<div>
<label htmlFor="password">Password:</label>
<input type="password" name="password" ref={e => passwordElement = e} />
</div>
<button type='submit'>Delete Account</button>
<button type='cancel' onClick={() => { passwordElement.value = ''; setOpen(false); }}>Cancel</button>
</form>
);
};
const handleSubmit = async (password) => {
//generate a new formdata payload
let formData = new FormData();
formData.append('password', password);
const result = await fetch('/api/accounts/deletion', { method: 'DELETE', body: formData });
if (!result.ok) {
alert(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))
;
}
};
export default DeleteAccount;
+8 -16
View File
@@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useCookies } from 'react-cookie'; import { Link } from 'react-router-dom';
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>
); );
}; };
@@ -14,34 +14,26 @@ const Visitor = () => {
const Member = () => { const Member = () => {
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> <Link to='/' onClick={logout}>Log out</Link>
</div> </div>
); );
}; };
const logout = async () => { const logout = async () => {
//TODO: update API
await fetch('/api/accounts/logout', { method: 'POST' }) await fetch('/api/accounts/logout', { method: 'POST' })
.catch(e => console.error(e)) .catch(e => console.error(e))
; ;
}; };
const Header = () => { const Header = () => {
const [cookies, setCookie] = useCookies(['loggedin']); let Options = Visitor;
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 /> <Options />
</header> </header>
); );
-116
View File
@@ -1,116 +0,0 @@
import React, { useState } from 'react';
import Select from 'react-dropdown-select';
//DOCS: props.uri is the address of a live news-server
//DOCS: props.newsKey is the key of the live news-server
const NewsEditor = props => {
let titleElement, authorElement, bodyElement;
const [articles, setArticles] = useState(null);
const [index, setIndex] = useState(null);
if (!articles) {
fetch(`${props.uri}/titles?limit=999`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
})
.then(a => {
if (!a.ok) {
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
}
return a.json();
})
.then(a => setArticles(a))
.catch(e => console.error(e))
;
}
return (
<div>
<h2 className='centered'>News Editor</h2>
<div>
<label htmlFor='article'>Article: </label>
<Select
options={(articles || []).map(article => { return { label: article.title, value: article.index }; })}
onChange={values => setIndex(fetchSelection(values[0].value, titleElement, authorElement, bodyElement, props.uri))}
/>
</div>
<form onSubmit={async e => {
e.preventDefault();
await handleSubmit(index, titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
titleElement.value = authorElement.value = bodyElement.value = '';
}}>
<div>
<label htmlFor='title'>Title: </label>
<input type='text' name='title' ref={ e => titleElement = e } />
</div>
<div>
<label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={ e => authorElement = e } />
</div>
<div>
<label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } />
</div>
<button type='submit'>Update</button>
</form>
</div>
);
};
const fetchSelection = (index, titleElement, authorElement, bodyElement, uri) => {
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();
author = author.trim();
body = body.trim();
uri = uri.trim();
newsKey = newsKey.trim();
//fetch POST json data
const raw = await fetch(
`${uri}/${index}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
}
);
if (raw.ok) {
const result = await raw.json();
if (result.ok) {
alert(`Updated article index ${index}`);
} else {
alert(result.error);
}
} else {
alert(raw.statusText);
}
};
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))
; ;
@@ -1,70 +0,0 @@
import React from 'react';
//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 => {
let titleElement, authorElement, bodyElement;
return (
<div>
<h2 className='centered'>News Publisher</h2>
<form onSubmit={async e => {
e.preventDefault();
await handleSubmit(titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
titleElement.value = authorElement.value = bodyElement.value = '';
}}>
<div>
<label htmlFor='title'>Title: </label>
<input type='text' name='title' ref={ e => titleElement = e } />
</div>
<div>
<label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={ e => authorElement = e } />
</div>
<div>
<label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={ e => bodyElement = e } />
</div>
<button type='submit'>Publish</button>
</form>
</div>
);
};
const handleSubmit = async (title, author, body, uri, newsKey) => {
title = title.trim();
author = author.trim();
body = body.trim();
uri = uri.trim();
newsKey = newsKey.trim();
//fetch POST json data
const raw = await fetch(
uri,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
}
);
if (raw.ok) {
const result = await raw.json();
if (result.ok) {
alert(`Published article index ${result.index}`);
} else {
alert(result.error);
}
} else {
alert(raw.statusText);
}
};
export default NewsPublisher;
+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:
+262 -2599
View File
File diff suppressed because it is too large Load Diff
+10 -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,31 @@
}, },
"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", "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-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",
"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({