Compare commits

...

81 Commits

Author SHA1 Message Date
Kayne Ruse eb370663d2 Tweaked some display 2021-07-29 00:37:23 +10:00
Kayne Ruse 462116d980 Fixed whitespace 2021-07-28 23:18:22 +10:00
Kayne Ruse af06ddc06d Working on password recovery 2021-07-28 23:01:41 +10:00
Kayne Ruse f937ee47db Removed universal-cookie, fixed package.json versioning 2021-07-26 05:05:39 +01:00
Kayne Ruse 6d0dd419ca Fixed #22 2021-07-26 13:48:08 +10:00
Kayne Ruse 2919467dff Fixed #23 2021-07-26 13:38:33 +10:00
Kayne Ruse 269caac88c Tweaked README.md 2021-07-25 20:35:34 +01:00
Kayne Ruse 69c297fa74 Stupid line endings 2021-07-25 19:22:17 +10:00
Kayne Ruse 0f538be3e5 Fixed #20 2021-07-25 18:53:14 +10:00
Kayne Ruse f85b6e8793 Fixed passwords 2021-07-25 18:52:46 +10:00
Kayne Ruse 2af9532930 Fixed deploy bug, properly 2021-07-24 14:01:20 +01:00
Kayne Ruse 191da50740 Deploy bug 2021-07-24 22:20:17 +10:00
Kayne Ruse f24c7990f6 Fixed line-endings 2021-07-24 20:23:18 +10:00
Kayne Ruse 7a48780f50 Update package.json 2021-07-23 21:25:24 +10:00
Kayne Ruse 33952a9896 Changed chat line id to index 2021-07-23 21:24:19 +10:00
Kayne Ruse 8076b0cc40 Pointed to new dev-services 2021-07-22 21:48:42 +01:00
Kayne Ruse e216474196 Bumped version number 2021-07-15 08:53:57 +10:00
Kayne Ruse 2532bf1867 Updated packages to fix vulnerabilities 2021-07-15 08:52:54 +10:00
Kayne Ruse 93a3c30e81 Fixed the database hostname
It's supposed to point to localhost when developing locally.
2021-04-08 04:21:31 +10:00
Kayne Ruse ae8c82e83a Fixed embedded source-map error 2021-04-07 05:00:16 +10:00
Kayne Ruse bc6a795750 WHOOPS THAT WAS A MISTAKE 2021-04-07 03:18:48 +10:00
Kayne Ruse 9947ef13c1 Added a workaround for a mysql bug 2021-04-07 02:21:44 +10:00
Kayne Ruse d3f0b1ac7d Fixed WEB_PORT setting 2021-04-03 03:59:08 +11:00
Kayne Ruse b5f9c45a1b Updated README.md 2021-03-28 08:56:02 +11:00
Kayne Ruse 5189415e1a Implemented permabans 2021-03-28 08:33:26 +11:00
Kayne Ruse be793ae2ff Chat report table working 2021-03-28 07:58:52 +11:00
Kayne Ruse 19f5c20056 "Fixed" errors on logout, read more
I really don't like this solution.

The problem was caused by fetch being called twice after the component was
rendered twice. I don't know why it renders twice.
2021-03-28 04:38:03 +11:00
Kayne Ruse 7923f51aae Updated README.md 2021-03-25 18:10:03 +11:00
Kayne Ruse 793e54e334 Updated admin and mod flag system 2021-03-24 08:21:40 +11:00
Kayne Ruse 4fa54668e6 Added reporting feature 2021-03-24 03:22:16 +11:00
Kayne Ruse ff0230b77f Tested the new libs 2021-03-24 01:53:33 +11:00
Kayne Ruse 6b53bee033 Updated libraries 2021-03-24 01:11:01 +11:00
Kayne Ruse 4280319443 Replaced react-loadable with @loadable/component 2021-03-24 01:03:11 +11:00
Kayne Ruse 9788552d0c Updated README.md 2021-03-22 16:49:24 +11:00
Kayne Ruse 3fd76375dd Each microservice has received a tweak to .envdev, read more
This should make it easier to set time zones and enable database logging.

Related to krgamestudios/MERN-template#16
2021-03-22 16:39:44 +11:00
Kayne Ruse 9c8ece5c06 Implemented DB_LOGGING 2021-03-20 05:13:12 +11:00
Kayne Ruse 9201e9374b Updated README.md 2021-03-19 20:39:52 +11:00
Kayne Ruse 8c8b78462f Updated README.md 2021-03-19 18:49:01 +11:00
Kayne Ruse 07e578b4c4 Updated README.md 2021-03-19 18:47:17 +11:00
Kayne Ruse 732eeb933e Tweak 2021-03-18 05:26:21 +11:00
Kayne Ruse 104e15d714 Stupid tabs 2021-03-18 05:21:26 +11:00
Kayne Ruse 2c9871d82a Tweak 2021-03-18 05:17:12 +11:00
Kayne Ruse f5f44ae9f7 Updated configure-script.js with chat info 2021-03-18 05:15:32 +11:00
Kayne Ruse 1c3d24575e Added backlog to chat 2021-03-18 02:40:22 +11:00
Kayne Ruse 13e3ce6db8 Chat is working with a local chat-server 2021-03-17 16:52:14 +11:00
Kayne Ruse 8561219542 Wrote CSS for a chatbox 2021-03-17 03:44:38 +11:00
Kayne Ruse e288a43519 Tweaked README.md 2021-03-17 00:56:33 +11:00
Kayne Ruse 211eb460cb Tweaked the contents of env variables, read more
NEWS_URI and AUTH_URI had the end of their routes lopped off, so I
could reference /admin using either one of them.
2021-03-14 04:37:35 +11:00
Kayne Ruse c2f1cd76e9 Removed docs/ 2021-03-14 00:14:59 +11:00
Kayne Ruse c629192d04 Merge branch 'main' of https://github.com/krgamestudios/MERN-template into main 2021-03-12 16:07:09 +11:00
Kayne Ruse b78b034d6d Fixed an error message 2021-03-12 16:06:55 +11:00
Kayne Ruse 741d6d163b Update README.md 2021-03-12 15:44:54 +11:00
Kayne Ruse f9df2722e8 Fixed typo
Resolved #5
2021-03-12 15:22:03 +11:00
Kayne Ruse 11d49f981d Added privilege editing
Resolved #7
2021-03-12 15:07:52 +11:00
Kayne Ruse e930fd2173 Added deletion to news-editor.jsx
Resovled #6
2021-03-12 13:58:14 +11:00
Kayne Ruse 8a920c5316 Added link to admin panel
Resolved #11
2021-03-12 13:32:19 +11:00
Kayne Ruse d66d0bc9da Fixed config error 2021-03-12 11:27:45 +11:00
Kayne Ruse 9e0d58e999 Corrected a docker error 2021-03-12 11:24:54 +11:00
Kayne Ruse 9c294ab961 Updated README.md and configure-script.js 2021-03-12 11:04:04 +11:00
Kayne Ruse 9b6c5af09d "articles" are always an array 2021-03-11 14:58:24 +11:00
Kayne Ruse 8f3ab27106 Switched to using useEffect 2021-03-11 14:56:07 +11:00
Kayne Ruse 253fd494ae Merge branch 'dev' into main
Resolved #3
2021-03-11 11:18:39 +11:00
Kayne Ruse 34b6a25bb5 Added admin panel - this should complete all of the dummied out features 2021-03-11 11:15:56 +11:00
Kayne Ruse b6e707d047 Added account page, read more
The account page also has the account deletion button, which was
a *real treat* to get working right.
2021-03-11 00:54:57 +11:00
Kayne Ruse 457cc85ad4 I need a linter 2021-03-10 22:35:27 +11:00
Kayne Ruse e1a20411a0 Fixed expiry calls 2021-03-10 22:33:22 +11:00
Kayne Ruse b8e4b33421 Tokens are tentatively working correctly, read more
They also seem to be refreshing correctly too, when tokenFetch() is used.
2021-03-10 18:54:20 +11:00
Kayne Ruse 44553836c7 Not working right - too tired right now 2021-03-08 17:01:14 +11:00
Kayne Ruse b5b1b987b1 So that's how you use refs 2021-03-08 14:44:36 +11:00
Kayne Ruse d29d256e5f Signups work 2021-03-08 13:03:46 +11:00
Kayne Ruse 7c09ac46da 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.
2021-03-08 12:34:41 +11:00
Kayne Ruse e3e5af4af0 Comment tweaks 2021-03-06 14:52:18 +11:00
Kayne Ruse dccf55c973 Added PRODUCTION env variable to client 2021-03-04 10:53:13 +11:00
Kayne Ruse 488f975e98 Fixex the deployment issues, read more
The deployment issues were caused by sequelize running out-of-order.
I fixed this by placing sequelize.sync() in correct locations.

I've also updated news-server with these changes.

I've also removed mariadb stuff from the game server's Dockerfile.

Resolved #1
2021-03-03 23:32:27 +00:00
Kayne Ruse b0ac371a43 Tweaking deployment startup, read more
MariaDB's root password needs to be hidden from the game server's container,
so I've changed the startup logic. MariaDB supposedly runs certain files that
are found in a specific directory, so I'm using volumes to place startup.sql
there.

This needs testing on a mock deployment server.

Related to #1
2021-03-04 06:15:46 +11:00
Kayne Ruse 0dcd092856 Merge remote-tracking branch 'refs/remotes/origin/main' into main 2021-03-04 06:05:48 +11:00
Kayne Ruse 06949d384a Added CHAT_KEY to game and chat servers
Resolved #2
2021-03-04 06:05:13 +11:00
Kayne Ruse 69b82fce3f Update README.md 2021-03-03 11:21:07 +11:00
Kayne Ruse 2c9ef261c1 Update README.md 2021-03-03 11:18:32 +11:00
Kayne Ruse 9a7e9313d8 Implemented username reserve feature 2021-03-03 04:27:48 +11:00
Kayne Ruse 34a5444705 Updated README.md 2021-02-28 00:44:43 +11:00
55 changed files with 8610 additions and 7967 deletions
+8 -10
View File
@@ -1,17 +1,15 @@
WEB_PROTOCOL=http
WEB_ADDRESS=localhost
WEB_PORT=3000 WEB_PORT=3000
MAIL_SMTP=smtp.example.com DB_HOSTNAME=localhost
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
# Select a "TZ database name" that suits your needs: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
DB_TIMEZONE=Australia/Sydney DB_TIMEZONE=Australia/Sydney
SESSION_SECRET=secret # Give this any value to enable database logging (such as "true")
SESSION_ADMIN=adminsecret DB_LOGGING=
# Make sure this value matches the system that you connect to
SECRET_ACCESS=access
+91 -82
View File
@@ -1,82 +1,91 @@
# MERN-template # MERN-template
A website template using the MERN stack. A website template using the MERN stack. The primary technology involved is:
# Setup Development * React
* Nodejs
To set up this template, please ensure mariadb is running on the host computer, and run `npm install` as normal. * MariaDB (with Sequelize)
* Docker (with docker-compose)
1. Run `sql/create_database.sql`
2. Run `cp .envdev .env` and enter your details into the new file This template is designed to support the development of persistent browser based games (PBBGs), but it, and it's component microservices, can be used elsewhere.
3. Execute `npm run dev`
This template is released under the zlib license (see LICENSE).
This should get the template working in development mode.
See the [github wiki](https://github.com/krgamestudios/MERN-template/wiki) for full documentation.
# Setup Deployment
# Microservices
Eventually, a clean install will be this easy:
There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React components that access them. These are also available via [docker hub](https://hub.docker.com/u/krgamestudios).
```
git clone https://github.com/krgamestudios/MERN-template.git * News Server: https://github.com/krgamestudios/news-server
npm run configure * Auth Server: https://github.com/krgamestudios/auth-server
docker-compose up --build * Chat Server: https://github.com/krgamestudios/chat-server
```
# Setup Deployment
# Microservices
A clean install is this easy:
There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React component that accesses them.
```
* News Server: https://github.com/krgamestudios/news-server git clone https://github.com/krgamestudios/MERN-template.git
* Chat Server: Coming soon... cd MERN-template
npm install
# TODO list node configure-script.js
docker-compose up --build
- ~~Legal Requirements:~~ ```
- ~~Physical Mailing Address Config (for emails)~~
- ~~Opt-out option (for emails)~~ # Setup Development
- ~~Privacy policy & data collection notices~~
- ~~LICENSE file~~ To set up this template in development mode:
- ~~annoying "This site uses cookies" message~~
- Account system 1. Ensure mariadb is running in your development environment
- ~~sign up~~ 2. Run `mariadb sql/create_database.sql` as the root user
- ~~validate email~~ 3. Run `npm install`
- ~~login (with cookies)~~ 4. Run `cp .envdev .env` and enter your details into the `.env` file
- ~~logout (with cookies)~~ 5. Execute `npm run dev`
- ~~account deletion~~ 6. Navigate to `http://localhost:3001` in your web browser
- ~~Change passwords~~
- Administration Panel # Features List
- ~~Default admin account~~
- ~~Exclusive to admin accounts~~ - Mainly one language across the codebase (JavaScript)
- inspect aggregate user data - Full documentation
- ~~News blog system (microservice)~~ - Setup tutorial
- ~~build the microservice to provide the news feed~~ - Fully Featured Account System (as a microservice)
- ~~access an external news feed~~ - Email validation
- ~~admin panel for publishing and editing news~~ - Logging in and out
- ~~"created at" and "updated at" in the response~~ - Account deletion
- Chat system (microservice) - Password management
- Based on usernames - JSON web token authentication
- Chat logs - Fully Featured News Blog (as a microservice)
- Direct Messages & rooms - Publish, edit or delete articles as needed
- admin panel banning/unbanning (currently borked) - Secured via admin panel
- ~~Configuraton Script:~~ - Fully Featured Chat System (as a microservice)
- ~~Default UUID keys~~ - Available when logged in
- ~~Docker, docker, docker.~~ - Chat logs saved to the database
- Better compression for client files - Room-based chat (type `/room name` to access a specific room)
- Full tutorial for setting up and using the site - Moderation tools
- Start here page - Permanently banning users
- Security holes - Chat-muting users for a time period
- HTTPS - Users reporting offensive chat-content
- Default admin account - Easy To Use Configuration Script
- Information about legal requirements of the developers using this template - Sets up everything via docker
- Privacy policy & data collection notices - A default admin account (if desired)
# Email settings # Coming Soon
Some of the external requirements can be tricky, so let me outline what is needed. If you decide to use gmail as your email provider, then use the following `.env` settings: - Full documentation
- Modding tutorials
MAIL_SMTP=smtp.gmail.com
MAIL_USERNAME=you@gmail.com # Coming Eventually
MAIL_PASSWORD=yourpassword
- Fully Featured News Blog (as a microservice)
you'll also need to enable "less secure apps" for the specified email address. Remember - don't ever commit the `.env` file! You might even want to create a dedicated email address just for your project. - Restore deleted articles
- Undo edits
- Fully Featured Chat System (as a microservice)
- Custom emoji
- Private messaging
- Broadcasting to all channels
- Badges next to usernames
- Better compression for client files
- Backend for leaderboards (modding tutorial?)
- Backend for energy systems (modding tutorial?)
- Backend for items, shops, trading and currency
+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')
); );
+13 -23
View File
@@ -1,37 +1,22 @@
//react //react
import React, { useState } from 'react'; import React, { useContext } from 'react';
import { BrowserRouter, Switch } from 'react-router-dom'; import { BrowserRouter, Switch } from 'react-router-dom';
import { useCookies } from 'react-cookie'; import { TokenContext } from './utilities/token-provider';
//library components //library components
import LazyRoute from './lazy-route'; import LazyRoute from './utilities/lazy-route';
import Markdown from './panels/markdown'; import Markdown from './panels/markdown';
//styling //styling
//TODO: styling import //import a styling template here
//common components //common components
import Header from './panels/header.jsx'; import Header from './panels/header';
import Footer from './panels/footer.jsx'; import Footer from './panels/footer';
import PopupChat from './panels/popup-chat';
const App = props => { const App = props => {
//handle cookies prompt const authTokens = useContext(TokenContext);
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 (
@@ -44,13 +29,18 @@ const App = props => {
<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='/recover' component={() => import('./pages/recover')} />
<LazyRoute path='/reset' component={() => import('./pages/reset')} />
<LazyRoute path='/admin' component={() => import('./pages/admin')} /> <LazyRoute path='/admin' component={() => import('./pages/admin')} />
<LazyRoute path='/mod' component={() => import('./pages/mod')} />
<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} />} />
<LazyRoute path='*' component={() => import('./pages/not-found')} /> <LazyRoute path='*' component={() => import('./pages/not-found')} />
</Switch> </Switch>
{ authTokens.accessToken ? <PopupChat /> : <></> }
<Footer /> <Footer />
</BrowserRouter> </BrowserRouter>
); );
-39
View File
@@ -1,39 +0,0 @@
import React from 'react';
import { Route } from 'react-router-dom';
import Loadable from 'react-loadable';
const Loading = props => {
if (props.error) {
return <p>{props.error}</p>;
}
if (props.timedOut) {
return (
<div className='page'>
<p>Page Timed Out</p>
</div>
);
}
if (props.pastDelay) {
return (
<div className='page'>
<p>Page Loading...</p>
</div>
);
}
return null;
};
const LazyRoute = lazyProps => {
const component = Loadable({
loader: lazyProps.component,
loading: Loading,
timeout: 10000
});
return <Route {...lazyProps} component={component} />
};
export default LazyRoute;
+54 -34
View File
@@ -1,53 +1,68 @@
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}/auth/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] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch);
passwordElement.value = retypeElement.value = '';
if (err) {
alert(err);
return;
}
alert('Details updated');
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,27 +74,32 @@ 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}/auth/account`, {
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];
} }
} }
export default Account; export default Account;
+17 -11
View File
@@ -1,26 +1,32 @@
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 => { import GrantAdmin from '../panels/grant-admin';
const [cookies, setCookie] = useCookies(); import GrantMod from '../panels/grant-mod';
//check for logged in redirect const Admin = props => {
if (!cookies['admin']) { //context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || !authTokens.getPayload().admin) {
return <Redirect to='/' />; return <Redirect to='/' />;
} }
return ( return (
<div className='page'> <div className='page'>
<h1 className='centered'>Administration</h1> <h1 className='centered'>Administration Tools</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 />
<GrantAdmin />
<GrantMod />
</div> </div>
); );
}; };
export default Admin; export default Admin;
+1 -2
View File
@@ -3,11 +3,10 @@ import React from 'react';
import NewsFeed from '../panels/news-feed'; import NewsFeed from '../panels/news-feed';
const HomePage = props => { const HomePage = props => {
//TODO: move the URIs into the config files
return ( return (
<div className='page'> <div className='page'>
<p>This is the MERN template homepage.</p> <p>This is the MERN template homepage.</p>
<NewsFeed uri={process.env.NEWS_URI} /> <NewsFeed />
</div> </div>
); );
}; };
+69 -33
View File
@@ -1,46 +1,52 @@
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 validateEmail = require('../../../common/utilities/validate-email');
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 +55,53 @@ 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();
//generate a new formdata payload const err = handleValidation(email, password);
let formData = new FormData();
formData.append('email', email); if (err) {
formData.append('password', password); return [err, false];
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];
} }
//send to the auth server
const result = await fetch(`${process.env.AUTH_URI}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
email,
password,
})
});
//handle errors
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.error(err);
return [err, false];
}
//return the new auth tokens
const newTokens = await result.json();
return [null, newTokens];
}; };
export default LogIn; //returns an error message, or null on success
const handleValidation = (email, password) => {
if (!validateEmail(email)) {
return 'invalid email';
}
if (password.length < 8) {
return 'invalid password (Must be at least 8 characters long)';
}
return null;
};
export default LogIn;
+27
View File
@@ -0,0 +1,27 @@
import React, { useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
import ChatReports from '../panels/chat-reports';
import BanUser from '../panels/ban-user';
const Mod = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || !(authTokens.getPayload().admin || authTokens.getPayload().mod)) {
return <Redirect to='/' />;
}
return (
<div className='page'>
<h1 className='centered'>Moderation Tools</h1>
<ChatReports />
<BanUser />
</div>
);
};
export default Mod;
+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>
); );
}; };
+91
View File
@@ -0,0 +1,91 @@
import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
//utilities
const validateEmail = require('../../../common/utilities/validate-email');
const Recover = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced?
if (authTokens.accessToken) {
return <Redirect to='/' />;
}
//refs
const emailRef = useRef();
const recoverRef = useRef();
return (
<div className='page'>
<h1 className='centered'>Recover Password</h1>
<form className='constricted' onSubmit={
async evt => { //on submit
recoverRef.current.disabled = true;
evt.preventDefault();
const [result, redirect] = await handleSubmit(emailRef.current.value);
if (result) {
alert(result);
recoverRef.current.disabled = false;
}
//redirect
if (redirect) {
props.history.push('/');
}
}
}>
<div>
<label htmlFor='email'>Enter Your Email:</label>
<input type='email' name='email' ref={emailRef} />
</div>
<button type='submit' ref={recoverRef}>Recover Password</button>
</form>
</div>
);
};
const handleSubmit = async (email) => {
email = email.trim();
const err = handleValidation(email);
if (err) {
return [err];
}
//send to the auth server
const result = await fetch(`${process.env.AUTH_URI}/auth/recover`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
email
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.error(err);
return [err, false];
}
return [await result.text(), true];
};
//returns an error message, or null on success
const handleValidation = (email) => {
if (!validateEmail(email)) {
return 'invalid email';
}
return null;
};
export default Recover;
+89
View File
@@ -0,0 +1,89 @@
import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom';
import queryString from 'query-string';
import { TokenContext } from '../utilities/token-provider';
const Reset = props => {
//context
const authTokens = useContext(TokenContext);
//query
const query = queryString.parse(props.location.search);
//misplaced?
if (authTokens.accessToken || !query.email || !query.token) {
return <Redirect to='/' />;
}
//refs
const passwordRef = useRef();
const retypeRef = useRef();
const resetRef = useRef();
//render the thing
return (
<div className='page'>
<h1 className='centered'>Reset Password</h1>
<form className='constricted' onSubmit={async evt => {
evt.preventDefault();
const [err] = await update(passwordRef.current.value, retypeRef.current.value, query);
if (err) {
alert(err);
return;
}
alert('Details updated');
//redirect
if (redirect) {
props.history.push('/');
}
}}>
<div>
<div>
<label htmlFor='password'>Enter New Password:</label>
<input type='password' name='password' ref={passwordRef} />
</div>
<div>
<label htmlFor='retype'>Retype New Password:</label>
<input type='password' name='retype' ref={retypeRef} />
</div>
</div>
<button type='submit'>Update Information</button>
</form>
</div>
);
};
const update = async (password, retype, query) => {
if (password != retype) {
return ['Passwords do not match'];
}
if (password && password.length < 8) {
return ['Password is too short'];
}
const result = await fetch(`${process.env.AUTH_URI}/auth/reset?email=${query.email}&token=${query.token}`, {
method: 'PATCH',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: password ? password : null,
})
});
if (!result.ok) {
return [`${await result.status}: ${await result.text()}`];
} else {
return [null];
}
}
export default Reset;
+55 -37
View File
@@ -1,63 +1,74 @@
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');
const validateUsername = require('../../../common/utilities/validate-username.js'); const validateUsername = require('../../../common/utilities/validate-username');
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();
const signupRef = 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
signupRef.current.disabled = true;
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) signupRef.current.disabled = 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' ref={signupRef}>Signup</button>
</form> </form>
</div> </div>
); );
@@ -70,24 +81,31 @@ const handleSubmit = async (email, username, password, retype, contact) => {
const err = handleValidation(email, username, password, retype); const err = handleValidation(email, username, password, retype);
if (err) { if (err) {
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}/auth/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
@@ -111,4 +129,4 @@ const handleValidation = (email, username, password, retype) => {
return null; return null;
}; };
export default SignUp; export default SignUp;
+65
View File
@@ -0,0 +1,65 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const BanUser = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Permanently Ban User</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const yes = confirm('Permanently ban this user from the website?');
if (!yes) {
return;
}
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch);
if (err) {
alert(err);
}
if (result) {
usernameRef.current.value = '';
}
}}>Submit</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/banuser`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default BanUser;
-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;
+67
View File
@@ -0,0 +1,67 @@
import React, { useState, useEffect, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
import dateFormat from 'dateformat';
const ChatReports = props => {
const [reports, setReports] = useState([]);
const authTokens = useContext(TokenContext);
useEffect(async () => {
const result = await authTokens.tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'GET',
headers: {
'Access-Control-Allow-Origin': '*'
}
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
setReports(await result.json());
}
}, []);
return (
<table>
<thead>
<tr>
<th>Date</th>
<th>Username</th>
<th>Room Name</th>
<th>Content</th>
<th>Reported By</th>
</tr>
</thead>
<tbody>
{reports.map((report, index) => (
<tr key={index}>
<td>{dateFormat(report.chatlog.createdAt, 'yyyy-mm-dd, H:MM:ss')}</td>
<td>{report.chatlog.username}</td>
<td>{report.chatlog.room}</td>
<td>{report.chatlog.text}</td>
<td>{report.reporter.join(', ')}</td>
<td><button onClick={() => deleteReportsFor(report.chatlogIndex, authTokens.tokenFetch, setReports)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
);
};
const deleteReportsFor = (chatlogIndex, tokenFetch, setReports) => {
tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ chatlogIndex })
});
setReports(reports => reports.filter(report => report.chatlogIndex != chatlogIndex));
};
export default ChatReports;
+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}/auth/account`, {
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}/auth/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;
+74
View File
@@ -0,0 +1,74 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const GrantAdmin = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Grant Admin Privileges</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('admin set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('admin removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/admin`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantAdmin;
+74
View File
@@ -0,0 +1,74 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const GrantMod = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Grant Moderation Privileges</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('mod set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('mod removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/mod`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantMod;
+54 -26
View File
@@ -1,48 +1,76 @@
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> <span> - </span>
<a href='/login'>Log In</a> <Link to='/login'>Log In</Link>
<span> - </span>
<Link to='/recover'>Recover</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> <span> - </span>
<a href='/' onClick={logout}>Log out</a>
{ authTokens.getPayload().admin ?
<span>
<Link to='/admin'>Admin</Link>
<span> - </span>
</span>:
<span />
}
{ authTokens.getPayload().mod ?
<span>
<Link to='/mod'>Moderation</Link>
<span> - </span>
</span>:
<span />
}
{ /* Logout button logs you out of the server too */ }
<Link to='/' onClick={async () => {
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/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>
); );
}; };
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown/with-html'; import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
const Markdown = props => { const Markdown = props => {
//content? //content?
@@ -27,7 +28,7 @@ const Markdown = props => {
} }
return ( return (
<ReactMarkdown escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown> <ReactMarkdown rehypePlugins={[rehypeRaw]} escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown>
); );
}; };
+112 -69
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}/news/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,84 +41,119 @@ 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}/news/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>
<button type='button' onClick={async evt => {
//onDelete
const [err, result] = await handleDelete(index, authTokens.tokenFetch);
if (err) {
alert(err);
return;
}
if (result) {
titleRef.current.value = authorRef.current.value = bodyRef.current.value = '';
alert(`Article deleted`);
}
}}>Delete</button>
</form> </form>
</div> </div>
); );
}; };
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}/news/${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; const handleDelete = async (index, tokenFetch) => {
const conf = confirm('Are you sure you want to delete this article?');
if (conf) {
const result = await tokenFetch(`${process.env.NEWS_URI}/news/${index}`, {
method: 'DELETE'
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
return [err, false];
}
}
return [null, conf];
};
export default NewsEditor;
+29 -16
View File
@@ -1,41 +1,54 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
//DOCS: props.uri is the address of a live news-server
const NewsFeed = props => { const NewsFeed = props => {
const [articles, setArticles] = useState(null); const [articles, setArticles] = useState([]);
const aborter = useRef(new AbortController()); //BUGFIX: double-renders = double fetches + react update after unmount
if (!articles) { useEffect(() => {
fetch(props.uri, { //this... um...
fetch(`${process.env.NEWS_URI}/news`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
}, },
signal: aborter.current.signal //oh dear
}) })
.then(a => { .then(blob => blob.json())
if (!a.ok) { .then(json => setArticles(json))
throw `Network error ${a.status}: ${a.statusText} ${a.url}`; .catch(e => null) //swallow errors
}
return a.json();
})
.then(a => setArticles(a))
.catch(e => console.error(e))
; ;
}
return () => aborter.current.abort(); //This is an ugly, ugly solution, but it's the only one that works
}, []);
return ( return (
<div> <div>
<h1 className='centered'>News Feed</h1> <h1 className='centered'>News Feed</h1>
{(articles || []).map((article, index) => { {(articles || []).map((article, index) => {
//BUGFIX: check for empty data
if (!article.title) {
return article.title = '';
}
if (!article.author) {
return article.author = '';
}
if (!article.body) {
return article.body = '';
}
//render
return ( return (
<div key={index}> <div key={index}>
<hr /> <hr />
<h2>{article.title}</h2> <h2>{article.title}</h2>
<p>Written by <strong>{article.author}</strong>, { <p>Written by <strong>{article.author}</strong>, {
article.edits > 0 ? article.edits > 0 ?
<span>Last Updated {dateFormat(articles.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> : <span>Last Updated {dateFormat(article.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
<span>Published {dateFormat(articles.createdAt, 'fullDate')}</span> <span>Published {dateFormat(article.createdAt, 'fullDate')}</span>
}</p> }</p>
<p style={{whiteSpace: 'pre-wrap'}}>{article.body}</p> <p style={{whiteSpace: 'pre-wrap'}}>{article.body}</p>
</div> </div>
+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}/news`,
{ {
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;
+113
View File
@@ -0,0 +1,113 @@
import React, { useState, useEffect, useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
import { io } from 'socket.io-client';
import '../../styles/popup-chat.css';
const socket = io(`${process.env.CHAT_URI}/chat`);
const PopupChat = props => {
const [open, setOpen] = useState(false);
const [chatlog, setChatlog] = useState([]);
const inputRef = useRef();
const sendRef = useRef();
const endRef = useRef();
const authTokens = useContext(TokenContext);
const pushChatlog = line => setChatlog(prevChatlog => [...prevChatlog, line]);
useEffect(() => {
socket.on('message', message => pushChatlog(message));
socket.on('backlog', messages => setChatlog(prev => [...prev, ...messages]));
socket.on('disconnect', reason => pushChatlog({ emphasis: true, text: 'Lost connection' }));
}, []);
useEffect(() => {
if (open) {
endRef.current.scrollIntoView();
}
}, [chatlog, open]);
if (!open) {
return (
<div className='chat'>
<button type='button' className='open' onClick={() => authTokens.tokenCallback(accessToken => handleOpen(setOpen, accessToken))}>Open Chat</button>
</div>
);
}
return (
<div className='chat'>
<div className='log'>
<ul className='scrollable'>
{chatlog.map((line, index) => processLine(line, index, authTokens.accessToken))}
<li ref={endRef} />
</ul>
</div>
<input type='text' className='input' placeholder='message' onKeyPress={evt => evt.key == 'Enter' ? sendRef.current.click() : ''} ref={inputRef} />
<button type='button' className='send' onClick={() => authTokens.tokenCallback(accessToken => handleSend(inputRef, pushChatlog, authTokens.getPayload().username, accessToken))} ref={sendRef}>Send</button>
<button type='button' className='close' onClick={() => handleClose(setOpen)}>Close Chat</button>
</div>
);
};
//handlers
const handleOpen = (setOpen, accessToken) => {
setOpen(true);
socket.emit('open chat', {
accessToken
});
};
const handleClose = setOpen => {
setOpen(false);
};
const handleSend = (inputRef, pushChatlog, username, accessToken) => {
if (inputRef.current.value == '') {
return;
}
socket.emit('message', {
accessToken,
text: inputRef.current.value
});
if (!inputRef.current.value.startsWith('/')) {
pushChatlog({ username: username, text: inputRef.current.value });
}
inputRef.current.value = '';
};
//render each line
const processLine = (line, index, accessToken) => {
let content = <div className='content'>{line.username ? <span className='username'>{line.username}: </span> : ''}{line.text ? <span className='text'>{line.text}</span> : ''}</div>;
//decorators
if (line.emphasis) {
content = <em>{content}</em>;
}
if (line.strong) {
content = <strong>{content}</strong>;
}
return <li key={index} className='line'>{content}<div className='report'><a onClick={() => processReport(line, accessToken)} style={{ display: line.index && !line.notification ? 'flex' : 'none' }}>!!!</a></div></li>;
};
const processReport = (line, accessToken) => {
const yes = confirm('Report this message?');
if (yes) {
socket.emit('report', {
accessToken,
index: line.index
});
}
};
export default PopupChat;
@@ -0,0 +1,13 @@
import React from 'react';
import { Route } from 'react-router-dom';
import loadable from '@loadable/component';
const LazyRoute = props => {
const { component, ...lazyProps } = props;
const lazyComponent = loadable(component);
return <Route {...lazyProps} component={lazyComponent} />
};
export default LazyRoute;
@@ -0,0 +1,123 @@
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}/auth/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}/auth/logout`) {
return fetch(`${process.env.AUTH_URI}/auth/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: {} }).headers,
'Authorization': `Bearer ${bearer}`
}
});
};
//access the refreshed token via callback
const tokenCallback = async (cb) => {
//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}/auth/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);
//finally
return cb(newAuth.accessToken);
} else {
return cb(accessToken);
}
};
return (
<TokenContext.Provider value={{ accessToken, refreshToken, setAccessToken, setRefreshToken, tokenFetch, tokenCallback, getPayload: () => decode(accessToken) }}>
{props.children}
</TokenContext.Provider>
)
};
export default TokenProvider;
+2 -1
View File
@@ -2,4 +2,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)
+82
View File
@@ -0,0 +1,82 @@
.chat {
position: fixed;
bottom: 23px;
right: 28px;
width: 280px;
border: solid;
border-color: black;
border-width: 2px;
background-color: #CCC;
display: inline-block;
max-height: calc(50vh - 23px);
}
.chat > button.open {
color: white;
background-color: grey;
}
.chat > button.send {
color: white;
background-color: green;
}
.chat > button.close {
color: black;
background-color: red;
}
.chat > button {
width: 100%;
height: 2em;
opacity: 0.8;
}
.chat > button:hover {
opacity: 1;
}
.chat > .input {
width: calc(100% - 10px);
height: 2em;
}
.chat > .log {
min-height: 300px;
}
.chat > .log > .scrollable > .line {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.chat > .log > .scrollable > .line > .report {
color: red;
display: none;
}
.chat > .log > .scrollable > .line:hover {
background-color: #BBB;
}
.chat > .log > .scrollable > .line:hover > .report {
display: flex;
}
.chat > .log > .scrollable > .line > .content > .username {
font-weight: bold;
}
.chat > .log > .scrollable {
margin: 0;
padding: 10px;
min-height: 280px;
max-height: calc(50vh - 23px - 20px - 6em);
overflow-x: wrap;
overflow-y: scroll;
}
.chat ul {
list-style: none;
}
+195 -82
View File
@@ -12,77 +12,128 @@ const rl = readline.createInterface({
}); });
//manually promisify this (util didn't work) //manually promisify this (util didn't work)
const question = (prompt, def) => { const question = (prompt, def = null) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
rl.question(`${prompt} (${def}): `, answer => { rl.question(`${prompt}${def ? ` (${def})` : ''}: `, answer => {
resolve(answer || def); //loop on required
if (def === null && !answer) {
return resolve(question(prompt, def));
}
return resolve(answer || def);
}); });
}); });
}; };
//questions //questions
(async () => { (async () => {
console.log(
`This configure script will generate the following files:
* docker-compose.yml
* Dockerfile
* startup.sql
Currently, all microservices are mandatory; you'll have to mess with the result
and the source code if you wish to be more selective. Microservices currently
impelented are:
* auth-server
* news-server
* chat-server
See https://github.com/krgamestudios/MERN-template/wiki for help.
`
);
//project configuration //project configuration
const projectName = await question('Project Name', 'template'); const projectName = await question('Project Name', 'template');
const projectWebAddress = await question('Project Web Address', 'example.com'); const projectWebAddress = await question('Project Web Address', 'example.com');
const projectMailSMTP = await question('Project Mail SMTP', 'smtp.example.com');
const projectMailUser = await question('Project Mail Username', 'foobar@example.com'); const projectDBUser = await question('Project DB Username', projectName);
const projectMailPass = await question('Project Mail Password', 'foobar'); const projectDBPass = await question('Project DB Password', 'pikachu');
const projectMailPhysical = await question('Project Physical Mailing Address', '');
const projectDBUser = await question('Project Database Username', projectName);
const projectDBPass = await question('Project Database Password', 'pikachu');
//news configuration //news configuration
const newsName = await question('News Name', 'news'); const newsName = await question('News Name', 'news');
const newsWebAddress = await question('News Web Address', 'news.example.com'); const newsWebAddress = await question('News Web Address', `${newsName}.${projectWebAddress}`);
const newsDBUser = await question('News Database Username', newsName); const newsDBUser = await question('News DB Username', newsName);
const newsDBPass = await question('News Database Password', 'charizard'); const newsDBPass = await question('News DB Password', 'venusaur');
const newsKey = await question('News Query Key', uuid());
//TODO: chat configuration //auth configuration
const authName = await question('Auth Name', 'auth');
const authWebAddress = await question('Auth Web Address', `${authName}.${projectWebAddress}`);
const authResetAddress = await question('Auth Reset Addr', `${projectWebAddress}/reset`);
const authDBUser = await question('Auth DB Username', authName);
const authDBPass = await question('Auth DB Password', 'charizard');
const emailSMTP = await question('Email SMTP', 'smtp.example.com');
const emailUser = await question('Email Address', 'foobar@example.com');
const emailPass = await question('Email Password', 'foobar');
const emailPhysical = await question('Physical Mailing Address', '');
//chat goes here
const chatName = await question('Chat Name', 'chat');
const chatWebAddress = await question('Chat Web Address', `${chatName}.${projectWebAddress}`);
const chatDBUser = await question('Chat DB Username', chatName);
const chatDBPass = await question('Chat DB Password', 'blastoise');
//database configuration //database configuration
const databaseRootPassword = await question('Database Root Password', 'password'); const dbRootPassword = await question('Database Root Password', 'password');
const databaseTimeZone = await question('Database Timezone', 'Australia/Sydney'); const dbTimeZone = await question('Database Timezone', 'Australia/Sydney');
//joint configuration
const accessToken = await question('Access Token Secret', uuid(32));
const refreshToken = await question('Refresh Token Secret', uuid(32));
console.log('--Leave "Default User" blank if you don\'t want one--');
const defaultUser = await question('Default Admin User', '');
//MUST be at least 8 chars
let tmpPass = '';
while (defaultUser && tmpPass.length < 8) {
console.log('--All passwords must be at least 8 characters long--');
tmpPass = await question('Default Admin Pass', '');
}
const defaultPass = tmpPass;
if (defaultUser) {
console.log(`Default user email will be: ${defaultUser}@${authWebAddress}`);
}
//traefic configuration //traefic configuration
const supportEmail = await question('Support Email', projectMailUser); const supportEmail = await question('Support Email', emailUser);
//other random values //misc. configuration
const sessionSecret = uuid(); //for session randomness const projectPort = 3000;
const sessionAdmin = uuid(128); //for checking if user is admin const newsPort = 3100;
const authPort = 3200;
const chatPort = 3300;
const yml = ` const ymlfile = `
version: "3.6" version: "3.6"
services: services:
${projectName}: ${projectName}:
build: . build: .
ports: ports:
- "3000" - "${projectPort}"
labels: labels:
- "traefik.enable=true" - traefik.enable=true
- "traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)" - traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)
- "traefik.http.routers.${projectName}router.entrypoints=websecure" - traefik.http.routers.${projectName}router.entrypoints=websecure
- "traefik.http.routers.${projectName}router.tls.certresolver=myresolver" - traefik.http.routers.${projectName}router.tls.certresolver=myresolver
- "traefik.http.routers.${projectName}router.service=${projectName}service@docker" - traefik.http.routers.${projectName}router.service=${projectName}service@docker
- "traefik.http.services.${projectName}service.loadbalancer.server.port=3000" - traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort}
environment: environment:
- WEB_PROTOCOL=https - WEB_PORT=${projectPort}
- WEB_ADDRESS=${projectWebAddress}
- WEB_PORT=3000
- MAIL_SMTP=${projectMailSMTP}
- MAIL_USERNAME=${projectMailUser}
- MAIL_PASSWORD=${projectMailPass}
- MAIL_PHYSICAL=${projectMailPhysical}
- DB_HOSTNAME=database - DB_HOSTNAME=database
- DB_DATABASE=${projectName} - DB_DATABASE=${projectName}
- DB_USERNAME=${projectDBUser} - DB_USERNAME=${projectDBUser}
- DB_PASSWORD=${projectDBPass} - DB_PASSWORD=${projectDBPass}
- DB_TIMEZONE=${databaseTimeZone} - DB_TIMEZONE=${dbTimeZone}
- SESSION_SECRET=${sessionSecret} - NEWS_URI=https://${newsWebAddress}
- SESSION_ADMIN=${sessionAdmin} - AUTH_URI=https://${authWebAddress}
- NEWS_URI=https://${newsWebAddress}/news - CHAT_URI=https://${chatWebAddress}
- NEWS_KEY=${newsKey} - SECRET_ACCESS=${accessToken}
networks: networks:
- app-network - app-network
depends_on: depends_on:
@@ -92,63 +143,119 @@ services:
${newsName}: ${newsName}:
image: krgamestudios/news-server:latest image: krgamestudios/news-server:latest
ports: ports:
- "3100" - ${newsPort}
labels: labels:
- "traefik.enable=true" - traefik.enable=true
- "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=${newsPort}
environment: environment:
- WEB_PORT=3100 - WEB_PORT=${newsPort}
- 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=${dbTimeZone}
- QUERY_LIMIT=10 - QUERY_LIMIT=10
- QUERY_KEY=${newsKey} - SECRET_ACCESS=${accessToken}
networks: networks:
- app-network - app-network
depends_on: depends_on:
- database - database
- traefik - traefik
#chat: ${authName}:
# image: krgamestudios/chat-server image: krgamestudios/auth-server:latest
# ports: ports:
# - "3200:3200" - ${authPort}
labels:
- traefik.enable=true
- traefik.http.routers.${authName}router.rule=Host(\`${authWebAddress}\`)
- traefik.http.routers.${authName}router.entrypoints=websecure
- traefik.http.routers.${authName}router.tls.certresolver=myresolver
- traefik.http.routers.${authName}router.service=${authName}service@docker
- traefik.http.services.${authName}service.loadbalancer.server.port=${authPort}
environment:
- WEB_PROTOCOL=https
- WEB_ADDRESS=${authWebAddress}
- WEB_RESET_ADDRESS=${authResetAddress}
- WEB_PORT=${authPort}
- DB_HOSTNAME=database
- DB_DATABASE=${authName}
- DB_USERNAME=${authDBUser}
- DB_PASSWORD=${authDBPass}
- DB_TIMEZONE=${dbTimeZone}
- MAIL_SMTP=${emailSMTP}
- MAIL_USERNAME=${emailUser}
- MAIL_PASSWORD=${emailPass}
- MAIL_PHYSICAL=${emailPhysical}
- ADMIN_DEFAULT_USERNAME=${defaultUser}
- ADMIN_DEFAULT_PASSWORD=${defaultPass}
- SECRET_ACCESS=${accessToken}
- SECRET_REFRESH=${refreshToken}
networks:
- app-network
depends_on:
- database
- traefik
${chatName}:
image: krgamestudios/chat-server:latest
ports:
- ${chatPort}
labels:
- traefik.enable=true
- traefik.http.routers.${chatName}router.rule=Host(\`${chatWebAddress}\`)
- traefik.http.routers.${chatName}router.entrypoints=websecure
- traefik.http.routers.${chatName}router.tls.certresolver=myresolver
- traefik.http.routers.${chatName}router.service=${chatName}service@docker
- traefik.http.services.${chatName}service.loadbalancer.server.port=${chatPort}
environment:
- WEB_PORT=${chatPort}
- DB_HOSTNAME=database
- DB_DATABASE=${chatName}
- DB_USERNAME=${chatDBUser}
- DB_PASSWORD=${chatDBPass}
- DB_TIMEZONE=${dbTimeZone}
- SECRET_ACCESS=${accessToken}
networks:
- app-network
depends_on:
- database
- traefik
database: database:
image: mariadb image: mariadb
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: ${databaseRootPassword} MYSQL_ROOT_PASSWORD: ${dbRootPassword}
volumes: volumes:
- ./mysql:/var/lib/mysql - ./mysql:/var/lib/mysql
- ./startup.sql:/docker-entrypoint-initdb.d/startup.sql:ro
networks: networks:
- app-network - app-network
traefik: traefik:
image: "traefik:v2.4" image: traefik:v2.4
container_name: "traefik" container_name: traefik
command: command:
- "--log.level=ERROR" - --log.level=ERROR
- "--api.insecure=false" - --api.insecure=false
- "--providers.docker=true" - --providers.docker=true
- "--providers.docker.exposedbydefault=false" - --providers.docker.exposedbydefault=false
- "--entrypoints.websecure.address=:443" - --entrypoints.websecure.address=:443
- "--certificatesresolvers.myresolver.acme.tlschallenge=true" - --certificatesresolvers.myresolver.acme.tlschallenge=true
- "--certificatesresolvers.myresolver.acme.email=${supportEmail}" - --certificatesresolvers.myresolver.acme.email=${supportEmail}
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json
- " traefik.docker.network=app-network" - traefik.docker.network=app-network
ports: ports:
- "80:80" - 80:80
- "443:443" - 443:443
volumes: volumes:
- "./letsencrypt:/letsencrypt" - ./letsencrypt:/letsencrypt
- "/var/run/docker.sock:/var/run/docker.sock:ro" - /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
- app-network - app-network
@@ -160,18 +267,17 @@ networks:
const dockerfile = ` const dockerfile = `
FROM node:15 FROM node:15
WORKDIR "/app" WORKDIR "/app"
COPY package*.json ./
RUN npm install
RUN apt-get update
RUN apt-get install -y mariadb-client
COPY . /app COPY . /app
EXPOSE 3000 RUN mkdir /app/public
RUN chown node:node /app/public
RUN npm install --production
EXPOSE ${projectPort}
USER node
ENTRYPOINT ["bash", "-c"] ENTRYPOINT ["bash", "-c"]
CMD ["mysql --host=database --user=root --password=${databaseRootPassword} < ./startup.sql && npm start"] CMD ["sleep 10 && npm start"]
`; `;
const scriptfile = ` const sqlfile = `
CREATE DATABASE IF NOT EXISTS ${projectName}; CREATE DATABASE IF NOT EXISTS ${projectName};
CREATE USER IF NOT EXISTS '${projectDBUser}'@'%' IDENTIFIED BY '${projectDBPass}'; CREATE USER IF NOT EXISTS '${projectDBUser}'@'%' IDENTIFIED BY '${projectDBPass}';
GRANT ALL PRIVILEGES ON ${projectName}.* TO '${projectDBUser}'@'%'; GRANT ALL PRIVILEGES ON ${projectName}.* TO '${projectDBUser}'@'%';
@@ -180,14 +286,21 @@ CREATE DATABASE IF NOT EXISTS ${newsName};
CREATE USER IF NOT EXISTS '${newsDBUser}'@'%' IDENTIFIED BY '${newsDBPass}'; CREATE USER IF NOT EXISTS '${newsDBUser}'@'%' IDENTIFIED BY '${newsDBPass}';
GRANT ALL PRIVILEGES ON ${newsName}.* TO '${newsDBUser}'@'%'; GRANT ALL PRIVILEGES ON ${newsName}.* TO '${newsDBUser}'@'%';
CREATE DATABASE IF NOT EXISTS ${authName};
CREATE USER IF NOT EXISTS '${authDBUser}'@'%' IDENTIFIED BY '${authDBPass}';
GRANT ALL PRIVILEGES ON ${authName}.* TO '${authDBUser}'@'%';
CREATE DATABASE IF NOT EXISTS ${chatName};
CREATE USER IF NOT EXISTS '${chatDBUser}'@'%' IDENTIFIED BY '${chatDBPass}';
GRANT ALL PRIVILEGES ON ${chatName}.* TO '${chatDBUser}'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
`; `;
fs.writeFileSync('docker-compose.yml', yml); fs.writeFileSync('docker-compose.yml', ymlfile);
fs.writeFileSync('Dockerfile', dockerfile); fs.writeFileSync('Dockerfile', dockerfile);
fs.writeFileSync('startup.sql', scriptfile); fs.writeFileSync('startup.sql', sqlfile);
})() })()
.then(() => rl.close()) .then(() => rl.close())
.catch(e => console.error(e)) .catch(e => console.error(e))
; ;
-141
View File
@@ -1,141 +0,0 @@
# Setup Tutorial
Last Updated February 15th 2020
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:
https://github.com/krgamestudios/MERN-template
Remember: This project isn't ready for deployment yet - it still has bugs galore.
## The What-Template?
MERN can stand for a few things, but in this case it means "MariaDB, Express, React and Nodejs". This are a series of technologies commonly used to create websites.
I determined that I might want to reuse some parts of the website I was planning to make, so I wrote the template first, to make it easier to reuse. Then, I released it so other people could use it too.
You're currently reading the tutorial on how to set up the template website, both in development mode and deployment mode. This tutorial will likely evolve over time as the template does - and there will likely be other guides going into how the template is built and how to modify it.
## Requirements
There are some requirements for this template, such as required software and a dedicated email. Software needed includes:
* Git
* Nodejs
* MariaDB
* Docker
* docker-compose
You'll also need an email address - if you use google, you'll need to enable "[less secure apps](https://support.google.com/accounts/answer/6010255?hl=en#zippy=%2Cif-less-secure-app-access-is-off-for-your-account%2Cif-less-secure-app-access-is-on-for-your-account)" so external apps can access it. I've only used this site with google so far, but feel free to experiment with other mail hosts.
## Setting Up Development
For development, you'll need Nodejs and MariaDB installed and working. Remember to run `npm install` in the git repo after cloning.
First, run `sql/create_database.sql` on your mariaDB instance - this will create a database called `template` and a user called `template`@`%`. You can of course mess with this, but everything else here assumes you do so consistently.
Next, copy `.envdev` into `.env`, then fill out `.env` with your details. Here's a breakdown of each field and what they mean:
```
WEB_PROTOCOL=http # are you using HTTP or HTTPS?
WEB_ADDRESS=localhost # what is your web domain?
WEB_PORT=3000 # what port is the game running on?
```
The first two are used mainly for the email validation link at the moment - but they should still be configured correctly. `WEB_PORT` is used to specify which port the game operates on - when you reach the webpack stage, you won't access this port directly.
```
MAIL_SMTP=smtp.example.com # SMTP server
MAIL_USERNAME=foobar@example.com # Email to be used
MAIL_PASSWORD=foobar # Password of that email account
MAIL_PHYSICAL=42 Placeholder Ave, Placeholder, 0000, USA # Your physical mailing address
```
Now, I've use exclusively google for this - and it shows. The first argument is usually set to `smtp.gmail.com`. The second is to your email account (a fake one is set in the code, `signup@WEB_ADDRESS`, but google overwrites this). The third is the plaintext password for this account (so you should almost certainly use a dedicated email for this). The fourth is actually included in the validation email itself - it's a legal requirement of the USA's [CAN-SPAM](https://en.wikipedia.org/wiki/CAN-SPAM_Act_of_2003) act of 2003, which goes over my head just a little (I try to appease as many jurisdictions as possible).
```
DB_HOSTNAME=127.0.0.1 # Database address
DB_DATABASE=template # database name
DB_USERNAME=template # database user
DB_PASSWORD=pikachu # database password
DB_TIMEZONE=Australia/Sydney # database timezone
```
This is fairly simple - but if you tinkered in `sql/create_database.sql`, do so here as well. `127.0.0.1` just means "this machine", of course. The timezone is my own timezone, but can be set to [any of these values](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (I think - haven't tested them).
```
SESSION_SECRET=secret
SESSION_ADMIN=adminsecret
```
Finally, these are crucial in production - so much so that they're completely randomized by the configure script. Here, however, you can set them to anything. `SESSION_SECRET` is used by `express-session` to save user session details, while `SESSION_ADMIN` is used as a sort of password for the administrator accounts.
Finally, it's time to run `npm run dev`. This will begin the server in dev mode - it'll use the `concurrently` library to run both client and server in the same window, so you don't need two monitor terminals. If all goes well, the server should start pretty quickly, though the client will take a moment to compile.
When they're both ready, you can access `http://localhost:3001/` in a browser (NOT 3000) and the template site should load.
At this stage, your brand new website will call out to the `dev-news` server for news postings, so you'll get some lorem ipsum, and possibly other content (it's a publicly available server - don't blame me!).
The server should've created a default administration account (and outputted the email and password). By logging in with this, you can access `http://localhost:3001/admin` and post to or edit existing news posts.
From here, you can now start exploring and fiddling with the code. Feel free to contribute any changes via pull requests on github; I'm likely to accept them if they improve the overall experience. Even these docs (and this tutorial) are subject to updates, so check back if you need to.
## Setting Up Deployment
In a perfect world, deploying to a server would be as easy as:
```
git clone https://github.com/krgamestudios/MERN-template.git
npm run configure
docker-compose up --build
```
Sadly, this isn't a perfect world. So let's instead break down what I did for the tentative deployment.
First, you'll need a server with a domain name. I personally pointed both `dev.eggtrainer.com` and `news.eggtrainer.com` at the same server (You might be able to separate the news server later - that's why there's two URLs). I'm using linode for this, but be aware that linode blocks email access until you open a ticket requesting permission to use emails from their servers. It's easy - just don't abuse their goodwill. I ended up using debian as the OS, but anything that runs node and docker should work.
Then, I installed git, node (for npm) and docker-compose on the new server. Then I cloned the MERN-template into the server. Note that I didn't install mariaDB or run `npm install` - docker-compose handles these.
Next I ran `npm run configure`, which takes in a number of arguments and spits out a number of config files. Here's the default prompts:
```
Project Name (template):
Project Web Address (example.com):
Project Mail SMTP (smtp.example.com):
Project Mail Username (foobar@example.com):
Project Mail Password (foobar):
Project Physical Mailing Address (<empty>):
Project Database Username (template):
Project Database Password (pikachu):
News Name (news):
News Web Address (news.example.com):
News Database Username (news):
News Database Password (charizard):
News Query Key (<random>):
Database Root Password (password):
Database Timezone (Australia/Sydney):
Support Email (foobar@example.com):
```
These should generally be fairly self-explanatory, except the values in the parentheses can changed based on previous entries. If you make a mistake here, just re-run this. This script produces three files:
* docker-compose.yml
* Dockerfile
* setup.sql
`setup.sql` is invoked by Dockerfile to create the database if it doesn't already exist, and `docker-compose.yml` invokes Dockerfile, among a number of other built-in containers (mariaDB and news-server). This will update regularly so check back often. If you want to delete any files created by configure, just run `npm run clean`.
Finally, it's time to run `sudo docker-compose up --build`. You might actually need to run it several times, killing the first attempts, as I haven't weeded out certain bugs yet. Remember - it's only in alpha, and not ready for prime time just yet.
You now have a self-contained MERN-template container, mariaDB container, news-server container and [traefik](https://traefik.io/) container.
## Troubleshooting
**Deploying the project didn't work?**
Try again. There are timing issues between the different containers that I still need to sort out. If it still doesn't work after 5-ish attempts, keep reading.
**Sequelize throws an error that a certain field is missing?**
If you just upgraded the template, try checking if any changes to the sequelize models have occured. If so, you'll have to go into the mariaDB container and alter the database directly.
+6922 -6419
View File
File diff suppressed because it is too large Load Diff
+61 -67
View File
@@ -1,67 +1,61 @@
{ {
"name": "mern-template", "name": "mern-template",
"version": "1.0.0", "version": "1.0.2",
"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", "start": "npm run build && node server/server.js",
"clean": "rm docker-compose.yml; rm Dockerfile; rm startup.sql", "build": "npm run build:server && npm run build:client",
"start": "npm run build && node server/server.js", "build:server": "exit 0",
"build": "npm run build:server && npm run build:client", "build:client": "webpack --env=production --config webpack.config.js",
"build:server": "exit 0", "dev": "concurrently npm:watch:server npm:watch:client",
"build:client": "webpack --env=production --config webpack.config.js", "watch:server": "nodemon ./* --ext js,jsx,json --ignore 'node_modules/*'",
"dev": "concurrently npm:watch:server npm:watch:client", "watch:client": "webpack serve --env=development --config webpack.config.js",
"watch:server": "nodemon . --ext js,jsx,json --ignore 'node_modules/*'", "analyzer": "webpack --env=production --analyzer --config webpack.config.js"
"watch:client": "webpack serve --env=development --config webpack.config.js", },
"analyzer": "webpack --env=production --analyzer --config webpack.config.js" "repository": {
}, "type": "git",
"repository": { "url": "git+https://github.com/KRGameStudios/MERN-template.git"
"type": "git", },
"url": "git+https://github.com/KRGameStudios/MERN-template.git" "author": "Kayne Ruse",
}, "license": "ISC",
"author": "Kayne Ruse", "bugs": {
"license": "ISC", "url": "https://github.com/KRGameStudios/MERN-template/issues"
"bugs": { },
"url": "https://github.com/KRGameStudios/MERN-template/issues" "homepage": "https://github.com/KRGameStudios/MERN-template#readme",
}, "dependencies": {
"homepage": "https://github.com/KRGameStudios/MERN-template#readme", "@babel/core": "^7.14.8",
"dependencies": { "@babel/preset-env": "^7.14.8",
"bcryptjs": "^2.4.3", "@babel/preset-react": "^7.14.5",
"connect-session-sequelize": "^7.1.0", "@loadable/component": "^5.15.0",
"cookie-parser": "^1.4.5", "babel-loader": "^8.2.2",
"core-js": "^3.8.3", "clean-webpack-plugin": "^3.0.0",
"dateformat": "^4.5.1", "concurrently": "^6.2.0",
"dotenv": "^8.2.0", "css-loader": "^6.2.0",
"express": "^4.17.1", "dateformat": "^4.5.1",
"express-formidable": "^1.2.0", "dotenv": "^10.0.0",
"express-session": "^1.17.1", "express": "^4.17.1",
"mariadb": "^2.5.2", "html-webpack-plugin": "^5.3.2",
"node-cron": "^2.0.3", "jwt-decode": "^3.1.2",
"nodemailer": "^6.4.17", "mariadb": "^2.5.4",
"react-cookie": "^4.0.3", "query-string": "^7.0.1",
"react-dropdown-select": "^4.7.3", "raw-loader": "^4.0.2",
"react-markdown": "^5.0.3", "react": "^17.0.2",
"regenerator-runtime": "^0.13.7", "react-dom": "^17.0.2",
"sequelize": "^6.4.0" "react-dropdown-select": "^4.7.4",
}, "react-markdown": "^6.0.2",
"devDependencies": { "react-router": "^5.2.0",
"@babel/core": "^7.12.10", "react-router-dom": "^5.2.0",
"@babel/preset-env": "^7.12.11", "rehype-raw": "^5.1.0",
"@babel/preset-react": "^7.12.10", "sequelize": "^6.6.5",
"babel-loader": "^8.2.2", "socket.io-client": "^4.1.3",
"clean-webpack-plugin": "^3.0.0", "style-loader": "^3.2.1",
"concurrently": "^5.3.0", "webpack": "^5.46.0",
"html-webpack-plugin": "^5.0.0-alpha.14", "webpack-bundle-analyzer": "^4.4.2",
"nodemon": "^2.0.7", "webpack-cli": "^4.7.2"
"raw-loader": "^4.0.2", },
"react": "^17.0.1", "devDependencies": {
"react-dom": "^17.0.1", "nodemon": "^2.0.12",
"react-loadable": "^5.5.0", "webpack-dev-server": "^3.11.2"
"react-router": "^5.2.0", }
"react-router-dom": "^5.2.0", }
"webpack": "^5.15.0",
"webpack-bundle-analyzer": "^4.3.0",
"webpack-cli": "^4.3.1",
"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;
-10
View File
@@ -1,10 +0,0 @@
const route = (req, res) => {
//clear cookies and stored data
req.session.account = null;
res.clearCookie('loggedin');
res.clearCookie('admin');
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;
-25
View File
@@ -1,25 +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 { accounts } = require('../database/models');
const defaultAdminAccount = async () => {
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))
});
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) {
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;
+2 -2
View File
@@ -4,9 +4,9 @@ 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: process.env.DB_LOGGING ? console.log : false
}); });
sequelize.sync(); sequelize.sync();
module.exports = sequelize; module.exports = sequelize;
-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'), //import 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)
});
+7 -26
View File
@@ -1,39 +1,19 @@
//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);
//libraries used here //config
const path = require('path'); app.use(express.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');
const models = require('./database/models'); //invoke all models
app.use(formidable());
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: true,
saveUninitialized: true,
store: new SequelizeStore({
db: database
})
}));
//account management
app.use('/api/accounts', require('./accounts'));
//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')));
@@ -44,6 +24,7 @@ app.get('*', (req, res) => {
}); });
//startup //startup
server.listen(process.env.WEB_PORT || 3000, (err) => { server.listen(process.env.WEB_PORT || 3000, async (err) => {
await database.sync();
console.log(`listening to localhost:${process.env.WEB_PORT || 3000}`); console.log(`listening to localhost:${process.env.WEB_PORT || 3000}`);
}); });
-2
View File
@@ -1,2 +0,0 @@
#This file should be used for altering the database in production - make sure it works!
+10 -4
View File
@@ -18,7 +18,7 @@ module.exports = ({ production, analyzer }) => {
filename: '[name].[chunkhash].js', filename: '[name].[chunkhash].js',
sourceMapFilename: '[name].[chunkhash].js.map' sourceMapFilename: '[name].[chunkhash].js.map'
}, },
devtool: 'eval-source-map', devtool: production ? 'source-map' : 'eval-source-map',
resolve: { resolve: {
extensions: ['.js', '.jsx'] extensions: ['.js', '.jsx']
}, },
@@ -32,11 +32,15 @@ module.exports = ({ production, analyzer }) => {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
presets: ['@babel/preset-env', '@babel/preset-react'], presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['react-loadable/babel', '@babel/plugin-syntax-dynamic-import'] plugins: ['@babel/plugin-syntax-dynamic-import']
} }
} }
] ]
}, },
{
test: /\.(css)$/,
use: ['style-loader', 'css-loader']
},
{ {
test: /\.(md)$/, test: /\.(md)$/,
use: [ use: [
@@ -50,8 +54,10 @@ module.exports = ({ production, analyzer }) => {
plugins: [ plugins: [
new DefinePlugin({ new DefinePlugin({
'process.env': { 'process.env': {
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"http://dev-news.eggtrainer.com:3100/news"', 'PRODUCTION': production,
'NEWS_KEY': production ? `"${process.env.NEWS_KEY}"` : '"key"', 'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"https://dev-news.krgamestudios.com"',
'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : '"https://dev-auth.krgamestudios.com"',
'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : '"https://dev-chat.krgamestudios.com"',
} }
}), }),
new CleanWebpackPlugin({ new CleanWebpackPlugin({