From cea9e610aa6f070ef46c84012ef5a3be6ea8de4b Mon Sep 17 00:00:00 2001 From: Sebastien Dumetz Date: Tue, 24 Oct 2023 16:25:39 +0200 Subject: [PATCH 1/2] update userLogin to properly disable button during request submission --- source/ui/composants/UserLogin.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/source/ui/composants/UserLogin.ts b/source/ui/composants/UserLogin.ts index 797e504e..97b9cf0f 100644 --- a/source/ui/composants/UserLogin.ts +++ b/source/ui/composants/UserLogin.ts @@ -7,7 +7,6 @@ import i18n from "../state/translate"; import styles from '!lit-css-loader?{"specifier":"lit-element"}!sass-loader!../styles.scss'; import Notification from "@ff/ui/Notification"; -import Modal from "./Modal"; /** * Main UI view for the Voyager Explorer application. @@ -20,7 +19,8 @@ import Modal from "./Modal"; @property() mode :"login"|"recover"; - #active = false; + @property({type:Boolean}) + active = false; constructor() { @@ -31,7 +31,7 @@ import Modal from "./Modal"; ev.preventDefault(); const username = ev.target["username"].value; const password = ev.target["password"].value; - this.#active = true; + this.active = true; doLogin(username, password) .then(()=>{ console.log("User logged-in succesfully"); @@ -39,13 +39,13 @@ import Modal from "./Modal"; },(e)=>{ console.log("Login failed :", e); Notification.show(`Failed to login : ${e}`, "warning") - }).finally(()=> this.#active = false) + }).finally(()=> this.active = false) } onRecoverSubmit = (ev :MouseEvent)=>{ ev.preventDefault(); const username = ev.target["username"].value; - this.#active = true; + this.active = true; fetch(`/api/v1/login/${username}/link`, { method: "POST", }).then(async (r)=>{ @@ -59,11 +59,11 @@ import Modal from "./Modal"; }).catch(e=>{ console.log("Failed to send recovery link :", e); Notification.show(`Failed : ${e.message}`, "warning") - }).finally(()=> this.#active = false) + }).finally(()=> this.active = false) } private renderLogin(){ - return html`
+ return html`
@@ -78,7 +78,7 @@ import Modal from "./Modal";
- +
@@ -88,7 +88,7 @@ import Modal from "./Modal"; } private renderRecover(){ - return html` + return html`

${this.t("info.recoverPasswordLead")}

@@ -100,7 +100,7 @@ import Modal from "./Modal";
- +
`; From b77cbde4ee5fcb44c55b87b63746c996405a5084 Mon Sep 17 00:00:00 2001 From: Sebastien DUMETZ Date: Sat, 21 Oct 2023 16:26:24 +0200 Subject: [PATCH 2/2] Rework templates to allow writing emails add rate-limiting to protect public sendmail route move route /stats to /admin/stats --- source/server/package-lock.json | 407 +++--------------- source/server/package.json | 7 +- source/server/routes/api/v1/admin/mailtest.ts | 26 ++ .../routes/api/v1/{ => admin}/stats/index.ts | 2 +- source/server/routes/api/v1/index.ts | 22 +- source/server/routes/api/v1/login.ts | 19 +- source/server/server.ts | 12 +- .../server/templates/emails/connection_en.hbs | 8 + .../server/templates/emails/connection_fr.hbs | 22 + source/server/templates/layouts/email.hbs | 27 ++ source/server/utils/config.ts | 5 +- source/server/utils/locals.test.ts | 55 +++ source/server/utils/locals.ts | 23 +- source/server/utils/mails/send.test.ts | 49 +++ source/server/utils/mails/send.ts | 57 ++- source/server/utils/mails/templates.ts | 72 ---- source/server/utils/templates.test.ts | 141 ++++++ source/server/utils/templates.ts | 73 ++++ source/ui/screens/Admin/AdminHome.ts | 47 +- source/ui/screens/Admin/AdminStats.ts | 2 +- source/ui/state/strings.ts | 10 +- 21 files changed, 619 insertions(+), 467 deletions(-) create mode 100644 source/server/routes/api/v1/admin/mailtest.ts rename source/server/routes/api/v1/{ => admin}/stats/index.ts (89%) create mode 100644 source/server/templates/emails/connection_en.hbs create mode 100644 source/server/templates/emails/connection_fr.hbs create mode 100644 source/server/templates/layouts/email.hbs create mode 100644 source/server/utils/locals.test.ts create mode 100644 source/server/utils/mails/send.test.ts delete mode 100644 source/server/utils/mails/templates.ts create mode 100644 source/server/utils/templates.test.ts create mode 100644 source/server/utils/templates.ts diff --git a/source/server/package-lock.json b/source/server/package-lock.json index 06cae15c..e450f956 100644 --- a/source/server/package-lock.json +++ b/source/server/package-lock.json @@ -12,9 +12,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", @@ -28,7 +29,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", @@ -342,6 +343,15 @@ "integrity": "sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g==", "dev": true }, + "node_modules/@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -354,12 +364,6 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, - "node_modules/@types/sendmail": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@types/sendmail/-/sendmail-1.4.4.tgz", - "integrity": "sha512-tSr6Y2LFKTp4TD6p/B6sSIXBf8RaIjuQhRpXFy8GxwihC0P1lfzf26C4Pvb6dCdVZu12YVOCeHG3wEx15da/Rg==", - "dev": true - }, "node_modules/@types/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", @@ -638,11 +642,6 @@ "acorn": "^8" } }, - "node_modules/addressparser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", - "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==" - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -1014,38 +1013,6 @@ "optional": true, "peer": true }, - "node_modules/buildmail": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-3.10.0.tgz", - "integrity": "sha512-6e5sDN/pl3en5Klqdfyir7LEIBiFr9oqZuvYaEyVwjxpIbBZN+98e0j87Fz2Ukl8ud32rbk9VGOZAnsOZ7pkaA==", - "deprecated": "This project is unmaintained", - "dependencies": { - "addressparser": "1.0.1", - "libbase64": "0.1.0", - "libmime": "2.1.0", - "libqp": "1.1.0", - "nodemailer-fetch": "1.6.0", - "nodemailer-shared": "1.1.0" - } - }, - "node_modules/buildmail/node_modules/iconv-lite": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz", - "integrity": "sha512-QwVuTNQv7tXC5mMWFX5N5wGjmybjNBBD8P3BReTkPmipoxTUFgWM2gXNvldHQr6T14DH0Dh6qBVg98iJt7u4mQ==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/buildmail/node_modules/libmime": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.0.tgz", - "integrity": "sha512-4be2R6/jOasyPTw0BkpIZBVk2cElqjdIdS0PRPhbOCV4wWuL/ZcYYpN1BCTVB+6eIQ0uuAwp5hQTHFrM5Joa8w==", - "dependencies": { - "iconv-lite": "0.4.13", - "libbase64": "0.1.0", - "libqp": "1.1.0" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1493,14 +1460,6 @@ "node": ">=0.3.1" } }, - "node_modules/dkim-signer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dkim-signer/-/dkim-signer-0.2.2.tgz", - "integrity": "sha512-24OZ3cCA30UTRz+Plpg+ibfPq3h7tDtsJRg75Bo0pGakZePXcPBddY80bKi1Bi7Jsz7tL5Cw527mhCRDvNFgfg==", - "dependencies": { - "libmime": "^2.0.3" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1723,54 +1682,15 @@ "node": ">= 0.10.0" } }, - "node_modules/express-handlebars": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-6.0.7.tgz", - "integrity": "sha512-iYeMFpc/hMD+E6FNAZA5fgWeXnXr4rslOSPkeEV6TwdmpJ5lEXuWX0u9vFYs31P2MURctQq2batR09oeNj0LIg==", - "dependencies": { - "glob": "^8.1.0", - "graceful-fs": "^4.2.10", - "handlebars": "^4.7.7" - }, - "engines": { - "node": ">=v12.22.9" - } - }, - "node_modules/express-handlebars/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/express-handlebars/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, + "node_modules/express-rate-limit": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.1.2.tgz", + "integrity": "sha512-uvkFt5JooXDhUhrfgqXLyIsAMRCtU1o8W/p0Q2p5U2ude7fEOfFaP0kSYbHOHmPbA9ZEm1JqrRne3vL9pVCBXA==", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/express-handlebars/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" + "node": ">= 16" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "express": "^4 || ^5" } }, "node_modules/fast-deep-equal": { @@ -2025,15 +1945,16 @@ "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "optional": true }, "node_modules/handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "dependencies": { "minimist": "^1.2.5", - "neo-async": "^2.6.0", + "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, @@ -2402,34 +2323,6 @@ "node": ">= 0.6" } }, - "node_modules/libbase64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", - "integrity": "sha512-B91jifmFw1DKEqEWstSpg1PbtUbBzR4yQAPT86kCQXBtud1AJVA+Z6RSklSrqmKe4q2eiEufgnhqJKPgozzfIQ==" - }, - "node_modules/libmime": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.3.tgz", - "integrity": "sha512-ABr2f4O+K99sypmkF/yPz2aXxUFHEZzv+iUkxItCeKZWHHXdQPpDXd6rV1kBBwL4PserzLU09EIzJ2lxC9hPfQ==", - "dependencies": { - "iconv-lite": "0.4.15", - "libbase64": "0.1.0", - "libqp": "1.1.0" - } - }, - "node_modules/libmime/node_modules/iconv-lite": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz", - "integrity": "sha512-RGR+c9Lm+tLsvU57FTJJtdbv2hQw42Yl2n26tVIBaYmZzLN+EGfroUugN/z9nJf9kOXd49hBmpoGr4FEm+A4pw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/libqp": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz", - "integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA==" - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -2492,34 +2385,6 @@ "node": ">=10" } }, - "node_modules/mailcomposer": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.12.0.tgz", - "integrity": "sha512-zBeDoKUTNI8IAsazoMQFt3eVSVRtDtgrvBjBVdBjxDEX+5KLlKtEFCrBXnxPhs8aTYufUS1SmbFnGpjHS53deg==", - "deprecated": "This project is unmaintained", - "dependencies": { - "buildmail": "3.10.0", - "libmime": "2.1.0" - } - }, - "node_modules/mailcomposer/node_modules/iconv-lite": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz", - "integrity": "sha512-QwVuTNQv7tXC5mMWFX5N5wGjmybjNBBD8P3BReTkPmipoxTUFgWM2gXNvldHQr6T14DH0Dh6qBVg98iJt7u4mQ==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/mailcomposer/node_modules/libmime": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.0.tgz", - "integrity": "sha512-4be2R6/jOasyPTw0BkpIZBVk2cElqjdIdS0PRPhbOCV4wWuL/ZcYYpN1BCTVB+6eIQ0uuAwp5hQTHFrM5Joa8w==", - "dependencies": { - "iconv-lite": "0.4.13", - "libbase64": "0.1.0", - "libqp": "1.1.0" - } - }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -3031,17 +2896,12 @@ "optional": true, "peer": true }, - "node_modules/nodemailer-fetch": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.6.0.tgz", - "integrity": "sha512-P7S5CEVGAmDrrpn351aXOLYs1R/7fD5NamfMCHyi6WIkbjS2eeZUB/TkuvpOQr0bvRZicVqo59+8wbhR3yrJbQ==" - }, - "node_modules/nodemailer-shared": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nodemailer-shared/-/nodemailer-shared-1.1.0.tgz", - "integrity": "sha512-68xW5LSyPWv8R0GLm6veAvm7E+XFXkVgvE3FW0FGxNMMZqMkPFeGDVALfR1DPdSfcoO36PnW7q5AAOgFImEZGg==", - "dependencies": { - "nodemailer-fetch": "1.6.0" + "node_modules/nodemailer": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz", + "integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==", + "engines": { + "node": ">=6.0.0" } }, "node_modules/nopt": { @@ -3453,18 +3313,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/sendmail": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sendmail/-/sendmail-1.6.1.tgz", - "integrity": "sha512-lIhvnjSi5e5jL8wA1GPP6j2QVlx6JOEfmdn0QIfmuJdmXYGmJ375kcOU0NSm/34J+nypm4sa1AXrYE5w3uNIIA==", - "dependencies": { - "dkim-signer": "0.2.2", - "mailcomposer": "3.12.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -4667,6 +4515,15 @@ "integrity": "sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g==", "dev": true }, + "@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -4679,12 +4536,6 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, - "@types/sendmail": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@types/sendmail/-/sendmail-1.4.4.tgz", - "integrity": "sha512-tSr6Y2LFKTp4TD6p/B6sSIXBf8RaIjuQhRpXFy8GxwihC0P1lfzf26C4Pvb6dCdVZu12YVOCeHG3wEx15da/Rg==", - "dev": true - }, "@types/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", @@ -4952,11 +4803,6 @@ "peer": true, "requires": {} }, - "addressparser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", - "integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==" - }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5240,36 +5086,6 @@ "optional": true, "peer": true }, - "buildmail": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-3.10.0.tgz", - "integrity": "sha512-6e5sDN/pl3en5Klqdfyir7LEIBiFr9oqZuvYaEyVwjxpIbBZN+98e0j87Fz2Ukl8ud32rbk9VGOZAnsOZ7pkaA==", - "requires": { - "addressparser": "1.0.1", - "libbase64": "0.1.0", - "libmime": "2.1.0", - "libqp": "1.1.0", - "nodemailer-fetch": "1.6.0", - "nodemailer-shared": "1.1.0" - }, - "dependencies": { - "iconv-lite": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz", - "integrity": "sha512-QwVuTNQv7tXC5mMWFX5N5wGjmybjNBBD8P3BReTkPmipoxTUFgWM2gXNvldHQr6T14DH0Dh6qBVg98iJt7u4mQ==" - }, - "libmime": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.0.tgz", - "integrity": "sha512-4be2R6/jOasyPTw0BkpIZBVk2cElqjdIdS0PRPhbOCV4wWuL/ZcYYpN1BCTVB+6eIQ0uuAwp5hQTHFrM5Joa8w==", - "requires": { - "iconv-lite": "0.4.13", - "libbase64": "0.1.0", - "libqp": "1.1.0" - } - } - } - }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5609,14 +5425,6 @@ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, - "dkim-signer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dkim-signer/-/dkim-signer-0.2.2.tgz", - "integrity": "sha512-24OZ3cCA30UTRz+Plpg+ibfPq3h7tDtsJRg75Bo0pGakZePXcPBddY80bKi1Bi7Jsz7tL5Cw527mhCRDvNFgfg==", - "requires": { - "libmime": "^2.0.3" - } - }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5801,45 +5609,11 @@ "vary": "~1.1.2" } }, - "express-handlebars": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-6.0.7.tgz", - "integrity": "sha512-iYeMFpc/hMD+E6FNAZA5fgWeXnXr4rslOSPkeEV6TwdmpJ5lEXuWX0u9vFYs31P2MURctQq2batR09oeNj0LIg==", - "requires": { - "glob": "^8.1.0", - "graceful-fs": "^4.2.10", - "handlebars": "^4.7.7" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "requires": { - "balanced-match": "^1.0.0" - } - }, - "glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - } - }, - "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "requires": { - "brace-expansion": "^2.0.1" - } - } - } + "express-rate-limit": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.1.2.tgz", + "integrity": "sha512-uvkFt5JooXDhUhrfgqXLyIsAMRCtU1o8W/p0Q2p5U2ude7fEOfFaP0kSYbHOHmPbA9ZEm1JqrRne3vL9pVCBXA==", + "requires": {} }, "fast-deep-equal": { "version": "3.1.3", @@ -6035,15 +5809,16 @@ "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "optional": true }, "handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "requires": { "minimist": "^1.2.5", - "neo-async": "^2.6.0", + "neo-async": "^2.6.2", "source-map": "^0.6.1", "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" @@ -6320,33 +6095,6 @@ "tsscmp": "1.0.6" } }, - "libbase64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", - "integrity": "sha512-B91jifmFw1DKEqEWstSpg1PbtUbBzR4yQAPT86kCQXBtud1AJVA+Z6RSklSrqmKe4q2eiEufgnhqJKPgozzfIQ==" - }, - "libmime": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.3.tgz", - "integrity": "sha512-ABr2f4O+K99sypmkF/yPz2aXxUFHEZzv+iUkxItCeKZWHHXdQPpDXd6rV1kBBwL4PserzLU09EIzJ2lxC9hPfQ==", - "requires": { - "iconv-lite": "0.4.15", - "libbase64": "0.1.0", - "libqp": "1.1.0" - }, - "dependencies": { - "iconv-lite": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz", - "integrity": "sha512-RGR+c9Lm+tLsvU57FTJJtdbv2hQw42Yl2n26tVIBaYmZzLN+EGfroUugN/z9nJf9kOXd49hBmpoGr4FEm+A4pw==" - } - } - }, - "libqp": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz", - "integrity": "sha512-4Rgfa0hZpG++t1Vi2IiqXG9Ad1ig4QTmtuZF946QJP4bPqOYC78ixUXgz5TW/wE7lNaNKlplSYTxQ+fR2KZ0EA==" - }, "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -6391,32 +6139,6 @@ "yallist": "^4.0.0" } }, - "mailcomposer": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.12.0.tgz", - "integrity": "sha512-zBeDoKUTNI8IAsazoMQFt3eVSVRtDtgrvBjBVdBjxDEX+5KLlKtEFCrBXnxPhs8aTYufUS1SmbFnGpjHS53deg==", - "requires": { - "buildmail": "3.10.0", - "libmime": "2.1.0" - }, - "dependencies": { - "iconv-lite": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz", - "integrity": "sha512-QwVuTNQv7tXC5mMWFX5N5wGjmybjNBBD8P3BReTkPmipoxTUFgWM2gXNvldHQr6T14DH0Dh6qBVg98iJt7u4mQ==" - }, - "libmime": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-2.1.0.tgz", - "integrity": "sha512-4be2R6/jOasyPTw0BkpIZBVk2cElqjdIdS0PRPhbOCV4wWuL/ZcYYpN1BCTVB+6eIQ0uuAwp5hQTHFrM5Joa8w==", - "requires": { - "iconv-lite": "0.4.13", - "libbase64": "0.1.0", - "libqp": "1.1.0" - } - } - } - }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6801,18 +6523,10 @@ "optional": true, "peer": true }, - "nodemailer-fetch": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/nodemailer-fetch/-/nodemailer-fetch-1.6.0.tgz", - "integrity": "sha512-P7S5CEVGAmDrrpn351aXOLYs1R/7fD5NamfMCHyi6WIkbjS2eeZUB/TkuvpOQr0bvRZicVqo59+8wbhR3yrJbQ==" - }, - "nodemailer-shared": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nodemailer-shared/-/nodemailer-shared-1.1.0.tgz", - "integrity": "sha512-68xW5LSyPWv8R0GLm6veAvm7E+XFXkVgvE3FW0FGxNMMZqMkPFeGDVALfR1DPdSfcoO36PnW7q5AAOgFImEZGg==", - "requires": { - "nodemailer-fetch": "1.6.0" - } + "nodemailer": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz", + "integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==" }, "nopt": { "version": "5.0.0", @@ -7102,15 +6816,6 @@ } } }, - "sendmail": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/sendmail/-/sendmail-1.6.1.tgz", - "integrity": "sha512-lIhvnjSi5e5jL8wA1GPP6j2QVlx6JOEfmdn0QIfmuJdmXYGmJ375kcOU0NSm/34J+nypm4sa1AXrYE5w3uNIIA==", - "requires": { - "dkim-signer": "0.2.2", - "mailcomposer": "3.12.0" - } - }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", diff --git a/source/server/package.json b/source/server/package.json index 4c2e3c76..14411261 100755 --- a/source/server/package.json +++ b/source/server/package.json @@ -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", @@ -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", diff --git a/source/server/routes/api/v1/admin/mailtest.ts b/source/server/routes/api/v1/admin/mailtest.ts new file mode 100644 index 00000000..44471427 --- /dev/null +++ b/source/server/routes/api/v1/admin/mailtest.ts @@ -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

${config.brand} test email

`, + `\t

This is a test email sent from the admin panel of ${config.hostname}

`, + ].join("\n")}); + + res.status(200).send(out); +} \ No newline at end of file diff --git a/source/server/routes/api/v1/stats/index.ts b/source/server/routes/api/v1/admin/stats/index.ts similarity index 89% rename from source/server/routes/api/v1/stats/index.ts rename to source/server/routes/api/v1/admin/stats/index.ts index 88778ae0..94b7a3ab 100644 --- a/source/server/routes/api/v1/stats/index.ts +++ b/source/server/routes/api/v1/admin/stats/index.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { getVfs } from "../../../../utils/locals.js"; +import { getVfs } from "../../../../../utils/locals.js"; diff --git a/source/server/routes/api/v1/index.ts b/source/server/routes/api/v1/index.ts index c0669908..7acb2d0b 100644 --- a/source/server/routes/api/v1/index.ts +++ b/source/server/routes/api/v1/index.ts @@ -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"; @@ -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(); @@ -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"); @@ -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); diff --git a/source/server/routes/api/v1/login.ts b/source/server/routes/api/v1/login.ts index 06e67756..f42313ad 100644 --- a/source/server/routes/api/v1/login.ts +++ b/source/server/routes/api/v1/login.ts @@ -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} @@ -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); @@ -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(); } diff --git a/source/server/server.ts b/source/server/server.ts index 9c093b55..27c7b5d5 100644 --- a/source/server/server.ts +++ b/source/server/server.ts @@ -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"; @@ -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{ @@ -25,6 +25,7 @@ export default async function createServer(config = defaultConfig) :Promise res.redirect("/ui/")); diff --git a/source/server/templates/emails/connection_en.hbs b/source/server/templates/emails/connection_en.hbs new file mode 100644 index 00000000..73f891f3 --- /dev/null +++ b/source/server/templates/emails/connection_en.hbs @@ -0,0 +1,8 @@ + +

eCorpus - connection link

+

Hi {{name}},

+

Click the link below to connect automatically:

+

{{url}}

+

This link stays valid for 30 days

+

You might want to change your password in user settings to be able to reconnect in the future

+

Have a great day!

diff --git a/source/server/templates/emails/connection_fr.hbs b/source/server/templates/emails/connection_fr.hbs new file mode 100644 index 00000000..199695f2 --- /dev/null +++ b/source/server/templates/emails/connection_fr.hbs @@ -0,0 +1,22 @@ + +

eCorpus - lien de connexion

+

Bonjour {{name}},

+

+ Nous venons de recevoir une demande de lien de connexion pour un compte lié à votre adresse.
+ Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet email. +

+

+ Sinon, vous pouvez vous connecter en cliquant sur le bouton ci-dessous. +

+

+ Connectez-vous +

+

+ Pour des raisons de sécurité, ce lien expirera dans 30 jours. +

+

+ Une fois connecté, n'oubliez pas de réinitialiser votre mot de passe! +

+

+ Pour rapporter toute erreur, vous pouvez nous contacter sur la page du projet eThesaurus +

diff --git a/source/server/templates/layouts/email.hbs b/source/server/templates/layouts/email.hbs new file mode 100644 index 00000000..b1a13682 --- /dev/null +++ b/source/server/templates/layouts/email.hbs @@ -0,0 +1,27 @@ + + + + + + + + + + {{{body}}} + + \ No newline at end of file diff --git a/source/server/utils/config.ts b/source/server/utils/config.ts index 15a9421f..ef0e96e8 100644 --- a/source/server/utils/config.ts +++ b/source/server/utils/config.ts @@ -1,4 +1,5 @@ import path from "path" +import {hostname} from "os"; const values = { @@ -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; diff --git a/source/server/utils/locals.test.ts b/source/server/utils/locals.test.ts new file mode 100644 index 00000000..b808f678 --- /dev/null +++ b/source/server/utils/locals.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/source/server/utils/locals.ts b/source/server/utils/locals.ts index c48a9921..cf0d0350 100644 --- a/source/server/utils/locals.ts +++ b/source/server/utils/locals.ts @@ -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{ port :number; fileDir :string; userManager :UserManager; vfs :Vfs; + templates :Templates; } /** @@ -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 diff --git a/source/server/utils/mails/send.test.ts b/source/server/utils/mails/send.test.ts new file mode 100644 index 00000000..a2723ee7 --- /dev/null +++ b/source/server/utils/mails/send.test.ts @@ -0,0 +1,49 @@ + +import nodemailer from "nodemailer"; + + +import send from "./send.js"; + + + +describe("mail.send()", function(){ + let logs :[string, string[]][]; + let sender :ReturnType; + this.beforeAll(async function(){ + + // @fixme could use the stream transport instead + sender = nodemailer.createTransport({ + jsonTransport: true + }); + }); + + + this.beforeEach(function(){ + //s.clear(); + logs = []; + }); + + it("send an email", async function(){ + let info:any = await send({ + to: "foo@example.com", + subject: "Test", + html: "

Hello

", + }, sender); + expect(info).to.have.property("message").a("string"); + let msg = JSON.parse(info.message); + expect(msg).to.have.property("from").to.have.property("address").to.match(/^noreply@/); + expect(msg).to.have.property("subject", "Test"); + expect(msg).to.have.property("to").to.deep.equal([{address: "foo@example.com", name: ""}]); + expect(msg).to.have.property("html", "

Hello

"); + }); + it("can wrap human-readable name in TO: field", async function(){ + let info:any = await send({ + to: "Foo ", + subject: "Test", + html: "

Hello

", + }, sender); + expect(info).to.have.property("message").a("string"); + let msg = JSON.parse(info.message); + expect(msg).to.have.property("to").to.deep.equal([{address: "foo@example.com", name: "Foo"}]); + }); +}); \ No newline at end of file diff --git a/source/server/utils/mails/send.ts b/source/server/utils/mails/send.ts index 018f3ed0..09e45218 100644 --- a/source/server/utils/mails/send.ts +++ b/source/server/utils/mails/send.ts @@ -1,24 +1,47 @@ -import sendmail from "sendmail"; -import { once } from "events"; + +import nodemailer from "nodemailer"; + import config from "../config.js"; -import { MailBody } from "./templates.js"; -import { promisify } from "util"; -const [smtpHost, smtpPortString] = config.smart_host.split(':'); -let smtpPort = parseInt(smtpPortString, 10); -if(Number.isNaN(smtpPort)) smtpPort = 25; +interface MailInput{ + to: string; + subject: string; + html :string; + text?: string; +} + + -const _send = promisify(sendmail({ - silent: true, - smtpHost, - smtpPort, -})); +let transportURL = new URL(config.smart_host); +//Set logger to the value of VERBOSE env var +if(!transportURL.searchParams.has("logger")) transportURL.searchParams.set("logger", config.verbose? "true": "false"); +if(!transportURL.searchParams.has("name")) transportURL.searchParams.set("name", config.hostname); -export default async function send(to:string, content:MailBody){ - await _send({ - from: `noreply@${config.hostname}`, - to, - ...content +/** + * Use smart host string with defaults + */ +const _transporter = nodemailer.createTransport(transportURL.toString()); + + +/** + * use sendmail to send an email + * **sendmail** is not really required but using /usr/bin/sendmail is not cross-platform and requires more configuration + * @param sender should only be changed in tests + * @see {Templates} to write emails + */ +export default async function send( + message :MailInput, + transporter: ReturnType =_transporter, +) :ReturnType{ + + const info = await transporter.sendMail({ + from: "noreply@"+config.hostname, + ...message, }); + + if(config.verbose){ + console.log("SMTP info :", info); + } + return info; } diff --git a/source/server/utils/mails/templates.ts b/source/server/utils/mails/templates.ts deleted file mode 100644 index 6be41423..00000000 --- a/source/server/utils/mails/templates.ts +++ /dev/null @@ -1,72 +0,0 @@ -import config from "../config.js"; - - -export type Mime = "text"|"html" -export type MailBody = Record; - -export const BOUNDARY = `=======${Buffer.from(config.brand.padStart(10,"0").slice(0,10)).toString("hex")}=======` - - -const styles = `` - - -export interface RecoverAccountOptions{ - link:string; - expires :Date; -} -export const recoverAccount = (p :RecoverAccountOptions) :MailBody =>({ - 'subject': "Votre lien de connection", - 'html': ` - - ${styles} - -

- Bonjour, -

-

- Nous venons de recevoir une demande de lien de connexion pour un compte lié à votre adresse.
- Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet email. -

-

- Sinon, vous pouvez vous connecter en cliquant sur le bouton ci-dessous. -

-

- Connectez-vous -

-

- Pour des raisons de sécurité, ce lien expirera le ${p.expires.toLocaleDateString("fr")} à ${p.expires.toLocaleTimeString("fr")}. -

-

- Une fois connecté, n'oubliez pas de réinitialiser votre mot de passe! -

-

Pour rapporter toute erreur, vous pouvez nous contacter sur la page du projet eThesaurus -`, - -'text':` - Bonjour, - \tNous venons de recevoir une demande de lien de connexion pour un compte lié à votre adresse. - Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet email. - - Sinon, vous pouvez vous connecter en cliquant sur le lien ci-dessous. - - ${p.link} - - Pour des raisons de sécurité, ce lien expirera le ${p.expires.toLocaleDateString("fr")} à ${p.expires.toLocaleTimeString("fr")}. - - Une fois connecté, n'oubliez pas de réinitialiser votre mot de passe! - - Pour rapporter toute erreur, vous pouvez nous contacter sur la page du projet eThesaurus: https://github.com/Holusion/e-thesaurus -`.replace(/^ +/gm, ""), -}) diff --git a/source/server/utils/templates.test.ts b/source/server/utils/templates.test.ts new file mode 100644 index 00000000..2ff75879 --- /dev/null +++ b/source/server/utils/templates.test.ts @@ -0,0 +1,141 @@ +import fs from "fs/promises"; +import {tmpdir} from "os"; +import path from "path"; +import { fileURLToPath } from 'url'; + +import Templates from "./templates.js"; +import express, { Express, NextFunction, Request, Response } from "express"; +import request from "supertest"; + +const thisDir = path.dirname(fileURLToPath(import.meta.url)); + +describe("Templates", function(){ + + describe("in test directory", function(){ + let dir:string; + this.beforeAll(async function(){ + dir = await fs.mkdtemp(path.join(tmpdir(), "ecorpus_templates_test")); + await fs.mkdir(path.join(dir, "layouts")); + }); + + this.afterAll(async function(){ + await fs.rm(dir, {recursive: true}); + }); + + describe("with a basic template", function(){ + let tmpl :string; + let t :Templates + this.beforeAll(async function(){ + tmpl = path.join(dir, "home.hbs"); + await fs.writeFile(tmpl, "Hello {{name}}"); + t = new Templates({dir, cache: false}); + }); + this.afterAll(async function(){ + await fs.rm(tmpl); + }) + + it("renders a template", async function(){ + let txt = await t.render(tmpl, {name: "World", layout: null}); + expect(txt).to.be.a.string; + expect(txt).to.equal("Hello World"); + }); + + it("renders a template with relative path", async function(){ + let txt = await t.render(path.basename(tmpl), {name: "World", layout: null}); + expect(txt).to.be.a.string; + expect(txt).to.equal("Hello World"); + }); + + it("renders a template without file extension", async function(){ + let txt = await t.render(path.basename(tmpl.slice(0,-4)), {name: "World", layout: null}); + expect(txt).to.be.a.string; + expect(txt).to.equal("Hello World"); + }); + + it("renders a template with default layout", async function(){ + await fs.writeFile(path.join(dir, "layouts", "main.hbs"), "{{title}}{{{body}}}"); + let txt = await t.render(tmpl, {name: "World", title: "TITLE"}); + expect(txt).to.be.a.string; + expect(txt).to.contain("Hello World"); + expect(txt).to.contain("TITLE"); + }); + + it("renders a template with custom layout", async function(){ + await fs.writeFile(path.join(dir, "layouts", "custom.hbs"), "{{title}}\n{{{body}}}"); + let txt = await t.render(tmpl, {name: "World", title: "TITLE", layout: "custom"}); + expect(txt).to.be.a.string; + expect(txt).to.equal("TITLE\nHello World"); + }); + + }); + + describe("cache", function(){ + it("enabled", async function(){ + let tmpl = path.join(dir, "test_enabled.hbs"); + await fs.writeFile(tmpl, "Hello {{name}}"); + let t = new Templates({dir, cache: true}); + let txt = await t.render(tmpl, {name: "World", layout: null}); + expect(txt).to.equal("Hello World"); + await fs.rm(tmpl); + txt = await t.render(tmpl, {name: "World", layout: null}); + expect(txt).to.equal("Hello World"); + }); + + it("disabled", async function(){ + let tmpl = path.join(dir, "test_disabled.hbs"); + await fs.writeFile(tmpl, "Hello {{name}}"); + let t = new Templates({dir, cache: false}); + let txt = await t.render(tmpl, {name: "World", layout: null}); + expect(txt).to.equal("Hello World"); + await fs.writeFile(tmpl, "Goodbye {{name}}"); + txt = await t.render(tmpl, {name: "World", layout: null}); + expect(txt).to.equal("Goodbye World"); + }); + }); + + + describe("as a middleware", function(){ + let app :Express; + let t :Templates; + let errors = []; + this.beforeAll(async function(){ + t = new Templates({dir, cache: false}); + app = express(); + app.engine('.hbs', t.middleware); + app.set('view engine', '.hbs'); + app.set('views', dir); + app.get("/layout/:name", (req, res)=>{ + res.render(req.params.name, {name: req.params.name, layout: "l"}); + }); + app.get("/:name", (req, res)=>{ + res.render(req.params.name, {name: req.params.name, layout: null}); + }); + app.use((err:Error, req :Request, res:Response, next :NextFunction)=>{ + errors.push(err); + res.status(500).send(err.message); + return; + }) + await fs.writeFile(path.join(dir, "home.hbs"), "Hello {{name}}"); + }); + + it("resolves templates", async function(){ + await request(app).get("/home") + .expect(200) + .expect("Hello home") + .expect("Content-Type", "text/html; charset=utf-8"); + }); + + it("wraps errors for missing templates", async function(){ + await request(app).get("/xxx") + .expect(500) + .expect(`Failed to lookup view "xxx" in views directory "${dir}"`); + }); + + it("wraps errors for missing layouts", async function(){ + await request(app).get("/layout/home") + .expect(500) + .expect(`[500] ENOENT: no such file or directory, open '${path.join(dir, "layouts/l.hbs")}'`); + }); + }); + }); +}); \ No newline at end of file diff --git a/source/server/utils/templates.ts b/source/server/utils/templates.ts new file mode 100644 index 00000000..778a580d --- /dev/null +++ b/source/server/utils/templates.ts @@ -0,0 +1,73 @@ +import Handlebars from "handlebars"; +import { promises as fs } from "fs"; +import path from "path"; +import { HTTPError, InternalError, NotFoundError } from "./errors.js"; + +interface TemplateConstructorOptions{ + dir: string; + cache?: boolean; +} + +interface EmailOptions{ + layout?: "email"|string; + lang?: "fr"|"en"; + [key:string]:any; +} + +export default class Templates{ + #cacheMap ?:Map; + #dir :string; + get #layoutsDir() :string{ return path.join(this.#dir, "layouts"); } + + constructor(opts :TemplateConstructorOptions){ + this.#dir = opts?.dir; + if(opts.cache){ + this.#cacheMap = new Map(); + } + } + + get dir(){ + return this.#dir; + } + + async compile(filepath :string):Promise>{ + if( !/\.hbs$/i.test(filepath)) filepath = filepath + ".hbs"; + const d = await fs.readFile(filepath, {encoding: "utf-8"}); + return Handlebars.compile(d); + } + + /** + * Wrapper around `Template.compile` that caches the result when `cache` is set to `true` in the constructor. + + */ + async getset(filepath :string) :Promise>>{ + return this.#cacheMap?.get(filepath) ?? await (async ()=>{ + let tmpl = await this.compile(filepath); + this.#cacheMap?.set(filepath, tmpl); + return tmpl; + })(); + } + /** + * render a template file + * Will not check if filepath _should_ be accessible. + * this is the caller's responsibility to only call for valid templates + * @param filepath path to the file, absolute or relative to this.#dir + * @param options template options + * @see https://handlebarsjs.com + */ + async render(filepath :string, options:any ={}) :Promise{ + filepath = path.isAbsolute(filepath)? filepath : path.resolve(this.#dir, filepath); + const html = (await this.getset(filepath))(options); + const layoutName = typeof options.layout !== "undefined" ? options.layout : "main"; + if(!layoutName) return html; + return await this.render(path.resolve(this.#layoutsDir, layoutName+".hbs"),{...options, body: html, layout: null}); + } + /** + * wrapper around render to be used as express middleware. + */ + middleware = (filepath :string, options :any, callback :(e: any, rendered?: string | undefined) => void)=>{ + this.render(filepath, options).then(r=>callback(null, r), (err)=>{ + return callback(new InternalError(err.message)); + }); + } +} \ No newline at end of file diff --git a/source/ui/screens/Admin/AdminHome.ts b/source/ui/screens/Admin/AdminHome.ts index 61d1b028..17ef2eeb 100644 --- a/source/ui/screens/Admin/AdminHome.ts +++ b/source/ui/screens/Admin/AdminHome.ts @@ -5,8 +5,45 @@ import "@ff/ui/Button"; import "./UsersList"; import i18n from "../../state/translate"; -import Notification from "@ff/ui/Notification"; +import Modal from "../../composants/Modal"; +@customElement("send-testmail") +class TestmailModalBody extends i18n(LitElement){ + @property({type: String}) + state = "initial"; + + protected render(): unknown { + let onsend = ()=>{ + this.state = "sending"; + fetch("/api/v1/admin/mailtest", {method: "POST"}).then(async (r)=>{ + let msg = await r.text(); + if(r.ok){ + this.state = "OK: "+msg; + }else{ + throw new Error(msg); + } + }).catch(e =>{ + console.warn("Failed to send test email : ", e); + this.state = "error: "+e.message; + }); + } + + if(this.state =="initial"){ + return html`

+ +
` + }else if(this.state === "sending"){ + return html`
+ +
` + }else{ + return html`
+ +

${this.state}

+
`; + } + } +} /** * Main UI view for the Voyager Explorer application. @@ -28,6 +65,14 @@ export default class AdminHomeScreen extends i18n(LitElement) {
  • ${this.t("ui.downloadZip")}
  • +
  • + { + e.preventDefault(); + Modal.show({ + header: this.t("ui.sendTestMail"), + body: html``, + }); + }}>${this.t("ui.sendTestMail")}
  • `; diff --git a/source/ui/screens/Admin/AdminStats.ts b/source/ui/screens/Admin/AdminStats.ts index cb814418..6d30709a 100644 --- a/source/ui/screens/Admin/AdminStats.ts +++ b/source/ui/screens/Admin/AdminStats.ts @@ -22,7 +22,7 @@ export default class AdminStatsScreen extends i18n(LitElement) { scenes :{mtime:string, name:string}[] =[]; fetchStats(){ - fetch("/api/v1/stats", { + fetch("/api/v1/admin/stats", { headers:{"Accept":"application/json"} }).then(async r=>{ let b = await r.json(); diff --git a/source/ui/state/strings.ts b/source/ui/state/strings.ts index c368e9a8..926dcd2e 100644 --- a/source/ui/state/strings.ts +++ b/source/ui/state/strings.ts @@ -210,7 +210,15 @@ export default { renameScene:{ fr: "Renommer la scène", en: "Rename scene" - } + }, + sendTestMail:{ + fr: "Envoyer un mail de test", + en: "Send a test mail", + }, + send: { + fr: "Envoyer", + en: "Send", + }, }, info:{ noData:{