399 lines
11 KiB
JavaScript
399 lines
11 KiB
JavaScript
import 'babel-polyfill';
|
|
import path from 'path';
|
|
import express from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import bodyParser from 'body-parser';
|
|
import expressJwt from 'express-jwt';
|
|
import expressGraphQL from 'express-graphql';
|
|
import jwt from 'jsonwebtoken';
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom/server';
|
|
import UniversalRouter from 'universal-router';
|
|
import PrettyError from 'pretty-error';
|
|
import Bouncer from 'express-bouncer';
|
|
import App from './components/App';
|
|
import Html from './components/Html';
|
|
import { ErrorPageWithoutStyle } from './routes/error/ErrorPage';
|
|
import errorPageStyle from './routes/error/ErrorPage.css';
|
|
import passport from './core/passport';
|
|
import admin from './core/admin';
|
|
import models from './data/models';
|
|
import schema from './data/schema';
|
|
import routes from './routes';
|
|
import assets from './assets'; // eslint-disable-line import/no-unresolved
|
|
import { port, auth, starters, mains, rsvpEndDate } from './config';
|
|
import Person from './data/models/Person';
|
|
import Rollbar from './core/rollbar';
|
|
import { sendSlackMsg, sendSlackMsgWithDebounce } from './core/slack';
|
|
import sequelize from './data/sequelize';
|
|
|
|
const app = express();
|
|
|
|
Rollbar.init();
|
|
const bouncer = Bouncer(10 * 60 * 1000, 60 * 60 * 1000, 5);
|
|
bouncer.blocked = (req, res, next, remaining) => {
|
|
let time = Math.ceil(remaining / 1000);
|
|
let suffix = 'seconds';
|
|
if (time > 120) {
|
|
time = Math.ceil(time / 60);
|
|
suffix = 'minutes';
|
|
}
|
|
const body = `You have made too many incorrect attempts. Please wait ${time} ${suffix}.`;
|
|
const uAgent = req.headers['user-agent'];
|
|
const ip = req.headers['x-forwarded-for'];
|
|
sendSlackMsg(`Bouncer block\nIP address: ${ip}\nUser agent: ${uAgent}\nDuration: ${time} ${suffix}`, '#wedding-site');
|
|
res.status(429).send(body);
|
|
};
|
|
|
|
// Tell any CSS tooling (such as Material UI) to use all vendor prefixes if the
|
|
// user agent is not known.
|
|
// -----------------------------------------------------------------------------
|
|
global.navigator = global.navigator || {};
|
|
global.navigator.userAgent = global.navigator.userAgent || 'all';
|
|
|
|
//
|
|
// Register Node.js middleware
|
|
// -----------------------------------------------------------------------------
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.use(cookieParser());
|
|
app.use(bodyParser.urlencoded({ extended: true }));
|
|
app.use(bodyParser.json());
|
|
|
|
//
|
|
// Authentication
|
|
// -----------------------------------------------------------------------------
|
|
app.use(expressJwt({
|
|
secret: auth.jwt.secret,
|
|
credentialsRequired: false,
|
|
getToken: req => req.cookies.id_token,
|
|
}));
|
|
app.use(passport.initialize());
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
app.enable('trust proxy');
|
|
}
|
|
app.get('/login/facebook',
|
|
passport.authenticate('facebook', { scope: ['email', 'user_location'], session: false }),
|
|
);
|
|
app.get('/login/facebook/return',
|
|
passport.authenticate('facebook', { failureRedirect: '/login', session: false }),
|
|
(req, res) => {
|
|
const expiresIn = 60 * 60 * 24 * 180; // 180 days
|
|
const token = jwt.sign(req.user, auth.jwt.secret, { expiresIn });
|
|
res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true });
|
|
res.redirect('/');
|
|
},
|
|
);
|
|
|
|
//
|
|
// Register API middleware
|
|
// -----------------------------------------------------------------------------
|
|
app.use('/graphql', expressGraphQL(req => ({
|
|
schema,
|
|
graphiql: process.env.NODE_ENV !== 'production',
|
|
rootValue: { request: req },
|
|
pretty: process.env.NODE_ENV !== 'production',
|
|
})));
|
|
|
|
//
|
|
// Register RSVP routes
|
|
// -----------------------------------------------------------------------------
|
|
// TODO: This could do with cleaning up, to prevent duplicate bouncer tests and Person queries
|
|
|
|
app.get('/:code', bouncer.block, (req, res, next) => {
|
|
try {
|
|
// Hijack router to test if url is a RSVP code
|
|
if (req.params.code.length === 4) {
|
|
// Possibly a password
|
|
Person.findAll({ where: { password: req.params.code } })
|
|
.then(data => {
|
|
if (data.length > 0) {
|
|
// It is a password
|
|
bouncer.reset(req);
|
|
res.redirect(`/rsvp/${req.params.code}`);
|
|
} else {
|
|
next();
|
|
}
|
|
}).catch((err) => {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
});
|
|
} else {
|
|
bouncer.reset(req);
|
|
next();
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post('/rsvp', (req, res) => {
|
|
res.redirect(`/rsvp/${req.body.code}`);
|
|
});
|
|
|
|
app.get('/rsvp/:code', bouncer.block, (req, res, next) => {
|
|
Person.findAll({ where: { password: req.params.code } })
|
|
.then(data => {
|
|
if (data.length > 0) {
|
|
bouncer.reset(req);
|
|
}
|
|
next();
|
|
}).catch((err) => {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
});
|
|
});
|
|
|
|
app.post('/rsvp/save', (req, res) => {
|
|
try {
|
|
if (new Date() > rsvpEndDate) {
|
|
res.json({
|
|
success: false,
|
|
err: 'RSVP end date has elapsed',
|
|
});
|
|
} else {
|
|
const update = {};
|
|
update[req.body.prop] = req.body.value;
|
|
Person.update(update, {
|
|
where: {
|
|
key: req.body.key,
|
|
},
|
|
}).then(() => {
|
|
Person.findOne({
|
|
where: { key: req.body.key },
|
|
}).then(data => {
|
|
if (data.completed) {
|
|
const attending = `\nAttending: ${data.attending ? 'Yes' : 'No'}`;
|
|
const starter = (data.attending && data.starter !== -1) ? `\nStarter: ${starters[data.starter]}` : '';
|
|
const main = (data.attending && data.main !== -1) ? `\nMain: ${mains[data.main]}` : '';
|
|
const dietary = (data.attending && data.dietary) ? `\nDietary requirements: ${data.dietary}` : '';
|
|
|
|
sendSlackMsgWithDebounce(`${data.firstname} ${data.lastname} has saved their RSVP:${attending}${starter}${main}${dietary}`, '#wedding-rsvps', req.body.key);
|
|
}
|
|
res.json({
|
|
success: true,
|
|
prop: req.body.prop,
|
|
});
|
|
}).catch(err => {
|
|
Rollbar.handleError(err);
|
|
res.json({
|
|
success: false,
|
|
err,
|
|
});
|
|
});
|
|
}).catch(err => {
|
|
Rollbar.handleError(err);
|
|
res.json({
|
|
success: false,
|
|
err,
|
|
});
|
|
});
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
res.json({
|
|
success: false,
|
|
err,
|
|
});
|
|
}
|
|
});
|
|
|
|
//
|
|
// Register Admin routes
|
|
// -----------------------------------------------------------------------------
|
|
//
|
|
|
|
app.get('/login', (req, res, next) => {
|
|
try {
|
|
if (req.user && admin(req.user.email)) {
|
|
res.redirect('/admin');
|
|
} else {
|
|
next();
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.use('/admin*', (req, res, next) => {
|
|
try {
|
|
if (!req.user || !admin(req.user.email)) {
|
|
res.redirect(401, '/login');
|
|
} else {
|
|
next();
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post('/admin/person', (req, res, next) => {
|
|
try {
|
|
const data = {
|
|
firstname: req.body.firstname,
|
|
lastname: req.body.lastname,
|
|
email: req.body.email,
|
|
ceremony: req.body.ceremony,
|
|
};
|
|
|
|
if (req.body.password && req.body.password !== '') {
|
|
data.password = req.body.password;
|
|
}
|
|
let promise;
|
|
if (req.body.key) {
|
|
promise = Person.update(data, {
|
|
where: { key: req.body.key },
|
|
});
|
|
} else {
|
|
promise = Person.create(data);
|
|
}
|
|
promise.then(() => {
|
|
res.redirect('/admin/people');
|
|
}).catch(err => {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
});
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post('/admin/person/delete', (req, res, next) => {
|
|
try {
|
|
if (req.body.key) {
|
|
Person.destroy({
|
|
where: { key: req.body.key },
|
|
}).then(() => {
|
|
res.redirect('/admin/people');
|
|
});
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.get('/admin/people/export', (req, res, next) => {
|
|
try {
|
|
Person.findAll()
|
|
.then(data => {
|
|
res.json(data);
|
|
})
|
|
.catch(err => {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
});
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.post('/admin/people/import', (req, res, next) => {
|
|
try {
|
|
if (req.body.data && req.body.data.length > 0) {
|
|
Person.destroy({ where: {} }).then(() => {
|
|
const promises = [];
|
|
req.body.data.map(person => promises.push(Person.create(person)));
|
|
sequelize.Promise.all(promises).then(() => {
|
|
res.json({ success: true });
|
|
});
|
|
}).catch(err => {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
});
|
|
} else {
|
|
res.json({ success: false, error: 'no data' });
|
|
}
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
|
|
//
|
|
// Register server-side rendering middleware
|
|
// -----------------------------------------------------------------------------
|
|
app.get('*', async (req, res, next) => {
|
|
try {
|
|
const css = new Set();
|
|
// Global (context) variables that can be easily accessed from any React component
|
|
// https://facebook.github.io/react/docs/context.html
|
|
const context = {
|
|
// Enables critical path CSS rendering
|
|
// https://github.com/kriasoft/isomorphic-style-loader
|
|
insertCss: (...styles) => {
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
styles.forEach(style => css.add(style._getCss()));
|
|
},
|
|
};
|
|
const route = await UniversalRouter.resolve(routes, {
|
|
path: req.path,
|
|
query: req.query,
|
|
agent: req.headers['user-agent'],
|
|
});
|
|
|
|
if (route.redirect) {
|
|
res.redirect(route.status || 302, route.redirect);
|
|
return;
|
|
}
|
|
|
|
const data = { ...route };
|
|
data.children = ReactDOM.renderToString(<App context={context}>{route.component}</App>);
|
|
data.style = [...css].join('');
|
|
data.scripts = [
|
|
assets.vendor.js,
|
|
assets.client.js,
|
|
];
|
|
if (assets[route.chunk]) {
|
|
data.scripts.push(assets[route.chunk].js);
|
|
}
|
|
|
|
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />);
|
|
res.status(route.status || 200);
|
|
res.send(`<!doctype html>${html}`);
|
|
} catch (err) {
|
|
Rollbar.handleError(err);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
//
|
|
// Error handling
|
|
// -----------------------------------------------------------------------------
|
|
app.use(Rollbar.errorHandler());
|
|
const pe = new PrettyError();
|
|
pe.skipNodeFiles();
|
|
pe.skipPackage('express');
|
|
|
|
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
|
console.log(pe.render(err)); // eslint-disable-line no-console
|
|
const html = ReactDOM.renderToStaticMarkup(
|
|
<Html
|
|
title="Internal Server Error"
|
|
description={err.message}
|
|
style={errorPageStyle._getCss()} // eslint-disable-line no-underscore-dangle
|
|
>
|
|
{ReactDOM.renderToString(<ErrorPageWithoutStyle error={err} />)}
|
|
</Html>,
|
|
);
|
|
res.status(err.status || 500);
|
|
res.send(`<!doctype html>${html}`);
|
|
});
|
|
|
|
//
|
|
// Launch the server
|
|
// -----------------------------------------------------------------------------
|
|
/* eslint-disable no-console */
|
|
models.sync().catch(err => console.error(err.stack)).then(() => {
|
|
app.listen(port, () => {
|
|
console.log(`The server is running at http://localhost:${port}/`);
|
|
});
|
|
});
|
|
/* eslint-enable no-console */
|