Compare commits

..

34 Commits

Author SHA1 Message Date
Kayne Ruse 7a48780f50 Update package.json 2021-07-23 21:25:24 +10:00
Kayne Ruse 33952a9896 Changed chat line id to index 2021-07-23 21:24:19 +10:00
Kayne Ruse 8076b0cc40 Pointed to new dev-services 2021-07-22 21:48:42 +01:00
Kayne Ruse e216474196 Bumped version number 2021-07-15 08:53:57 +10:00
Kayne Ruse 2532bf1867 Updated packages to fix vulnerabilities 2021-07-15 08:52:54 +10:00
Kayne Ruse 93a3c30e81 Fixed the database hostname
It's supposed to point to localhost when developing locally.
2021-04-08 04:21:31 +10:00
Kayne Ruse ae8c82e83a Fixed embedded source-map error 2021-04-07 05:00:16 +10:00
Kayne Ruse bc6a795750 WHOOPS THAT WAS A MISTAKE 2021-04-07 03:18:48 +10:00
Kayne Ruse 9947ef13c1 Added a workaround for a mysql bug 2021-04-07 02:21:44 +10:00
Kayne Ruse d3f0b1ac7d Fixed WEB_PORT setting 2021-04-03 03:59:08 +11:00
Kayne Ruse b5f9c45a1b Updated README.md 2021-03-28 08:56:02 +11:00
Kayne Ruse 5189415e1a Implemented permabans 2021-03-28 08:33:26 +11:00
Kayne Ruse be793ae2ff Chat report table working 2021-03-28 07:58:52 +11:00
Kayne Ruse 19f5c20056 "Fixed" errors on logout, read more
I really don't like this solution.

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

Related to krgamestudios/MERN-template#16
2021-03-22 16:39:44 +11:00
Kayne Ruse 9c8ece5c06 Implemented DB_LOGGING 2021-03-20 05:13:12 +11:00
Kayne Ruse 9201e9374b Updated README.md 2021-03-19 20:39:52 +11:00
Kayne Ruse 8c8b78462f Updated README.md 2021-03-19 18:49:01 +11:00
Kayne Ruse 07e578b4c4 Updated README.md 2021-03-19 18:47:17 +11:00
Kayne Ruse 732eeb933e Tweak 2021-03-18 05:26:21 +11:00
Kayne Ruse 104e15d714 Stupid tabs 2021-03-18 05:21:26 +11:00
Kayne Ruse 2c9871d82a Tweak 2021-03-18 05:17:12 +11:00
Kayne Ruse f5f44ae9f7 Updated configure-script.js with chat info 2021-03-18 05:15:32 +11:00
Kayne Ruse 1c3d24575e Added backlog to chat 2021-03-18 02:40:22 +11:00
Kayne Ruse 13e3ce6db8 Chat is working with a local chat-server 2021-03-17 16:52:14 +11:00
Kayne Ruse 8561219542 Wrote CSS for a chatbox 2021-03-17 03:44:38 +11:00
Kayne Ruse e288a43519 Tweaked README.md 2021-03-17 00:56:33 +11:00
23 changed files with 7634 additions and 4896 deletions
+8 -2
View File
@@ -1,9 +1,15 @@
WEB_PORT=3000
DB_HOSTNAME=database
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
SECRET_ACCESS=access
# 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
+31 -30
View File
@@ -5,18 +5,20 @@ A website template using the MERN stack. The primary technology involved is:
* React
* Nodejs
* MariaDB (with Sequelize)
* Docker
* 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).
* Auth Server: https://github.com/krgamestudios/auth-server
* 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
@@ -42,15 +44,26 @@ To set up this template in development mode:
# Features List
- Fully Featured Account System
- 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
- News Blog
- Optional microservice
- Secure publishing and editing of articles via admin panel
- 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)
@@ -58,31 +71,19 @@ To set up this template in development mode:
# Coming Soon
- Full documentation
- Setup tutorial
- Modding tutorial
- Fully Featured Chat System
- Optional microservice
- Chat logs
- Custom emoji
- Global and room-based chat
- Private messaging?
- Broadcasting to all channels
- Badges next to usernames?
- Moderation tools for banning, suspending, or chat-banning users
- 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 energy systems
- Backend for leaderboards
- Backend for leaderboards (modding tutorial?)
- Backend for energy systems (modding tutorial?)
- Backend for items, shops, trading and currency
# Gmail Email Settings
If you decide to use gmail as your email provider (as I do), 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.
+10 -4
View File
@@ -1,19 +1,23 @@
//react
import React from 'react';
import React, { useContext } from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
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
//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 => {
const authTokens = useContext(TokenContext);
//default render
return (
<BrowserRouter>
@@ -26,12 +30,14 @@ const App = props => {
<LazyRoute path='/account' component={() => import('./pages/account')} />
<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>
);
-39
View File
@@ -1,39 +0,0 @@
import React from 'react';
import { Route } from 'react-router-dom';
import Loadable from 'react-loadable';
const Loading = props => {
if (props.error) {
return <p>{props.error}</p>;
}
if (props.timedOut) {
return (
<div className='page'>
<p>Page Timed Out</p>
</div>
);
}
if (props.pastDelay) {
return (
<div className='page'>
<p>Page Loading...</p>
</div>
);
}
return null;
};
const LazyRoute = lazyProps => {
const component = Loadable({
loader: lazyProps.component,
loading: Loading,
timeout: 10000
});
return <Route {...lazyProps} component={component} />
};
export default LazyRoute;
+7 -4
View File
@@ -5,23 +5,26 @@ import { TokenContext } from '../utilities/token-provider';
import NewsPublisher from '../panels/news-publisher';
import NewsEditor from '../panels/news-editor';
import PrivilegeEditor from '../panels/privilege-editor';
import GrantAdmin from '../panels/grant-admin';
import GrantMod from '../panels/grant-mod';
const Admin = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || authTokens.getPayload().privilege != 'administrator') {
if (!authTokens.accessToken || !authTokens.getPayload().admin) {
return <Redirect to='/' />;
}
return (
<div className='page'>
<h1 className='centered'>Administration</h1>
<h1 className='centered'>Administration Tools</h1>
<NewsPublisher />
<NewsEditor />
<PrivilegeEditor />
<GrantAdmin />
<GrantMod />
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import React, { useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
import ChatReports from '../panels/chat-reports';
import BanUser from '../panels/ban-user';
const Mod = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || !(authTokens.getPayload().admin || authTokens.getPayload().mod)) {
return <Redirect to='/' />;
}
return (
<div className='page'>
<h1 className='centered'>Moderation Tools</h1>
<ChatReports />
<BanUser />
</div>
);
};
export default Mod;
+65
View File
@@ -0,0 +1,65 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const BanUser = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Permanently Ban User</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const yes = confirm('Permanently ban this user from the website?');
if (!yes) {
return;
}
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch);
if (err) {
alert(err);
}
if (result) {
usernameRef.current.value = '';
}
}}>Submit</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/banuser`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default BanUser;
+67
View File
@@ -0,0 +1,67 @@
import React, { useState, useEffect, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
import dateFormat from 'dateformat';
const ChatReports = props => {
const [reports, setReports] = useState([]);
const authTokens = useContext(TokenContext);
useEffect(async () => {
const result = await authTokens.tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'GET',
headers: {
'Access-Control-Allow-Origin': '*'
}
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
setReports(await result.json());
}
}, []);
return (
<table>
<thead>
<tr>
<th>Date</th>
<th>Username</th>
<th>Room Name</th>
<th>Content</th>
<th>Reported By</th>
</tr>
</thead>
<tbody>
{reports.map((report, index) => (
<tr key={index}>
<td>{dateFormat(report.chatlog.createdAt, 'yyyy-mm-dd, H:MM:ss')}</td>
<td>{report.chatlog.username}</td>
<td>{report.chatlog.room}</td>
<td>{report.chatlog.text}</td>
<td>{report.reporter.join(', ')}</td>
<td><button onClick={() => deleteReportsFor(report.chatlogIndex, authTokens.tokenFetch, setReports)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
);
};
const deleteReportsFor = (chatlogIndex, tokenFetch, setReports) => {
tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ chatlogIndex })
});
setReports(reports => reports.filter(report => report.chatlogIndex != chatlogIndex));
};
export default ChatReports;
+74
View File
@@ -0,0 +1,74 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const GrantAdmin = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Grant Admin Privileges</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('admin set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('admin removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/admin`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantAdmin;
+74
View File
@@ -0,0 +1,74 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
const GrantMod = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Grant Moderation Privileges</h2>
<form>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('mod set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('mod removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/mod`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantMod;
+9 -1
View File
@@ -21,7 +21,7 @@ const Member = () => {
<Link to='/account'>Account</Link>
<span> - </span>
{ authTokens.getPayload().privilege == 'administrator' ?
{ authTokens.getPayload().admin ?
<span>
<Link to='/admin'>Admin</Link>
<span> - </span>
@@ -29,6 +29,14 @@ const Member = () => {
<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
+3 -2
View File
@@ -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>
);
};
+12 -12
View File
@@ -1,26 +1,26 @@
import React, { useState, useEffect } 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([]);
const aborter = useRef(new AbortController()); //BUGFIX: double-renders = double fetches + react update after unmount
useEffect(async () => {
const result = await fetch(`${process.env.NEWS_URI}/news`, {
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(blob => blob.json())
.then(json => setArticles(json))
.catch(e => null) //swallow errors
;
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
setArticles(await result.json());
}
return () => aborter.current.abort(); //This is an ugly, ugly solution, but it's the only one that works
}, []);
return (
+113
View File
@@ -0,0 +1,113 @@
import React, { useState, useEffect, useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
import { io } from 'socket.io-client';
import '../../styles/popup-chat.css';
const socket = io(`${process.env.CHAT_URI}/chat`);
const PopupChat = props => {
const [open, setOpen] = useState(false);
const [chatlog, setChatlog] = useState([]);
const inputRef = useRef();
const sendRef = useRef();
const endRef = useRef();
const authTokens = useContext(TokenContext);
const pushChatlog = line => setChatlog(prevChatlog => [...prevChatlog, line]);
useEffect(() => {
socket.on('message', message => pushChatlog(message));
socket.on('backlog', messages => setChatlog(prev => [...prev, ...messages]));
socket.on('disconnect', reason => pushChatlog({ emphasis: true, text: 'Lost connection' }));
}, []);
useEffect(() => {
if (open) {
endRef.current.scrollIntoView();
}
}, [chatlog, open]);
if (!open) {
return (
<div className='chat'>
<button type='button' className='open' onClick={() => authTokens.tokenCallback(accessToken => handleOpen(setOpen, accessToken))}>Open Chat</button>
</div>
);
}
return (
<div className='chat'>
<div className='log'>
<ul className='scrollable'>
{chatlog.map((line, index) => processLine(line, index, authTokens.accessToken))}
<li ref={endRef} />
</ul>
</div>
<input type='text' className='input' placeholder='message' onKeyPress={evt => evt.key == 'Enter' ? sendRef.current.click() : ''} ref={inputRef} />
<button type='button' className='send' onClick={() => authTokens.tokenCallback(accessToken => handleSend(inputRef, pushChatlog, authTokens.getPayload().username, accessToken))} ref={sendRef}>Send</button>
<button type='button' className='close' onClick={() => handleClose(setOpen)}>Close Chat</button>
</div>
);
};
//handlers
const handleOpen = (setOpen, accessToken) => {
setOpen(true);
socket.emit('open chat', {
accessToken
});
};
const handleClose = setOpen => {
setOpen(false);
};
const handleSend = (inputRef, pushChatlog, username, accessToken) => {
if (inputRef.current.value == '') {
return;
}
socket.emit('message', {
accessToken,
text: inputRef.current.value
});
if (!inputRef.current.value.startsWith('/')) {
pushChatlog({ username: username, text: inputRef.current.value });
}
inputRef.current.value = '';
};
//render each line
const processLine = (line, index, accessToken) => {
let content = <div className='content'>{line.username ? <span className='username'>{line.username}: </span> : ''}{line.text ? <span className='text'>{line.text}</span> : ''}</div>;
//decorators
if (line.emphasis) {
content = <em>{content}</em>;
}
if (line.strong) {
content = <strong>{content}</strong>;
}
return <li key={index} className='line'>{content}<div className='report'><a onClick={() => processReport(line, accessToken)} style={{ display: line.index && !line.notification ? 'flex' : 'none' }}>!!!</a></div></li>;
};
const processReport = (line, accessToken) => {
const yes = confirm('Report this message?');
if (yes) {
socket.emit('report', {
accessToken,
index: line.index
});
}
};
export default PopupChat;
@@ -1,70 +0,0 @@
import React, { useState, useRef, useContext } from 'react';
import Select from 'react-dropdown-select';
import { TokenContext } from '../utilities/token-provider';
const PrivilegeEditor = props => {
//context
const authTokens = useContext(TokenContext);
//state
const [privilege, setPrivilege] = useState('normal');
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Privilege Editor</h2>
<form onSubmit={async evt => {
evt.preventDefault();
const [err, result] = await handleSubmit(usernameRef.current.value, privilege, authTokens.tokenFetch);
if (err) {
alert(err);
}
if (result) {
alert('Privilege set');
usernameRef.current.value = '';
}
}}>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<Select
options={[{ label: 'administrator', value: 1 }, { label: 'moderator', value: 2 }, { label: 'alpha', value: 3 }, { label: 'beta', value: 4 }, { label: 'gamma', value: 5 }, { label: 'normal', value: 6 }]}
onChange={values => setPrivilege(values[0].label)}
/>
<button type='submit'>Change</button>
</form>
</div>
);
};
const handleSubmit = async (username, privilege, tokenFetch) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/privilege`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username,
privilege
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default PrivilegeEditor;
@@ -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;
+38 -2
View File
@@ -11,7 +11,7 @@ const TokenProvider = props => {
useEffect(() => {
setAccessToken(localStorage.getItem("accessToken") || '');
setRefreshToken(localStorage.getItem("refreshToken") || '');
}, [])
}, []);
useEffect(() => {
localStorage.setItem("accessToken", accessToken);
@@ -77,8 +77,44 @@ const TokenProvider = props => {
});
};
//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, getPayload: () => decode(accessToken) }}>
<TokenContext.Provider value={{ accessToken, refreshToken, setAccessToken, setRefreshToken, tokenFetch, tokenCallback, getPayload: () => decode(accessToken) }}>
{props.children}
</TokenContext.Provider>
)
+82
View File
@@ -0,0 +1,82 @@
.chat {
position: fixed;
bottom: 23px;
right: 28px;
width: 280px;
border: solid;
border-color: black;
border-width: 2px;
background-color: #CCC;
display: inline-block;
max-height: calc(50vh - 23px);
}
.chat > button.open {
color: white;
background-color: grey;
}
.chat > button.send {
color: white;
background-color: green;
}
.chat > button.close {
color: black;
background-color: red;
}
.chat > button {
width: 100%;
height: 2em;
opacity: 0.8;
}
.chat > button:hover {
opacity: 1;
}
.chat > .input {
width: calc(100% - 10px);
height: 2em;
}
.chat > .log {
min-height: 300px;
}
.chat > .log > .scrollable > .line {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.chat > .log > .scrollable > .line > .report {
color: red;
display: none;
}
.chat > .log > .scrollable > .line:hover {
background-color: #BBB;
}
.chat > .log > .scrollable > .line:hover > .report {
display: flex;
}
.chat > .log > .scrollable > .line > .content > .username {
font-weight: bold;
}
.chat > .log > .scrollable {
margin: 0;
padding: 10px;
min-height: 280px;
max-height: calc(50vh - 23px - 20px - 6em);
overflow-x: wrap;
overflow-y: scroll;
}
.chat ul {
list-style: none;
}
+39 -7
View File
@@ -40,6 +40,7 @@ impelented are:
* auth-server
* news-server
* chat-server
See https://github.com/krgamestudios/MERN-template/wiki for help.
`
@@ -70,6 +71,10 @@ See https://github.com/krgamestudios/MERN-template/wiki for help.
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 dbRootPassword = await question('Database Root Password', 'password');
@@ -101,7 +106,7 @@ See https://github.com/krgamestudios/MERN-template/wiki for help.
const projectPort = 3000;
const newsPort = 3100;
const authPort = 3200;
//const chatPort = 3300;
const chatPort = 3300;
const ymlfile = `
version: "3.6"
@@ -118,7 +123,7 @@ services:
- traefik.http.routers.${projectName}router.service=${projectName}service@docker
- traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort}
environment:
- WEB_PORT=3000
- WEB_PORT=${projectPort}
- DB_HOSTNAME=database
- DB_DATABASE=${projectName}
- DB_USERNAME=${projectDBUser}
@@ -126,6 +131,7 @@ services:
- DB_TIMEZONE=${dbTimeZone}
- NEWS_URI=https://${newsWebAddress}
- AUTH_URI=https://${authWebAddress}
- CHAT_URI=https://${chatWebAddress}
- SECRET_ACCESS=${accessToken}
networks:
- app-network
@@ -143,9 +149,9 @@ services:
- 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=3100
- traefik.http.services.${newsName}service.loadbalancer.server.port=${newsPort}
environment:
- WEB_PORT=3100
- WEB_PORT=${newsPort}
- DB_HOSTNAME=database
- DB_DATABASE=${newsName}
- DB_USERNAME=${newsDBUser}
@@ -192,9 +198,31 @@ services:
depends_on:
- database
- traefik
#chat:
# image: krgamestudios/chat-server
${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
@@ -258,6 +286,10 @@ 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;
`;
+6812 -4581
View File
File diff suppressed because it is too large Load Diff
+33 -29
View File
@@ -1,6 +1,6 @@
{
"name": "mern-template",
"version": "1.0.0",
"version": "1.0.2",
"description": "A website template using the MERN stack.",
"main": "server/server.js",
"scripts": {
@@ -9,7 +9,7 @@
"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: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"
},
@@ -24,34 +24,38 @@
},
"homepage": "https://github.com/KRGameStudios/MERN-template#readme",
"dependencies": {
"@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",
"dateformat": "^4.5.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"html-webpack-plugin": "^5.0.0-alpha.14",
"jwt-decode": "^3.1.2",
"mariadb": "^2.5.2",
"raw-loader": "^4.0.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-dropdown-select": "^4.7.4",
"react-loadable": "^5.5.0",
"react-markdown": "^5.0.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"sequelize": "^6.4.0",
"universal-cookie": "^4.0.4",
"webpack": "^5.15.0",
"webpack-cli": "^4.3.1"
"@babel/core": ">=7.12.10",
"@babel/preset-env": ">=7.12.11",
"@babel/preset-react": ">=7.12.10",
"@loadable/component": ">=5.14.1",
"babel-loader": ">=8.2.2",
"clean-webpack-plugin": ">=3.0.0",
"concurrently": ">=5.3.0",
"css-loader": ">=5.1.3",
"dateformat": ">=4.5.1",
"dotenv": ">=8.2.0",
"express": ">=4.17.1",
"html-webpack-plugin": ">=5.0.0-alpha.14",
"jwt-decode": ">=3.1.2",
"mariadb": ">=2.5.2",
"raw-loader": ">=4.0.2",
"react": ">=17.0.1",
"react-dom": ">=17.0.1",
"react-dropdown-select": ">=4.7.4",
"react-markdown": ">=5.0.3",
"react-router": ">=5.2.0",
"react-router-dom": ">=5.2.0",
"rehype-raw": "^5.1.0",
"sequelize": ">=6.4.0",
"socket.io-client": ">=4.0.0",
"style-loader": ">=2.0.0",
"universal-cookie": ">=4.0.4",
"webpack": ">=5.15.0",
"webpack-cli": ">=4.3.1"
},
"devDependencies": {
"nodemon": "^2.0.7",
"webpack-bundle-analyzer": "^4.3.0",
"webpack-dev-server": "^3.11.2"
"nodemon": ">=2.0.7",
"webpack-bundle-analyzer": ">=4.3.0",
"webpack-dev-server": ">=1.16.5"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ const sequelize = new Sequelize(process.env.DB_DATABASE, process.env.DB_USERNAME
host: process.env.DB_HOSTNAME,
dialect: 'mariadb',
timezone: process.env.DB_TIMEZONE,
logging: false
logging: process.env.DB_LOGGING ? console.log : false
});
sequelize.sync();
+116 -112
View File
@@ -1,112 +1,116 @@
//plugins
const { DefinePlugin } = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//libraries
const path = require('path');
//the exported config function
module.exports = ({ production, analyzer }) => {
return {
mode: production ? "production" : "development",
entry: path.resolve(__dirname, 'client', 'client.jsx'),
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name].[chunkhash].js',
sourceMapFilename: '[name].[chunkhash].js.map'
},
devtool: 'eval-source-map',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules)/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['react-loadable/babel', '@babel/plugin-syntax-dynamic-import']
}
}
]
},
{
test: /\.(md)$/,
use: [
{
loader: 'raw-loader'
},
],
},
]
},
plugins: [
new DefinePlugin({
'process.env': {
'PRODUCTION': production,
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"https://dev-news.eggtrainer.com"',
'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : '"https://dev-auth.eggtrainer.com"',
// 'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : '"https://dev-chat.eggtrainer.com"',
}
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['*', '!content*']
}),
new HtmlWebpackPlugin({
template: './client/template.html',
minify: {
collapseWhitespace: production,
removeComments: production,
removeAttributeQuotes: production
}
}),
new BundleAnalyzerPlugin({
analyzerMode: analyzer ? 'server' : 'disabled'
})
],
devServer: {
contentBase: path.resolve(__dirname, 'public'),
compress: true,
port: 3001,
proxy: {
'/api/': 'http://localhost:3000/'
},
overlay: {
errors: true
},
stats: {
colors: true,
hash: false,
version: false,
timings: false,
assets: false,
chunks: false,
modules: false,
reasons: false,
children: false,
source: false,
errors: true,
errorDetails: false,
warnings: true,
publicPath: false
},
host: '0.0.0.0',
disableHostCheck: true,
clientLogLevel: 'silent',
historyApiFallback: true,
hot: true,
injectHot: true
},
watchOptions: {
ignored: /(node_modules)/
}
}
};
//plugins
const { DefinePlugin } = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//libraries
const path = require('path');
//the exported config function
module.exports = ({ production, analyzer }) => {
return {
mode: production ? "production" : "development",
entry: path.resolve(__dirname, 'client', 'client.jsx'),
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name].[chunkhash].js',
sourceMapFilename: '[name].[chunkhash].js.map'
},
devtool: production ? 'source-map' : 'eval-source-map',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules)/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['@babel/plugin-syntax-dynamic-import']
}
}
]
},
{
test: /\.(css)$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.(md)$/,
use: [
{
loader: 'raw-loader'
},
],
},
]
},
plugins: [
new DefinePlugin({
'process.env': {
'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({
cleanOnceBeforeBuildPatterns: ['*', '!content*']
}),
new HtmlWebpackPlugin({
template: './client/template.html',
minify: {
collapseWhitespace: production,
removeComments: production,
removeAttributeQuotes: production
}
}),
new BundleAnalyzerPlugin({
analyzerMode: analyzer ? 'server' : 'disabled'
})
],
devServer: {
contentBase: path.resolve(__dirname, 'public'),
compress: true,
port: 3001,
proxy: {
'/api/': 'http://localhost:3000/'
},
overlay: {
errors: true
},
stats: {
colors: true,
hash: false,
version: false,
timings: false,
assets: false,
chunks: false,
modules: false,
reasons: false,
children: false,
source: false,
errors: true,
errorDetails: false,
warnings: true,
publicPath: false
},
host: '0.0.0.0',
disableHostCheck: true,
clientLogLevel: 'silent',
historyApiFallback: true,
hot: true,
injectHot: true
},
watchOptions: {
ignored: /(node_modules)/
}
}
};