Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send Emails #49

Merged
merged 2 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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