522 lines
17 KiB
JavaScript
522 lines
17 KiB
JavaScript
import express from 'express';
|
|
import session from 'express-session';
|
|
import compression from 'compression';
|
|
import bodyParser from 'body-parser';
|
|
import * as sapper from '@sapper/server';
|
|
import mongoose from 'mongoose';
|
|
import passport from 'passport';
|
|
import { Strategy } from 'passport-local';
|
|
import sessionFileStore from 'session-file-store';
|
|
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
|
import fileUpload from 'express-fileupload';
|
|
import nodemailer from 'nodemailer';
|
|
import fs from 'fs';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import useragent from 'useragent';
|
|
import RSS from 'rss';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import Article from './models/article.js';
|
|
import Category from './models/category.js';
|
|
import User from './models/user.js';
|
|
import legacyMiddleware from './legacy/middleware.js';
|
|
import legacyRouter from './legacy/router.js';
|
|
|
|
require('dotenv').config();
|
|
const FileStore = sessionFileStore(session);
|
|
|
|
const { PORT, NODE_ENV, SESSION_SECRET, MONGODB_CONN,
|
|
SMTP_USERNAME, SMTP_PASSWORD, SMTP_SERVER, SMTP_PORT, SMTP_RECIPIENTS } = process.env;
|
|
const dev = NODE_ENV === 'development';
|
|
|
|
mongoose.set('useNewUrlParser', true);
|
|
mongoose.set('useUnifiedTopology', true);
|
|
mongoose.set('useCreateIndex', true);
|
|
mongoose.connect(MONGODB_CONN, function (err) {
|
|
if (err) throw err;
|
|
console.log('Successfully connected to MongoDB');
|
|
});
|
|
|
|
passport.serializeUser((user, cb) => {
|
|
cb(null, user);
|
|
});
|
|
|
|
passport.deserializeUser((obj, cb) => {
|
|
cb(null, obj);
|
|
});
|
|
|
|
passport.use(new Strategy((username, password, done) => {
|
|
User.findOne({ username }, (err, user) => {
|
|
if (err) done(err);
|
|
|
|
if (!user) {
|
|
return done(null, false, { message: 'Incorrect username.' });
|
|
}
|
|
|
|
user.comparePassword(password, (err, match) => {
|
|
if (err) done(err);
|
|
if (!match) {
|
|
return done(null, false, { message: 'Incorrect password.' });
|
|
} else {
|
|
return done(null, user);
|
|
}
|
|
});
|
|
});
|
|
}));
|
|
|
|
const loginAttemptRateLimiter = new RateLimiterMemory({
|
|
points: 5,
|
|
duration: 3600,
|
|
blockDuration: 60
|
|
});
|
|
|
|
const registerRateLimiter = new RateLimiterMemory({
|
|
points: 1,
|
|
duration: 60,
|
|
blockDuration: 60
|
|
});
|
|
|
|
const rateLimiterMiddleware = rl => async function (req, res, next) {
|
|
try {
|
|
await rl.consume(req.ip);
|
|
next();
|
|
} catch (err) {
|
|
res.writeHead(429, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: 'Too Many Requests'
|
|
}));
|
|
}
|
|
};
|
|
|
|
const isAuthor = function(req, res, next) {
|
|
if (req.user) {
|
|
if (req.user.author) {
|
|
next();
|
|
} else {
|
|
res.writeHead(401, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You are not designated as an author.`
|
|
}));
|
|
}
|
|
} else {
|
|
res.writeHead(401, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You are not logged in`
|
|
}));
|
|
}
|
|
};
|
|
|
|
const mailer = nodemailer.createTransport({
|
|
host: SMTP_SERVER,
|
|
port: 587,
|
|
secure: SMTP_PORT === 465,
|
|
auth: {
|
|
user: SMTP_USERNAME,
|
|
pass: SMTP_PASSWORD
|
|
},
|
|
});
|
|
|
|
|
|
var app = express();
|
|
app.set('view engine', 'ejs');
|
|
app.set('views', process.cwd() + '/src/legacy/views');
|
|
|
|
var mainRouter = express.Router();
|
|
mainRouter
|
|
.post('/cms/login',
|
|
// rateLimiterMiddleware(loginAttemptRateLimiter),
|
|
passport.authenticate('local', { failWithError: true }),
|
|
function(req, res, next) {
|
|
// handle success
|
|
return res.redirect('/cms');
|
|
},
|
|
function(err, req, res, next) {
|
|
// handle error
|
|
res.writeHead(err.status || 500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: err.message
|
|
}));
|
|
}
|
|
)
|
|
.get('/cms/logout', (req, res, next) => {
|
|
req.logout();
|
|
req.session.destroy(function (err) {
|
|
if (err) next(err);
|
|
return res.redirect('/');
|
|
});
|
|
})
|
|
.post('/cms/article/:edit?',
|
|
isAuthor,
|
|
async function(req, res, next) {
|
|
try {
|
|
let editArticle;
|
|
if (req.params.edit) {
|
|
editArticle = await Article.findOne({ slug: req.params.edit });
|
|
if (!editArticle) {
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Article to edit not found`
|
|
}));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const { html, title, category, description } = req.body;
|
|
const image = req.files && req.files.image;
|
|
if (!title || (!editArticle && !image) || !html || !category) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You must supply an article title, header image file, category, and content.`
|
|
}));
|
|
return false;
|
|
}
|
|
let ext, filename, url;
|
|
if (image) {
|
|
if (!/^image\//.test(image.mimetype)) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Invalid MIME type for the header image file.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (image.truncated) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Received truncated image file. Try again with a smaller file.`
|
|
}));
|
|
return false;
|
|
}
|
|
ext = image.name.match(/(\.[^.]+)$/)[0];
|
|
filename = crypto.randomBytes(20).toString('hex') + ext;
|
|
url = `/a/${filename}`;
|
|
await image.mv('./static' + url);
|
|
}
|
|
const cat = await Category.findOne({ slug: category });
|
|
if (!cat) {
|
|
res.writeHead(404, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `That category does not exist.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (editArticle) {
|
|
let newObj = {
|
|
html, title, description, category: cat, updated_at: Date.now()
|
|
};
|
|
if (filename) {
|
|
newObj.image = filename;
|
|
}
|
|
await Article.updateOne({ slug: editArticle.slug }, { $set: newObj });
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
slug: editArticle.slug
|
|
}));
|
|
} else {
|
|
const article = await new Article({ html, title, image: filename, category: cat, description, author: req.user._id });
|
|
await article.save();
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
slug: article.slug
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Failed to add article: ${err}`
|
|
}));
|
|
}
|
|
}
|
|
)
|
|
.post('/cms/upload',
|
|
isAuthor,
|
|
async function(req, res, next) {
|
|
try {
|
|
const { upload } = req.files;
|
|
if (!upload) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You must supply a file.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (!/^image\//.test(upload.mimetype)) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Invalid MIME type for the uploaded image.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (upload.truncated) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Received truncated image file. Try again with a smaller file.`
|
|
}));
|
|
return false;
|
|
}
|
|
const ext = upload.name.match(/(\.[^.]+)$/)[0];
|
|
const filename = crypto.randomBytes(20).toString('hex') + ext;
|
|
const url = `/a/${filename}`;
|
|
await upload.mv('./static' + url);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({ url }));
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Failed to upload image: ${err}`
|
|
}));
|
|
}
|
|
}
|
|
)
|
|
.post('/cms/category',
|
|
isAuthor,
|
|
async function(req, res, next) {
|
|
try {
|
|
const { name } = req.body;
|
|
if (!name) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You must supply a category name.`
|
|
}));
|
|
return;
|
|
}
|
|
const cat = await new Category({ name });
|
|
await cat.save();
|
|
const categories = await Category.find();
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify(categories));
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Failed to add category: ${err}`
|
|
}));
|
|
}
|
|
}
|
|
)
|
|
.get('/a/random',
|
|
async function(req, res, next) {
|
|
var articleCount = await Article.countDocuments();
|
|
var random = Math.floor(Math.random() * articleCount);
|
|
var randomArticle = await Article.findOne().skip(random).select('slug');
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify(randomArticle));
|
|
}
|
|
)
|
|
.post('/me/avatar',
|
|
async function(req, res, next) {
|
|
if (!req.user) {
|
|
res.writeHead(401, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You must be logged in to set an avatar.`
|
|
}));
|
|
return false;
|
|
}
|
|
try {
|
|
const { upload } = req.files;
|
|
if (!upload) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `You must supply a file.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (!/^image\//.test(upload.mimetype)) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Invalid MIME type for the uploaded image.`
|
|
}));
|
|
return false;
|
|
}
|
|
if (upload.truncated) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Received truncated image file. Try again with a smaller file.`
|
|
}));
|
|
return false;
|
|
}
|
|
const ext = upload.name.match(/(\.[^.]+)$/)[0];
|
|
const filename = crypto.randomBytes(20).toString('hex') + ext;
|
|
const url = `/u/${filename}`;
|
|
await upload.mv('./static' + url);
|
|
const user = await User.findById(req.user._id);
|
|
req.user.avatar = user.avatar = filename;
|
|
await user.save();
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({ filename }));
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: `Failed to upload image: ${err}`
|
|
}));
|
|
}
|
|
}
|
|
)
|
|
.get('/rss.xml', async function (req, res) {
|
|
let year = new Date().getFullYear();
|
|
let feed = new RSS({
|
|
title: 'HowFeed.biz Articles',
|
|
feed_url: 'http://howfeed.biz/rss.xml',
|
|
site_url: 'http://howfeed.biz/',
|
|
image_url: 'http://howfeed.biz/logo.png',
|
|
language: 'en',
|
|
webMaster: 'webmaster@howfeed.biz',
|
|
copyright: `${year} FemboyFinancial Holdings Co., Ltd. (USA LLC)`
|
|
});
|
|
let articles = await Article.find().populate({
|
|
path: 'author',
|
|
select: 'realname avatar'
|
|
}).populate({
|
|
path: 'category'
|
|
});
|
|
|
|
for (let article of articles) {
|
|
feed.item({
|
|
title: article.title,
|
|
description: article.html,
|
|
url: `http://howfeed.biz/a/${article.slug}`,
|
|
categories: [ article.category.name ],
|
|
author: article.author.realname,
|
|
date: article.created_at,
|
|
enclosure: {
|
|
url: `http://howfeed.biz/a/${article.image}`
|
|
}
|
|
});
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/rss+xml'
|
|
});
|
|
res.end(feed.xml());
|
|
})
|
|
.post('/suggestions', async function (req, res) {
|
|
let { name, title, message } = req.body;
|
|
if (!message) {
|
|
res.writeHead(422, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: 'No message supplied'
|
|
}));
|
|
return false;
|
|
}
|
|
name = name || 'Anonymous';
|
|
title = title || 'Suggestion';
|
|
try {
|
|
await mailer.sendMail({
|
|
from: `"HowFeed Suggestions" <${SMTP_USERNAME}>`,
|
|
to: SMTP_RECIPIENTS,
|
|
subject: title,
|
|
text: `Suggested by ${name}:
|
|
|
|
${message}`
|
|
});
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: 'Your suggestion was delivered.'
|
|
}));
|
|
} catch (err) {
|
|
res.writeHead(500, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
res.end(JSON.stringify({
|
|
message: err.message
|
|
}));
|
|
}
|
|
});
|
|
|
|
app.use(helmet())
|
|
.use(cors())
|
|
.use(bodyParser.json())
|
|
.use(bodyParser.urlencoded({ extended: true }))
|
|
.use(fileUpload({
|
|
limits: { fileSize: 16000000 }
|
|
}))
|
|
.use(session({
|
|
secret: SESSION_SECRET,
|
|
resave: false,
|
|
saveUninitialized: true,
|
|
cookie: {
|
|
httpOnly: true,
|
|
maxAge: 31536000
|
|
},
|
|
store: new FileStore({
|
|
path: '.sessions'
|
|
})
|
|
}))
|
|
.use(passport.initialize())
|
|
.use(passport.session())
|
|
.use(compression({ threshold: 0 }))
|
|
.use(express.static('./static'))
|
|
.use('/', mainRouter)
|
|
.use('/legacy', legacyRouter)
|
|
.use(legacyMiddleware((req, res) => {
|
|
if (req.baseUrl !== '/legacy') {
|
|
res.redirect('/legacy' + req.originalUrl);
|
|
}
|
|
}))
|
|
.use(sapper.middleware({
|
|
session: req => ({
|
|
user: req.session.passport ? req.session.passport.user : null
|
|
})
|
|
}))
|
|
.listen(PORT, err => {
|
|
if (err) console.log('error', err);
|
|
console.log(`Express server listening on port ${PORT}`);
|
|
});
|