Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb370663d2 | |||
| 462116d980 | |||
| af06ddc06d | |||
| f937ee47db | |||
| 6d0dd419ca | |||
| 2919467dff | |||
| 269caac88c | |||
| 69c297fa74 | |||
| 0f538be3e5 | |||
| f85b6e8793 | |||
| 2af9532930 | |||
| 191da50740 | |||
| f24c7990f6 | |||
| 7a48780f50 | |||
| 33952a9896 | |||
| 8076b0cc40 | |||
| e216474196 | |||
| 2532bf1867 | |||
| 93a3c30e81 | |||
| ae8c82e83a | |||
| bc6a795750 | |||
| 9947ef13c1 | |||
| d3f0b1ac7d | |||
| b5f9c45a1b | |||
| 5189415e1a | |||
| be793ae2ff | |||
| 19f5c20056 | |||
| 7923f51aae | |||
| 793e54e334 | |||
| 4fa54668e6 | |||
| ff0230b77f | |||
| 6b53bee033 | |||
| 4280319443 | |||
| 9788552d0c | |||
| 3fd76375dd | |||
| 9c8ece5c06 | |||
| 9201e9374b | |||
| 8c8b78462f | |||
| 07e578b4c4 | |||
| 732eeb933e | |||
| 104e15d714 | |||
| 2c9871d82a | |||
| f5f44ae9f7 | |||
| 1c3d24575e | |||
| 13e3ce6db8 | |||
| 8561219542 | |||
| e288a43519 | |||
| 211eb460cb | |||
| c2f1cd76e9 | |||
| c629192d04 | |||
| b78b034d6d | |||
| 741d6d163b | |||
| f9df2722e8 | |||
| 11d49f981d | |||
| e930fd2173 | |||
| 8a920c5316 | |||
| d66d0bc9da | |||
| 9e0d58e999 | |||
| 9c294ab961 | |||
| 9b6c5af09d | |||
| 8f3ab27106 | |||
| 253fd494ae | |||
| 34b6a25bb5 | |||
| b6e707d047 | |||
| 457cc85ad4 | |||
| e1a20411a0 | |||
| b8e4b33421 | |||
| 44553836c7 | |||
| b5b1b987b1 | |||
| d29d256e5f | |||
| 7c09ac46da | |||
| e3e5af4af0 | |||
| dccf55c973 | |||
| 488f975e98 | |||
| b0ac371a43 | |||
| 0dcd092856 | |||
| 06949d384a | |||
| 69b82fce3f | |||
| 2c9ef261c1 | |||
| 9a7e9313d8 | |||
| 34a5444705 |
@@ -1,17 +1,15 @@
|
||||
WEB_PROTOCOL=http
|
||||
WEB_ADDRESS=localhost
|
||||
WEB_PORT=3000
|
||||
|
||||
MAIL_SMTP=smtp.example.com
|
||||
MAIL_USERNAME=foobar@example.com
|
||||
MAIL_PASSWORD=foobar
|
||||
MAIL_PHYSICAL=42 Placeholder Ave, Placeholder, 0000, USA
|
||||
|
||||
DB_HOSTNAME=127.0.0.1
|
||||
DB_HOSTNAME=localhost
|
||||
DB_DATABASE=template
|
||||
DB_USERNAME=template
|
||||
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
|
||||
|
||||
SESSION_SECRET=secret
|
||||
SESSION_ADMIN=adminsecret
|
||||
# Give this any value to enable database logging (such as "true")
|
||||
DB_LOGGING=
|
||||
|
||||
# Make sure this value matches the system that you connect to
|
||||
SECRET_ACCESS=access
|
||||
|
||||
@@ -1,82 +1,91 @@
|
||||
# MERN-template
|
||||
|
||||
A website template using the MERN stack.
|
||||
|
||||
# Setup Development
|
||||
|
||||
To set up this template, please ensure mariadb is running on the host computer, and run `npm install` as normal.
|
||||
|
||||
1. Run `sql/create_database.sql`
|
||||
2. Run `cp .envdev .env` and enter your details into the new file
|
||||
3. Execute `npm run dev`
|
||||
|
||||
This should get the template working in development mode.
|
||||
|
||||
# Setup Deployment
|
||||
|
||||
Eventually, a clean install will be this easy:
|
||||
|
||||
```
|
||||
git clone https://github.com/krgamestudios/MERN-template.git
|
||||
npm run configure
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
# Microservices
|
||||
|
||||
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
|
||||
* Chat Server: Coming soon...
|
||||
|
||||
# TODO list
|
||||
|
||||
- ~~Legal Requirements:~~
|
||||
- ~~Physical Mailing Address Config (for emails)~~
|
||||
- ~~Opt-out option (for emails)~~
|
||||
- ~~Privacy policy & data collection notices~~
|
||||
- ~~LICENSE file~~
|
||||
- ~~annoying "This site uses cookies" message~~
|
||||
- Account system
|
||||
- ~~sign up~~
|
||||
- ~~validate email~~
|
||||
- ~~login (with cookies)~~
|
||||
- ~~logout (with cookies)~~
|
||||
- ~~account deletion~~
|
||||
- ~~Change passwords~~
|
||||
- Administration Panel
|
||||
- ~~Default admin account~~
|
||||
- ~~Exclusive to admin accounts~~
|
||||
- inspect aggregate user data
|
||||
- ~~News blog system (microservice)~~
|
||||
- ~~build the microservice to provide the news feed~~
|
||||
- ~~access an external news feed~~
|
||||
- ~~admin panel for publishing and editing news~~
|
||||
- ~~"created at" and "updated at" in the response~~
|
||||
- Chat system (microservice)
|
||||
- Based on usernames
|
||||
- Chat logs
|
||||
- Direct Messages & rooms
|
||||
- admin panel banning/unbanning (currently borked)
|
||||
- ~~Configuraton Script:~~
|
||||
- ~~Default UUID keys~~
|
||||
- ~~Docker, docker, docker.~~
|
||||
- Better compression for client files
|
||||
- Full tutorial for setting up and using the site
|
||||
- Start here page
|
||||
- Security holes
|
||||
- HTTPS
|
||||
- Default admin account
|
||||
- Information about legal requirements of the developers using this template
|
||||
- Privacy policy & data collection notices
|
||||
|
||||
# Email settings
|
||||
|
||||
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:
|
||||
|
||||
MAIL_SMTP=smtp.gmail.com
|
||||
MAIL_USERNAME=you@gmail.com
|
||||
MAIL_PASSWORD=yourpassword
|
||||
|
||||
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.
|
||||
|
||||
# MERN-template
|
||||
|
||||
A website template using the MERN stack. The primary technology involved is:
|
||||
|
||||
* React
|
||||
* Nodejs
|
||||
* MariaDB (with Sequelize)
|
||||
* Docker (with docker-compose)
|
||||
|
||||
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.
|
||||
|
||||
This template is released under the zlib license (see LICENSE).
|
||||
|
||||
See the [github wiki](https://github.com/krgamestudios/MERN-template/wiki) for full documentation.
|
||||
|
||||
# Microservices
|
||||
|
||||
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).
|
||||
|
||||
* News Server: https://github.com/krgamestudios/news-server
|
||||
* Auth Server: https://github.com/krgamestudios/auth-server
|
||||
* Chat Server: https://github.com/krgamestudios/chat-server
|
||||
|
||||
# Setup Deployment
|
||||
|
||||
A clean install is this easy:
|
||||
|
||||
```
|
||||
git clone https://github.com/krgamestudios/MERN-template.git
|
||||
cd MERN-template
|
||||
npm install
|
||||
node configure-script.js
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
# Setup Development
|
||||
|
||||
To set up this template in development mode:
|
||||
|
||||
1. Ensure mariadb is running in your development environment
|
||||
2. Run `mariadb sql/create_database.sql` as the root user
|
||||
3. Run `npm install`
|
||||
4. Run `cp .envdev .env` and enter your details into the `.env` file
|
||||
5. Execute `npm run dev`
|
||||
6. Navigate to `http://localhost:3001` in your web browser
|
||||
|
||||
# Features List
|
||||
|
||||
- Mainly one language across the codebase (JavaScript)
|
||||
- Full documentation
|
||||
- Setup tutorial
|
||||
- Fully Featured Account System (as a microservice)
|
||||
- Email validation
|
||||
- Logging in and out
|
||||
- Account deletion
|
||||
- Password management
|
||||
- JSON web token authentication
|
||||
- Fully Featured News Blog (as a microservice)
|
||||
- Publish, edit or delete articles as needed
|
||||
- Secured via admin panel
|
||||
- Fully Featured Chat System (as a microservice)
|
||||
- Available when logged in
|
||||
- Chat logs saved to the database
|
||||
- Room-based chat (type `/room name` to access a specific room)
|
||||
- Moderation tools
|
||||
- Permanently banning users
|
||||
- Chat-muting users for a time period
|
||||
- Users reporting offensive chat-content
|
||||
- Easy To Use Configuration Script
|
||||
- Sets up everything via docker
|
||||
- A default admin account (if desired)
|
||||
|
||||
# Coming Soon
|
||||
|
||||
- Full documentation
|
||||
- Modding tutorials
|
||||
|
||||
# Coming Eventually
|
||||
|
||||
- Fully Featured News Blog (as a microservice)
|
||||
- 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
@@ -1,16 +1,15 @@
|
||||
//polyfills
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { CookiesProvider } from 'react-cookie';
|
||||
|
||||
import App from './components/app';
|
||||
import TokenProvider from './components/utilities/token-provider';
|
||||
|
||||
ReactDOM.render(
|
||||
<CookiesProvider>
|
||||
<TokenProvider>
|
||||
<App />
|
||||
</CookiesProvider>,
|
||||
</TokenProvider>,
|
||||
document.querySelector('#root')
|
||||
);
|
||||
|
||||
+13
-23
@@ -1,37 +1,22 @@
|
||||
//react
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { BrowserRouter, Switch } from 'react-router-dom';
|
||||
import { useCookies } from 'react-cookie';
|
||||
import { TokenContext } from './utilities/token-provider';
|
||||
|
||||
//library components
|
||||
import LazyRoute from './lazy-route';
|
||||
import LazyRoute from './utilities/lazy-route';
|
||||
import Markdown from './panels/markdown';
|
||||
|
||||
//styling
|
||||
//TODO: styling import
|
||||
//import a styling template here
|
||||
|
||||
//common components
|
||||
import Header from './panels/header.jsx';
|
||||
import Footer from './panels/footer.jsx';
|
||||
import Header from './panels/header';
|
||||
import Footer from './panels/footer';
|
||||
import PopupChat from './panels/popup-chat';
|
||||
|
||||
const App = props => {
|
||||
//handle cookies prompt
|
||||
const [cookies, setCookie] = useCookies();
|
||||
|
||||
if (!cookies['accept-cookies']) {
|
||||
const accept = confirm('This website uses cookies to operate correctly. By clicking "ok", you agree to accept said cookies.');
|
||||
|
||||
if (accept) {
|
||||
setCookie('accept-cookies', true);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<p>This website won't operate correctly without cookies.</p>
|
||||
<button onClick={() => window.location.reload()}>Reload Page</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//default render
|
||||
return (
|
||||
@@ -44,13 +29,18 @@ const App = props => {
|
||||
<LazyRoute path='/login' component={() => import('./pages/login')} />
|
||||
<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='/mod' component={() => import('./pages/mod')} />
|
||||
|
||||
<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='*' component={() => import('./pages/not-found')} />
|
||||
</Switch>
|
||||
{ authTokens.accessToken ? <PopupChat /> : <></> }
|
||||
<Footer />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,53 +1,68 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useContext, useRef } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useCookies } from 'react-cookie';
|
||||
|
||||
import { TokenContext } from '../utilities/token-provider';
|
||||
|
||||
import DeleteAccount from '../panels/delete-account';
|
||||
|
||||
const Account = props => {
|
||||
const [cookies, setCookie] = useCookies();
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//check for logged in redirect
|
||||
if (!cookies['loggedin']) {
|
||||
//misplaced?
|
||||
if (!authTokens.accessToken) {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
//refs
|
||||
let contactElement, passwordElement, retypeElement;
|
||||
const passwordRef = useRef();
|
||||
const retypeRef = useRef();
|
||||
const contactRef = useRef();
|
||||
|
||||
//once before render
|
||||
//grab the user's info
|
||||
useEffect(() => {
|
||||
fetch('/api/accounts')
|
||||
authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/account`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
})
|
||||
.then(blob => blob.json())
|
||||
.then(json => {
|
||||
contactElement.checked = json.contact;
|
||||
})
|
||||
.then(json => contactRef.current.checked = json.contact)
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
}, []);
|
||||
|
||||
//render the thing
|
||||
return (
|
||||
<div className='page'>
|
||||
<h1 className='centered'>Account</h1>
|
||||
<form className='constricted' onSubmit={async evt => {
|
||||
evt.preventDefault();
|
||||
await update(contactElement.checked, passwordElement.value, retypeElement.value);
|
||||
passwordElement.value = retypeElement.value = '';
|
||||
const [err] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch);
|
||||
|
||||
if (err) {
|
||||
alert(err);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Details updated');
|
||||
passwordRef.current.value = retypeRef.current.value = '';
|
||||
}}>
|
||||
<div>
|
||||
<div>
|
||||
<label htmlFor='contact'>Allow Promotional Emails:</label>
|
||||
<input type='checkbox' name='contact' ref={e => contactElement = e} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor='password'>Change Password:</label>
|
||||
<input type='password' name='password' ref={e => passwordElement = e} />
|
||||
<input type='password' name='password' ref={passwordRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -59,27 +74,32 @@ const Account = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const update = async (contact, password, retype) => {
|
||||
const update = async (password, retype, contact, tokenFetch) => {
|
||||
if (password != retype) {
|
||||
alert('Passwords do not match');
|
||||
return ['Passwords do not match'];
|
||||
}
|
||||
|
||||
//generate a new formdata payload
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append('contact', contact);
|
||||
|
||||
if (password) {
|
||||
formData.append('password', password);
|
||||
if (password && password.length < 8) {
|
||||
return ['Password is too short'];
|
||||
}
|
||||
|
||||
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) {
|
||||
alert(await result.text());
|
||||
if (!result.ok) {
|
||||
return [`${await result.status}: ${await result.text()}`];
|
||||
} else {
|
||||
alert(await result.text());
|
||||
return [null];
|
||||
}
|
||||
}
|
||||
|
||||
export default Account;
|
||||
export default Account;
|
||||
@@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
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 NewsEditor from '../panels/news-editor';
|
||||
|
||||
const Admin = props => {
|
||||
const [cookies, setCookie] = useCookies();
|
||||
import GrantAdmin from '../panels/grant-admin';
|
||||
import GrantMod from '../panels/grant-mod';
|
||||
|
||||
//check for logged in redirect
|
||||
if (!cookies['admin']) {
|
||||
const Admin = props => {
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//misplaced? (admin only)
|
||||
if (!authTokens.accessToken || !authTokens.getPayload().admin) {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='page'>
|
||||
<h1 className='centered'>Administration</h1>
|
||||
<NewsPublisher uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
|
||||
<NewsEditor uri={process.env.NEWS_URI} newsKey={process.env.NEWS_KEY} />
|
||||
<h1 className='centered'>Administration Tools</h1>
|
||||
<NewsPublisher />
|
||||
<NewsEditor />
|
||||
<GrantAdmin />
|
||||
<GrantMod />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
export default Admin;
|
||||
@@ -3,11 +3,10 @@ import React from 'react';
|
||||
import NewsFeed from '../panels/news-feed';
|
||||
|
||||
const HomePage = props => {
|
||||
//TODO: move the URIs into the config files
|
||||
return (
|
||||
<div className='page'>
|
||||
<p>This is the MERN template homepage.</p>
|
||||
<NewsFeed uri={process.env.NEWS_URI} />
|
||||
<NewsFeed />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import React from 'react';
|
||||
import React, { useContext, useRef } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useCookies } from 'react-cookie';
|
||||
|
||||
//utilities
|
||||
const validateEmail = require('../../../common/utilities/validate-email.js');
|
||||
import { TokenContext } from '../utilities/token-provider';
|
||||
|
||||
const validateEmail = require('../../../common/utilities/validate-email');
|
||||
|
||||
const LogIn = props => {
|
||||
const [cookies, setCookie] = useCookies();
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//check for logged in redirect
|
||||
if (cookies['loggedin']) {
|
||||
//misplaced?
|
||||
if (authTokens.accessToken) {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
//refs
|
||||
let emailElement, passwordElement;
|
||||
const emailRef = useRef();
|
||||
const passwordRef = useRef();
|
||||
|
||||
return (
|
||||
<div className='page'>
|
||||
<h1 className='centered'>Login</h1>
|
||||
<form className='constricted' onSubmit={
|
||||
evt => {
|
||||
async evt => {
|
||||
//on submit
|
||||
evt.preventDefault();
|
||||
handleSubmit(emailElement.value, passwordElement.value)
|
||||
.then(([res, ok]) => {
|
||||
alert(res);
|
||||
if (ok) {
|
||||
window.location.reload(true); //BUFGIX: force reload of the header element
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
const [err, newTokens] = await handleSubmit(emailRef.current.value, passwordRef.current.value);
|
||||
if (err) {
|
||||
alert(err);
|
||||
}
|
||||
|
||||
//save auth tokens and redirect
|
||||
if (newTokens) {
|
||||
authTokens.setAccessToken(newTokens.accessToken);
|
||||
authTokens.setRefreshToken(newTokens.refreshToken);
|
||||
|
||||
props.history.push('/');
|
||||
}
|
||||
}
|
||||
}>
|
||||
<div>
|
||||
<label htmlFor="email">Email:</label>
|
||||
<input type="email" name="email" ref={e => emailElement = e} />
|
||||
<input type="email" name="email" ref={emailRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password">Password:</label>
|
||||
<input type="password" name="password" ref={e => passwordElement = e} />
|
||||
<input type="password" name="password" ref={passwordRef} />
|
||||
</div>
|
||||
|
||||
<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) => {
|
||||
email = email.trim();
|
||||
|
||||
//generate a new formdata payload
|
||||
let formData = new FormData();
|
||||
const err = handleValidation(email, password);
|
||||
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const result = await fetch('/api/accounts/login', { method: 'POST', body: formData });
|
||||
|
||||
if (result.ok) {
|
||||
return [await result.text(), true];
|
||||
} else {
|
||||
return [await result.text(), false];
|
||||
if (err) {
|
||||
return [err, 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;
|
||||
@@ -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;
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
const NotFound = props => {
|
||||
return (
|
||||
<div className='page'>
|
||||
<h1 className='middle centered'>Not Found</h1>
|
||||
<h1 className='middle centered'>Page Not Found</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,63 +1,74 @@
|
||||
import React from 'react';
|
||||
import React, { useContext, useRef } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useCookies } from 'react-cookie';
|
||||
|
||||
import { TokenContext } from '../utilities/token-provider';
|
||||
|
||||
//utilities
|
||||
const validateEmail = require('../../../common/utilities/validate-email.js');
|
||||
const validateUsername = require('../../../common/utilities/validate-username.js');
|
||||
const validateEmail = require('../../../common/utilities/validate-email');
|
||||
const validateUsername = require('../../../common/utilities/validate-username');
|
||||
|
||||
const SignUp = props => {
|
||||
const [cookies, setCookie] = useCookies();
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//check for logged in redirect
|
||||
if (cookies['loggedin']) {
|
||||
//misplaced?
|
||||
if (authTokens.accessToken) {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
//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 (
|
||||
<div className='page'>
|
||||
<h1 className='centered'>Signup</h1>
|
||||
<form className='constricted' onSubmit={
|
||||
evt => {
|
||||
async evt => { //on submit
|
||||
signupRef.current.disabled = true;
|
||||
evt.preventDefault();
|
||||
handleSubmit(emailElement.value, usernameElement.value, passwordElement.value, retypeElement.value, contactElement.checked)
|
||||
.then(res => res ? alert(res) : null)
|
||||
.then(() => emailElement.value = usernameElement.value = passwordElement.value = retypeElement.value = '') //clear input
|
||||
.then(() => contactElement.checked = false)
|
||||
.then(() => props.history.push('/'))
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
const [result, redirect] = await handleSubmit(emailRef.current.value, usernameRef.current.value, passwordRef.current.value, retypeRef.current.value, contactRef.current.checked);
|
||||
if (result) {
|
||||
alert(result);
|
||||
signupRef.current.disabled = false;
|
||||
}
|
||||
|
||||
//redirect
|
||||
if (redirect) {
|
||||
props.history.push('/');
|
||||
}
|
||||
}
|
||||
}>
|
||||
<div>
|
||||
<label htmlFor='email'>Email:</label>
|
||||
<input type='email' name='email' ref={e => emailElement = e} />
|
||||
<input type='email' name='email' ref={emailRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor='username'>Username:</label>
|
||||
<input type='text' name='username' ref={e => usernameElement = e} />
|
||||
<input type='text' name='username' ref={usernameRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor='password'>Password:</label>
|
||||
<input type='password' name='password' ref={e => passwordElement = e} />
|
||||
<input type='password' name='password' ref={passwordRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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={e => contactElement = e} />
|
||||
<input type='checkbox' name='contact' ref={contactRef} />
|
||||
</div>
|
||||
|
||||
<button type='submit'>Signup</button>
|
||||
<button type='submit' ref={signupRef}>Signup</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
@@ -70,24 +81,31 @@ const handleSubmit = async (email, username, password, retype, contact) => {
|
||||
const err = handleValidation(email, username, password, retype);
|
||||
|
||||
if (err) {
|
||||
return err;
|
||||
return [err];
|
||||
}
|
||||
|
||||
//generate a new formdata payload
|
||||
let formData = new FormData();
|
||||
//send to the auth server
|
||||
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);
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
formData.append('contact', contact)
|
||||
|
||||
const result = await fetch('/api/accounts/signup', { method: 'POST', body: formData });
|
||||
|
||||
if (result.ok) {
|
||||
return result.text();
|
||||
} else {
|
||||
return result.text();
|
||||
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
|
||||
@@ -111,4 +129,4 @@ const handleValidation = (email, username, password, retype) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
export default SignUp;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
const DeleteAccount = props => {
|
||||
const authTokens = useContext(TokenContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const passwordRef = useRef();
|
||||
|
||||
if (!open) {
|
||||
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button>
|
||||
}
|
||||
|
||||
let passwordElement;
|
||||
|
||||
return (
|
||||
<form className={props.className} onSubmit={async evt => {
|
||||
evt.preventDefault();
|
||||
const password = passwordElement.value;
|
||||
passwordElement.value = '';
|
||||
await handleSubmit(password);
|
||||
const [err] = await handleSubmit(passwordRef.current.value, authTokens);
|
||||
if (err) {
|
||||
alert(err);
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
<label htmlFor="password">Password:</label>
|
||||
<input type="password" name="password" ref={e => passwordElement = e} />
|
||||
<input type="password" name="password" ref={passwordRef} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (password) => {
|
||||
//generate a new formdata payload
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append('password', password);
|
||||
|
||||
const result = await fetch('/api/accounts/deletion', { method: 'DELETE', body: formData });
|
||||
const handleSubmit = async (password, authTokens) => {
|
||||
//schedule a deletion
|
||||
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/account`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
alert(await result.text());
|
||||
} else {
|
||||
//force logout
|
||||
fetch('/api/accounts/logout', { method: 'POST' })
|
||||
.then(alert(await result.text()))
|
||||
.then(() => window.location.reload(true)) //BUFGIX: force reload of the header element
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
return [`${await result.status}: ${await result.text()}`];
|
||||
}
|
||||
|
||||
//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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,48 +1,76 @@
|
||||
import React from 'react';
|
||||
import { useCookies } from 'react-cookie';
|
||||
import React, { useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { TokenContext } from '../utilities/token-provider';
|
||||
|
||||
const Visitor = () => {
|
||||
return (
|
||||
<div>
|
||||
<a href='/signup'>Sign Up</a>
|
||||
<em> - </em>
|
||||
<a href='/login'>Log In</a>
|
||||
<Link to='/signup'>Sign Up</Link>
|
||||
<span> - </span>
|
||||
<Link to='/login'>Log In</Link>
|
||||
<span> - </span>
|
||||
<Link to='/recover'>Recover</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Member = () => {
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href='/account'>Account</a>
|
||||
<em> - </em>
|
||||
<a href='/' onClick={logout}>Log out</a>
|
||||
<Link to='/account'>Account</Link>
|
||||
<span> - </span>
|
||||
|
||||
{ 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>
|
||||
);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await fetch('/api/accounts/logout', { method: 'POST' })
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
};
|
||||
|
||||
const Header = () => {
|
||||
const [cookies, setCookie] = useCookies(['loggedin']);
|
||||
|
||||
let Options;
|
||||
|
||||
//check for logged in/out status
|
||||
if (cookies['loggedin']) {
|
||||
Options = Member;
|
||||
} else {
|
||||
Options = Visitor;
|
||||
}
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<h1><a href='/'>MERN Template</a></h1>
|
||||
<Options />
|
||||
<h1><Link to='/'>MERN Template</Link></h1>
|
||||
{ authTokens.accessToken ? <Member /> : <Visitor /> }
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 => {
|
||||
//content?
|
||||
@@ -27,7 +28,7 @@ const Markdown = props => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactMarkdown escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]} escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useContext, useRef } from 'react';
|
||||
import Select from 'react-dropdown-select';
|
||||
|
||||
//DOCS: props.uri is the address of a live news-server
|
||||
//DOCS: props.newsKey is the key of the live news-server
|
||||
import { TokenContext } from '../utilities/token-provider';
|
||||
|
||||
const NewsEditor = props => {
|
||||
let titleElement, authorElement, bodyElement;
|
||||
const [articles, setArticles] = useState(null);
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//refs
|
||||
const titleRef = useRef();
|
||||
const authorRef = useRef();
|
||||
const bodyRef = useRef();
|
||||
|
||||
//state
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [index, setIndex] = useState(null);
|
||||
|
||||
if (!articles) {
|
||||
fetch(`${props.uri}/titles?limit=999`, {
|
||||
//run once
|
||||
useEffect(async () => {
|
||||
const result = await fetch(`${process.env.NEWS_URI}/news/metadata?limit=999`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
})
|
||||
.then(a => {
|
||||
if (!a.ok) {
|
||||
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
|
||||
}
|
||||
return a.json();
|
||||
})
|
||||
.then(a => setArticles(a))
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const err = `${result.status}: ${await result.text()}`;
|
||||
console.log(err);
|
||||
alert(err);
|
||||
} else {
|
||||
setArticles(await result.json());
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -33,84 +41,119 @@ const NewsEditor = props => {
|
||||
<div>
|
||||
<label htmlFor='article'>Article: </label>
|
||||
<Select
|
||||
options={(articles || []).map(article => { return { label: article.title, value: article.index }; })}
|
||||
onChange={values => setIndex(fetchSelection(values[0].value, titleElement, authorElement, bodyElement, props.uri))}
|
||||
options={articles.map(article => { return { label: article.title, value: article.index }; })}
|
||||
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>
|
||||
<form onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
await handleSubmit(index, titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
|
||||
titleElement.value = authorElement.value = bodyElement.value = '';
|
||||
|
||||
<form onSubmit={async evt => {
|
||||
//onSubmit
|
||||
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>
|
||||
<label htmlFor='title'>Title: </label>
|
||||
<input type='text' name='title' ref={ e => titleElement = e } />
|
||||
<input type='text' name='title' ref={titleRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor='author'>Author: </label>
|
||||
<input type='text' name='author' ref={ e => authorElement = e } />
|
||||
<input type='text' name='author' ref={authorRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fetchSelection = (index, titleElement, authorElement, bodyElement, uri) => {
|
||||
fetch(`${uri}/archive/${index}`, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
})
|
||||
.then(blob => blob.json())
|
||||
.then(article => {
|
||||
titleElement.value = article.title;
|
||||
authorElement.value = article.author;
|
||||
bodyElement.value = article.body;
|
||||
})
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
|
||||
return index; //this is admittedly odd
|
||||
};
|
||||
|
||||
const handleSubmit = async (index, title, author, body, uri, newsKey) => {
|
||||
const handleSubmit = async (title, author, body, index, tokenFetch) => {
|
||||
title = title.trim();
|
||||
author = author.trim();
|
||||
body = body.trim();
|
||||
uri = uri.trim();
|
||||
newsKey = newsKey.trim();
|
||||
|
||||
//fetch POST json data
|
||||
const raw = await fetch(
|
||||
`${uri}/${index}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
|
||||
}
|
||||
);
|
||||
const result = await tokenFetch(`${process.env.NEWS_URI}/news/${index}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
author,
|
||||
body
|
||||
})
|
||||
});
|
||||
|
||||
if (raw.ok) {
|
||||
const result = await raw.json();
|
||||
|
||||
if (result.ok) {
|
||||
alert(`Updated article index ${index}`);
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
} else {
|
||||
alert(raw.statusText);
|
||||
if (!result.ok) {
|
||||
return [`${result.status}: ${await result.text()}`];
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,41 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
//DOCS: props.uri is the address of a live news-server
|
||||
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) {
|
||||
fetch(props.uri, {
|
||||
useEffect(() => {
|
||||
//this... um...
|
||||
fetch(`${process.env.NEWS_URI}/news`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
signal: aborter.current.signal //oh dear
|
||||
})
|
||||
.then(a => {
|
||||
if (!a.ok) {
|
||||
throw `Network error ${a.status}: ${a.statusText} ${a.url}`;
|
||||
}
|
||||
return a.json();
|
||||
})
|
||||
.then(a => setArticles(a))
|
||||
.catch(e => console.error(e))
|
||||
.then(blob => blob.json())
|
||||
.then(json => setArticles(json))
|
||||
.catch(e => null) //swallow errors
|
||||
;
|
||||
}
|
||||
|
||||
return () => aborter.current.abort(); //This is an ugly, ugly solution, but it's the only one that works
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className='centered'>News Feed</h1>
|
||||
{(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 (
|
||||
<div key={index}>
|
||||
<hr />
|
||||
<h2>{article.title}</h2>
|
||||
<p>Written by <strong>{article.author}</strong>, {
|
||||
article.edits > 0 ?
|
||||
<span>Last Updated {dateFormat(articles.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
|
||||
<span>Published {dateFormat(articles.createdAt, 'fullDate')}</span>
|
||||
<span>Last Updated {dateFormat(article.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
|
||||
<span>Published {dateFormat(article.createdAt, 'fullDate')}</span>
|
||||
}</p>
|
||||
<p style={{whiteSpace: 'pre-wrap'}}>{article.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -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 => {
|
||||
let titleElement, authorElement, bodyElement;
|
||||
//context
|
||||
const authTokens = useContext(TokenContext);
|
||||
|
||||
//refs
|
||||
const titleRef = useRef();
|
||||
const authorRef = useRef();
|
||||
const bodyRef = useRef();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className='centered'>News Publisher</h2>
|
||||
<form onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
await handleSubmit(titleElement.value, authorElement.value, bodyElement.value, props.uri, props.newsKey);
|
||||
titleElement.value = authorElement.value = bodyElement.value = '';
|
||||
<form onSubmit={async evt => {
|
||||
//on submit
|
||||
evt.preventDefault();
|
||||
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>
|
||||
<label htmlFor='title'>Title: </label>
|
||||
<input type='text' name='title' ref={ e => titleElement = e } />
|
||||
<input type='text' name='title' ref={titleRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor='author'>Author: </label>
|
||||
<input type='text' name='author' ref={ e => authorElement = e } />
|
||||
<input type='text' name='author' ref={authorRef} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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();
|
||||
author = author.trim();
|
||||
body = body.trim();
|
||||
uri = uri.trim();
|
||||
newsKey = newsKey.trim();
|
||||
|
||||
//fetch POST json data
|
||||
const raw = await fetch(
|
||||
uri,
|
||||
const result = await tokenFetch(
|
||||
`${process.env.NEWS_URI}/news`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
body: JSON.stringify({ title: title, author: author, body: body, key: newsKey })
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
author,
|
||||
body
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (raw.ok) {
|
||||
const result = await raw.json();
|
||||
|
||||
if (result.ok) {
|
||||
alert(`Published article index ${result.index}`);
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
} else {
|
||||
alert(raw.statusText);
|
||||
if (!result.ok) {
|
||||
return [`${result.status}: ${await result.text()}`];
|
||||
}
|
||||
|
||||
const json = await result.json();
|
||||
|
||||
return [null, json.index];
|
||||
};
|
||||
|
||||
export default NewsPublisher;
|
||||
@@ -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,4 +2,5 @@
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
@@ -12,77 +12,128 @@ const rl = readline.createInterface({
|
||||
});
|
||||
|
||||
//manually promisify this (util didn't work)
|
||||
const question = (prompt, def) => {
|
||||
const question = (prompt, def = null) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
rl.question(`${prompt} (${def}): `, answer => {
|
||||
resolve(answer || def);
|
||||
rl.question(`${prompt}${def ? ` (${def})` : ''}: `, answer => {
|
||||
//loop on required
|
||||
if (def === null && !answer) {
|
||||
return resolve(question(prompt, def));
|
||||
}
|
||||
|
||||
return resolve(answer || def);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
//questions
|
||||
(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
|
||||
const projectName = await question('Project Name', 'template');
|
||||
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 projectMailPass = await question('Project Mail Password', 'foobar');
|
||||
const projectMailPhysical = await question('Project Physical Mailing Address', '');
|
||||
const projectDBUser = await question('Project Database Username', projectName);
|
||||
const projectDBPass = await question('Project Database Password', 'pikachu');
|
||||
|
||||
const projectDBUser = await question('Project DB Username', projectName);
|
||||
const projectDBPass = await question('Project DB Password', 'pikachu');
|
||||
|
||||
//news configuration
|
||||
const newsName = await question('News Name', 'news');
|
||||
const newsWebAddress = await question('News Web Address', 'news.example.com');
|
||||
const newsDBUser = await question('News Database Username', newsName);
|
||||
const newsDBPass = await question('News Database Password', 'charizard');
|
||||
const newsKey = await question('News Query Key', uuid());
|
||||
const newsWebAddress = await question('News Web Address', `${newsName}.${projectWebAddress}`);
|
||||
const newsDBUser = await question('News DB Username', newsName);
|
||||
const newsDBPass = await question('News DB Password', 'venusaur');
|
||||
|
||||
//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
|
||||
const databaseRootPassword = await question('Database Root Password', 'password');
|
||||
const databaseTimeZone = await question('Database Timezone', 'Australia/Sydney');
|
||||
const dbRootPassword = await question('Database Root Password', 'password');
|
||||
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
|
||||
const supportEmail = await question('Support Email', projectMailUser);
|
||||
const supportEmail = await question('Support Email', emailUser);
|
||||
|
||||
//other random values
|
||||
const sessionSecret = uuid(); //for session randomness
|
||||
const sessionAdmin = uuid(128); //for checking if user is admin
|
||||
//misc. configuration
|
||||
const projectPort = 3000;
|
||||
const newsPort = 3100;
|
||||
const authPort = 3200;
|
||||
const chatPort = 3300;
|
||||
|
||||
const yml = `
|
||||
const ymlfile = `
|
||||
version: "3.6"
|
||||
services:
|
||||
${projectName}:
|
||||
build: .
|
||||
ports:
|
||||
- "3000"
|
||||
- "${projectPort}"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)"
|
||||
- "traefik.http.routers.${projectName}router.entrypoints=websecure"
|
||||
- "traefik.http.routers.${projectName}router.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.${projectName}router.service=${projectName}service@docker"
|
||||
- "traefik.http.services.${projectName}service.loadbalancer.server.port=3000"
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.${projectName}router.rule=Host(\`${projectWebAddress}\`)
|
||||
- traefik.http.routers.${projectName}router.entrypoints=websecure
|
||||
- traefik.http.routers.${projectName}router.tls.certresolver=myresolver
|
||||
- traefik.http.routers.${projectName}router.service=${projectName}service@docker
|
||||
- traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort}
|
||||
environment:
|
||||
- WEB_PROTOCOL=https
|
||||
- WEB_ADDRESS=${projectWebAddress}
|
||||
- WEB_PORT=3000
|
||||
- MAIL_SMTP=${projectMailSMTP}
|
||||
- MAIL_USERNAME=${projectMailUser}
|
||||
- MAIL_PASSWORD=${projectMailPass}
|
||||
- MAIL_PHYSICAL=${projectMailPhysical}
|
||||
- WEB_PORT=${projectPort}
|
||||
- DB_HOSTNAME=database
|
||||
- DB_DATABASE=${projectName}
|
||||
- DB_USERNAME=${projectDBUser}
|
||||
- DB_PASSWORD=${projectDBPass}
|
||||
- DB_TIMEZONE=${databaseTimeZone}
|
||||
- SESSION_SECRET=${sessionSecret}
|
||||
- SESSION_ADMIN=${sessionAdmin}
|
||||
- NEWS_URI=https://${newsWebAddress}/news
|
||||
- NEWS_KEY=${newsKey}
|
||||
- DB_TIMEZONE=${dbTimeZone}
|
||||
- NEWS_URI=https://${newsWebAddress}
|
||||
- AUTH_URI=https://${authWebAddress}
|
||||
- CHAT_URI=https://${chatWebAddress}
|
||||
- SECRET_ACCESS=${accessToken}
|
||||
networks:
|
||||
- app-network
|
||||
depends_on:
|
||||
@@ -92,63 +143,119 @@ services:
|
||||
${newsName}:
|
||||
image: krgamestudios/news-server:latest
|
||||
ports:
|
||||
- "3100"
|
||||
- ${newsPort}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)"
|
||||
- "traefik.http.routers.${newsName}router.entrypoints=websecure"
|
||||
- "traefik.http.routers.${newsName}router.tls.certresolver=myresolver"
|
||||
- "traefik.http.routers.${newsName}router.service=newsservice@docker"
|
||||
- "traefik.http.services.${newsName}service.loadbalancer.server.port=3100"
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.${newsName}router.rule=Host(\`${newsWebAddress}\`)
|
||||
- traefik.http.routers.${newsName}router.entrypoints=websecure
|
||||
- traefik.http.routers.${newsName}router.tls.certresolver=myresolver
|
||||
- traefik.http.routers.${newsName}router.service=${newsName}service@docker
|
||||
- traefik.http.services.${newsName}service.loadbalancer.server.port=${newsPort}
|
||||
environment:
|
||||
- WEB_PORT=3100
|
||||
- WEB_PORT=${newsPort}
|
||||
- DB_HOSTNAME=database
|
||||
- DB_DATABASE=news
|
||||
- DB_DATABASE=${newsName}
|
||||
- DB_USERNAME=${newsDBUser}
|
||||
- DB_PASSWORD=${newsDBPass}
|
||||
- DB_TIMEZONE=${databaseTimeZone}
|
||||
- DB_TIMEZONE=${dbTimeZone}
|
||||
- QUERY_LIMIT=10
|
||||
- QUERY_KEY=${newsKey}
|
||||
- SECRET_ACCESS=${accessToken}
|
||||
networks:
|
||||
- app-network
|
||||
depends_on:
|
||||
- database
|
||||
- traefik
|
||||
|
||||
#chat:
|
||||
# image: krgamestudios/chat-server
|
||||
# ports:
|
||||
# - "3200:3200"
|
||||
${authName}:
|
||||
image: krgamestudios/auth-server:latest
|
||||
ports:
|
||||
- ${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:
|
||||
image: mariadb
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${databaseRootPassword}
|
||||
MYSQL_ROOT_PASSWORD: ${dbRootPassword}
|
||||
volumes:
|
||||
- ./mysql:/var/lib/mysql
|
||||
- ./startup.sql:/docker-entrypoint-initdb.d/startup.sql:ro
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.4"
|
||||
container_name: "traefik"
|
||||
image: traefik:v2.4
|
||||
container_name: traefik
|
||||
command:
|
||||
- "--log.level=ERROR"
|
||||
- "--api.insecure=false"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
|
||||
- "--certificatesresolvers.myresolver.acme.email=${supportEmail}"
|
||||
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
|
||||
- " traefik.docker.network=app-network"
|
||||
- --log.level=ERROR
|
||||
- --api.insecure=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --certificatesresolvers.myresolver.acme.tlschallenge=true
|
||||
- --certificatesresolvers.myresolver.acme.email=${supportEmail}
|
||||
- --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json
|
||||
- traefik.docker.network=app-network
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- "./letsencrypt:/letsencrypt"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- ./letsencrypt:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -160,18 +267,17 @@ networks:
|
||||
const dockerfile = `
|
||||
FROM node:15
|
||||
WORKDIR "/app"
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y mariadb-client
|
||||
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"]
|
||||
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 USER IF NOT EXISTS '${projectDBUser}'@'%' IDENTIFIED BY '${projectDBPass}';
|
||||
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}';
|
||||
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;
|
||||
`;
|
||||
|
||||
fs.writeFileSync('docker-compose.yml', yml);
|
||||
fs.writeFileSync('docker-compose.yml', ymlfile);
|
||||
fs.writeFileSync('Dockerfile', dockerfile);
|
||||
fs.writeFileSync('startup.sql', scriptfile);
|
||||
fs.writeFileSync('startup.sql', sqlfile);
|
||||
})()
|
||||
.then(() => rl.close())
|
||||
.catch(e => console.error(e))
|
||||
;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+6922
-6419
File diff suppressed because it is too large
Load Diff
+61
-67
@@ -1,67 +1,61 @@
|
||||
{
|
||||
"name": "mern-template",
|
||||
"version": "1.0.0",
|
||||
"description": "A website template using the MERN stack.",
|
||||
"main": "server/server.js",
|
||||
"scripts": {
|
||||
"configure": "node configure-script.js",
|
||||
"clean": "rm docker-compose.yml; rm Dockerfile; rm startup.sql",
|
||||
"start": "npm run build && node server/server.js",
|
||||
"build": "npm run build:server && npm run build:client",
|
||||
"build:server": "exit 0",
|
||||
"build:client": "webpack --env=production --config webpack.config.js",
|
||||
"dev": "concurrently npm:watch:server npm:watch:client",
|
||||
"watch:server": "nodemon . --ext js,jsx,json --ignore 'node_modules/*'",
|
||||
"watch:client": "webpack serve --env=development --config webpack.config.js",
|
||||
"analyzer": "webpack --env=production --analyzer --config webpack.config.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KRGameStudios/MERN-template.git"
|
||||
},
|
||||
"author": "Kayne Ruse",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/KRGameStudios/MERN-template/issues"
|
||||
},
|
||||
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-session-sequelize": "^7.1.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"core-js": "^3.8.3",
|
||||
"dateformat": "^4.5.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"express-formidable": "^1.2.0",
|
||||
"express-session": "^1.17.1",
|
||||
"mariadb": "^2.5.2",
|
||||
"node-cron": "^2.0.3",
|
||||
"nodemailer": "^6.4.17",
|
||||
"react-cookie": "^4.0.3",
|
||||
"react-dropdown-select": "^4.7.3",
|
||||
"react-markdown": "^5.0.3",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"sequelize": "^6.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.10",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-react": "^7.12.10",
|
||||
"babel-loader": "^8.2.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"concurrently": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.0.0-alpha.14",
|
||||
"nodemon": "^2.0.7",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "mern-template",
|
||||
"version": "1.0.2",
|
||||
"description": "A website template using the MERN stack.",
|
||||
"main": "server/server.js",
|
||||
"scripts": {
|
||||
"start": "npm run build && node server/server.js",
|
||||
"build": "npm run build:server && npm run build:client",
|
||||
"build:server": "exit 0",
|
||||
"build:client": "webpack --env=production --config webpack.config.js",
|
||||
"dev": "concurrently npm:watch:server npm:watch:client",
|
||||
"watch:server": "nodemon ./* --ext js,jsx,json --ignore 'node_modules/*'",
|
||||
"watch:client": "webpack serve --env=development --config webpack.config.js",
|
||||
"analyzer": "webpack --env=production --analyzer --config webpack.config.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KRGameStudios/MERN-template.git"
|
||||
},
|
||||
"author": "Kayne Ruse",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/KRGameStudios/MERN-template/issues"
|
||||
},
|
||||
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.14.8",
|
||||
"@babel/preset-env": "^7.14.8",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@loadable/component": "^5.15.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"concurrently": "^6.2.0",
|
||||
"css-loader": "^6.2.0",
|
||||
"dateformat": "^4.5.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"mariadb": "^2.5.4",
|
||||
"query-string": "^7.0.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropdown-select": "^4.7.4",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"rehype-raw": "^5.1.0",
|
||||
"sequelize": "^6.6.5",
|
||||
"socket.io-client": "^4.1.3",
|
||||
"style-loader": "^3.2.1",
|
||||
"webpack": "^5.46.0",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^4.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.12",
|
||||
"webpack-dev-server": "^3.11.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -4,9 +4,9 @@ const sequelize = new Sequelize(process.env.DB_DATABASE, process.env.DB_USERNAME
|
||||
host: process.env.DB_HOSTNAME,
|
||||
dialect: 'mariadb',
|
||||
timezone: process.env.DB_TIMEZONE,
|
||||
logging: false
|
||||
logging: process.env.DB_LOGGING ? console.log : false
|
||||
});
|
||||
|
||||
sequelize.sync();
|
||||
|
||||
module.exports = sequelize;
|
||||
module.exports = sequelize;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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,5 +1,3 @@
|
||||
module.exports = {
|
||||
bannedEmails: require('./banned-emails'),
|
||||
accounts: require('./accounts'),
|
||||
pendingSignups: require('./pending-signups')
|
||||
//import models
|
||||
}
|
||||
@@ -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
@@ -1,39 +1,19 @@
|
||||
//environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
//libraries
|
||||
const path = require('path');
|
||||
|
||||
//create the server
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const server = require('http').Server(app);
|
||||
|
||||
//libraries used here
|
||||
const path = require('path');
|
||||
const formidable = require('express-formidable');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const session = require('express-session');
|
||||
const SequelizeStore = require("connect-session-sequelize")(session.Store);
|
||||
//config
|
||||
app.use(express.json());
|
||||
|
||||
//database connection
|
||||
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
|
||||
app.use('/', express.static(path.resolve(__dirname, '..', 'public')));
|
||||
@@ -44,6 +24,7 @@ app.get('*', (req, res) => {
|
||||
});
|
||||
|
||||
//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}`);
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#This file should be used for altering the database in production - make sure it works!
|
||||
|
||||
+10
-4
@@ -18,7 +18,7 @@ module.exports = ({ production, analyzer }) => {
|
||||
filename: '[name].[chunkhash].js',
|
||||
sourceMapFilename: '[name].[chunkhash].js.map'
|
||||
},
|
||||
devtool: 'eval-source-map',
|
||||
devtool: production ? 'source-map' : 'eval-source-map',
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx']
|
||||
},
|
||||
@@ -32,11 +32,15 @@ module.exports = ({ production, analyzer }) => {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
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)$/,
|
||||
use: [
|
||||
@@ -50,8 +54,10 @@ module.exports = ({ production, analyzer }) => {
|
||||
plugins: [
|
||||
new DefinePlugin({
|
||||
'process.env': {
|
||||
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"http://dev-news.eggtrainer.com:3100/news"',
|
||||
'NEWS_KEY': production ? `"${process.env.NEWS_KEY}"` : '"key"',
|
||||
'PRODUCTION': production,
|
||||
'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({
|
||||
|
||||
Reference in New Issue
Block a user