Skip to content

Commit

Permalink
Rework templates to allow writing emails
Browse files Browse the repository at this point in the history
add rate-limiting to protect public sendmail route

move route /stats to /admin/stats
  • Loading branch information
sdumetz committed Nov 16, 2023
1 parent cea9e61 commit b77cbde
Show file tree
Hide file tree
Showing 21 changed files with 619 additions and 467 deletions.
407 changes: 56 additions & 351 deletions source/server/package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions source/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@
"body-parser": "^1.20.1",
"cookie-session": "^2.0.0",
"express": "^4.17.1",
"express-handlebars": "^6.0.7",
"express-rate-limit": "^7.1.2",
"handlebars": "^4.7.8",
"morgan": "^1.10.0",
"sendmail": "^1.6.1",
"nodemailer": "^6.9.7",
"sqlite": "^4.1.2",
"sqlite3": "^5.1.2",
"three": "^0.146.0",
Expand All @@ -57,7 +58,7 @@
"@types/mocha": "^8.0.0",
"@types/morgan": "^1.9.3",
"@types/node": "^16",
"@types/sendmail": "^1.4.4",
"@types/nodemailer": "^6.4.14",
"@types/supertest": "^2.0.12",
"@types/three": "^0.146.0",
"chai": "^4.2.0",
Expand Down
26 changes: 26 additions & 0 deletions source/server/routes/api/v1/admin/mailtest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Request, Response } from "express";
import sendmail from "../../../../utils/mails/send.js";
import { getUser } from "../../../../utils/locals.js";
import config from "../../../../utils/config.js";
import { BadRequestError } from "../../../../utils/errors.js";

/**
* Send a test email
* Exposes all possible logs from the emailer
* This is a protected route and requires admin privileges
*/
export default async function handleMailtest(req :Request, res :Response){
const {username :requester, email:to} = getUser(req);
if(!to){
throw new BadRequestError("No email address found for user "+ requester);
}
let out = await sendmail({
to,
subject: config.brand+" test email",
html: [
`\t<h1>${config.brand} test email</h1>`,
`\t<p>This is a test email sent from the admin panel of ${config.hostname}</p>`,
].join("\n")});

res.status(200).send(out);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response } from "express";
import { getVfs } from "../../../../utils/locals.js";
import { getVfs } from "../../../../../utils/locals.js";



Expand Down
22 changes: 16 additions & 6 deletions source/server/routes/api/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

import path from "path";
import { Router } from "express";
import { rateLimit } from 'express-rate-limit'

import User from "../../../auth/User.js";
import UserManager from "../../../auth/UserManager.js";
import { BadRequestError } from "../../../utils/errors.js";
import { canAdmin, canRead, getUserManager, isAdministrator, isAdministratorOrOpen, isUser } from "../../../utils/locals.js";
import { canAdmin, canRead, either, getUserManager, isAdministrator, isAdministratorOrOpen, isUser } from "../../../utils/locals.js";
import wrap from "../../../utils/wrapAsync.js";
import bodyParser from "body-parser";
import { getLogin, getLoginLink, sendLoginLink, postLogin } from "./login.js";
Expand All @@ -21,10 +23,10 @@ import postUser from "./users/post.js";
import handleDeleteUser from "./users/uid/delete.js";
import { handlePatchUser } from "./users/uid/patch.js";
import { postSceneHistory } from "./scenes/scene/history/post.js";
import handleGetStats from "./stats/index.js";
import handleGetStats from "./admin/stats/index.js";
import postScenes from "./scenes/post.js";
import patchScene from "./scenes/scene/patch.js";

import handleMailtest from "./admin/mailtest.js";


const router = Router();
Expand All @@ -36,9 +38,10 @@ router.use((req, res, next)=>{
//Browser should always make the request
res.set("Cache-Control", "max-age=0, must-revalidate");
next();
})
});

router.get("/stats", isAdministrator, wrap(handleGetStats));
router.get("/admin/stats", isAdministrator, wrap(handleGetStats));
router.post("/admin/mailtest", isAdministrator, wrap(handleMailtest));

router.use("/login", (req, res, next)=>{
res.append("Cache-Control", "private");
Expand All @@ -47,7 +50,14 @@ router.use("/login", (req, res, next)=>{
router.get("/login", wrap(getLogin));
router.post("/login", bodyParser.json(), postLogin);
router.get("/login/:username/link", isAdministrator, wrap(getLoginLink));
router.post("/login/:username/link", wrap(sendLoginLink));
router.post("/login/:username/link", either(isAdministrator, rateLimit({
//Special case of real low rate-limiting for non-admin users to send emails
windowMs: 1 * 60 * 1000, // 1 minute
limit: 1, // Limit each IP to 1 request per `window`.
standardHeaders: 'draft-7',
legacyHeaders: false,
})), wrap(sendLoginLink));

router.post("/logout", postLogout);


Expand Down
19 changes: 14 additions & 5 deletions source/server/routes/api/v1/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { createHmac } from "crypto";
import { Request, RequestHandler, Response } from "express";
import User, { SafeUser } from "../../../auth/User.js";
import { BadRequestError, ForbiddenError, HTTPError } from "../../../utils/errors.js";
import { getHost, getUser, getUserManager } from "../../../utils/locals.js";
import { AppLocals, getHost, getUser, getUserManager } from "../../../utils/locals.js";
import sendmail from "../../../utils/mails/send.js";
import { recoverAccount } from "../../../utils/mails/templates.js";
/**
*
* @type {RequestHandler}
Expand Down Expand Up @@ -102,7 +101,6 @@ export async function getLoginLink(req :Request, res :Response){

export async function sendLoginLink(req :Request, res :Response){
let {username} = req.params;
let requester = getUser(req);
let userManager = getUserManager(req);

let user = await userManager.getUserByName(username);
Expand All @@ -115,8 +113,19 @@ export async function sendLoginLink(req :Request, res :Response){
payload,
getHost(req)
);
let content = recoverAccount({link: link.toString(), expires:payload.expires});
await sendmail(user.email, content);

let lang = "fr";
const mail_content = await (res.app.locals as AppLocals).templates.render(`emails/connection_${lang}`, {
name: user.username,
lang: "fr",
url: link.toString()
});
await sendmail({
to: user.email,
subject: "Votre lien de connexion à eCorpus",
html: mail_content,
});

console.log("sent an account recovery mail to :", user.email);
res.status(204).send();
}
12 changes: 6 additions & 6 deletions source/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import path from "path";
import util from "util";
import cookieSession from "cookie-session";
import express from "express";
import { engine } from 'express-handlebars';

import UserManager from "./auth/UserManager.js";
import { BadRequestError, HTTPError } from "./utils/errors.js";
Expand All @@ -15,6 +14,7 @@ import openDatabase from "./vfs/helpers/db.js";
import Vfs from "./vfs/index.js";
import defaultConfig from "./utils/config.js";
import User from "./auth/User.js";
import Templates from "./utils/templates.js";


export default async function createServer(config = defaultConfig) :Promise<express.Application>{
Expand All @@ -25,6 +25,7 @@ export default async function createServer(config = defaultConfig) :Promise<expr

const userManager = new UserManager(db);

const templates = new Templates({dir: config.templates_dir, cache: config.node_env == "production"});

const app = express();
app.disable('x-powered-by');
Expand All @@ -46,6 +47,7 @@ export default async function createServer(config = defaultConfig) :Promise<expr
userManager,
fileDir: config.files_dir,
vfs,
templates,
}) as AppLocals;

app.use(cookieSession({
Expand Down Expand Up @@ -81,12 +83,10 @@ export default async function createServer(config = defaultConfig) :Promise<expr
}));
}


app.engine('.hbs', engine({
extname: '.hbs',
}));

app.engine('.hbs', templates.middleware);
app.set('view engine', '.hbs');
app.set('views', config.templates_dir);
app.set('views', templates.dir);


app.get(["/"], (req, res)=> res.redirect("/ui/"));
Expand Down
8 changes: 8 additions & 0 deletions source/server/templates/emails/connection_en.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

<h1>eCorpus - connection link</h1>
<p>Hi <b>{{name}}</b>,</p>
<p>Click the link below to connect automatically:</p>
<p><a href="{{url}}">{{url}}</a></p>
<p>This link stays valid for 30 days</p>
<p>You might want to change your password in user settings to be able to reconnect in the future</p>
<p>Have a great day!</p>
22 changes: 22 additions & 0 deletions source/server/templates/emails/connection_fr.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

<h1>eCorpus - lien de connexion</h1>
<p>Bonjour <b>{{name}}</b>,</p>
<p>
Nous venons de recevoir une demande de lien de connexion pour un compte lié à votre adresse.<br>
Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet email.
</p>
<p>
Sinon, vous pouvez vous connecter en cliquant sur le bouton ci-dessous.
</p>
<p align="center">
<a class="btn" href="{{url}}">Connectez-vous</a>
</p>
<p>
Pour des raisons de sécurité, ce lien expirera dans 30 jours.
</p>
<p>
Une fois connecté, n'oubliez pas de réinitialiser votre mot de passe!
</p>
<p>
Pour rapporter toute erreur, vous pouvez nous contacter sur la <a href="https://github.com/Holusion/e-thesaurus">page du projet eThesaurus</a>
</p>
27 changes: 27 additions & 0 deletions source/server/templates/layouts/email.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">

<style>
h1{
font-size:1.2rem;
font-weight: bold;
color: #00517d;
}
.btn {
cursor: pointer;
color: white !important;
background-color: #0089bf;
text-decoration: none;
padding: 0.5rem 1rem;
margin: 2rem 0.5rem;
border-radius: 4px;
}
</style>

</head>
<body>
{{{body}}}
</body>
</html>
5 changes: 3 additions & 2 deletions source/server/utils/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path"
import {hostname} from "os";


const values = {
Expand All @@ -15,9 +16,9 @@ const values = {
dist_dir: [({root_dir}:{root_dir:string})=> path.resolve(root_dir,"dist"), toPath],
assets_dir: [({root_dir}:{root_dir:string})=> path.resolve(root_dir,"assets"), toPath],
trust_proxy: [true, toBool],
hostname: ["ecorpus.holusion.net", toString],
hostname: [hostname(), toString],
hot_reload:[false, toBool],
smart_host: ["localhost", toString],
smart_host: ["smtp://localhost", toString],
verbose: [false, toBool],
} as const;

Expand Down
55 changes: 55 additions & 0 deletions source/server/utils/locals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import express, { Express, NextFunction, Request, RequestHandler, Response } from "express";
import request from "supertest";
import { InternalError, UnauthorizedError } from "./errors.js";
import { either } from "./locals.js";

//Dummy middlewares
function pass(req :Request, res :Response, next :NextFunction){
next();
}

function fail(req :Request, res :Response, next :NextFunction){
next(new UnauthorizedError());
}

function err(req :Request, res :Response, next :NextFunction){
next(new InternalError());
}

function h(req:Request, res:Response){
res.status(204).send();
}

describe("either() middleware", function(){
let app :Express;
let handler :RequestHandler;
this.beforeEach(function(){
app = express();
//small trick to allow error handling :
process.nextTick(()=>{
app.use((err:Error, req :Request, res:Response, next :NextFunction)=>{
res.status((err as any).code ?? 500).send(err.message);
});
});
});

it("checks each middleware for a pass", async function(){
app.get("/", either(fail, fail, pass), h);
await request(app).get("/").expect(204);
});

it("uses first middleware to pass", async function(){
app.get("/", either(pass, fail), h);
await request(app).get("/").expect(204);
});

it("doesn't allow errors other than UnauthoriezError", async function(){
app.get("/", either(fail, err), h);
await request(app).get("/").expect(500);
});

it("throws if no middleware passed", async function(){
app.get("/", either(fail, fail), h);
await request(app).get("/").expect(401);
});
});
23 changes: 22 additions & 1 deletion source/server/utils/locals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import User, { SafeUser } from "../auth/User.js";
import UserManager, { AccessType, AccessTypes } from "../auth/UserManager.js";
import Vfs, { GetFileParams } from "../vfs/index.js";
import { BadRequestError, ForbiddenError, HTTPError, InternalError, NotFoundError, UnauthorizedError } from "./errors.js";
import Templates from "./templates.js";

export interface AppLocals extends Record<string, any>{
port :number;
fileDir :string;
userManager :UserManager;
vfs :Vfs;
templates :Templates;
}

/**
Expand Down Expand Up @@ -50,13 +52,32 @@ export function isAdministratorOrOpen(req: Request, res:Response, next :NextFunc
}).then(()=>next(), next);
});
}

/**
* Checks if user.isAdministrator is true
* Not the same thing as canAdmin() that checks if the user has admin rights over a scene
*/
export function isAdministrator(req: Request, res:Response, next :NextFunction){
res.append("Cache-Control", "private");

if((req.session as User).isAdministrator) next();
else next(new UnauthorizedError());
}
/**
* Wraps middlewares to find if at least one passes
* Usefull for conditional rate-limiting
* @example either(isAdministrator, isUser, rateLimit({...}))
*/
export function either(...handlers:RequestHandler[]) :RequestHandler{
return (req, res, next)=>{
let mdw = handlers.shift();
if(!mdw) return next(new UnauthorizedError());
return mdw(req, res, (err)=>{
if(!err) return next();
else if (err instanceof UnauthorizedError) return either(...handlers)(req, res, next);
else return next(err);
});
}
}

/**
* Generic internal permissions check
Expand Down
Loading

0 comments on commit b77cbde

Please sign in to comment.