diff --git a/.gitignore b/.gitignore index c8c9be4..7119cfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules .env .data -out -config.json \ No newline at end of file +out \ No newline at end of file diff --git a/README.md b/README.md index 2d30a57..cc8e371 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ Then, add your bot token... ``` echo "TOKEN=INSERT-TOKEN.HERE" > .env ``` +and, in addition, SMTP authentication... +``` +echo "\nMAIL_USER=user@example.com" > .env +echo "\nMAIL_PASS=password here" > .env +``` Invite your bot to a server, and create a new `config.json` in the project root: ```js diff --git a/assets/icons/mail.svg b/assets/icons/mail.svg new file mode 100644 index 0000000..54933b7 --- /dev/null +++ b/assets/icons/mail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config.json b/config.json index 8c02936..3409ed3 100644 --- a/config.json +++ b/config.json @@ -16,8 +16,13 @@ }, "mail": { - "host": "smtp.fastmail.com", - "port": 465, - "secure": true + "transport": { + "host": "smtp.fastmail.com", + "port": 465, + "secure": true + }, + "send": { + "from": "mono@fyle.uk" + } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 395e863..2f0f924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/body-parser": "^1.19.2", "@types/express": "^4.17.14", "@types/multer": "^1.4.7", + "@types/nodemailer": "^6.4.8", "axios": "^0.27.2", "body-parser": "^1.20.0", "bytes": "^3.1.2", @@ -252,6 +253,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.8.tgz", + "integrity": "sha512-oVsJSCkqViCn8/pEu2hfjwVO+Gb3e+eTWjg3PcjeFKRItfKpKwHphQqbYmPQrlMk+op7pNNWPbsJIEthpFN/OQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -1857,6 +1866,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" }, + "@types/nodemailer": { + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.8.tgz", + "integrity": "sha512-oVsJSCkqViCn8/pEu2hfjwVO+Gb3e+eTWjg3PcjeFKRItfKpKwHphQqbYmPQrlMk+op7pNNWPbsJIEthpFN/OQ==", + "requires": { + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", diff --git a/package.json b/package.json index 02aaf32..12191ea 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/body-parser": "^1.19.2", "@types/express": "^4.17.14", "@types/multer": "^1.4.7", + "@types/nodemailer": "^6.4.8", "axios": "^0.27.2", "body-parser": "^1.20.0", "bytes": "^3.1.2", diff --git a/src/server/lib/accounts.ts b/src/server/lib/accounts.ts index 2eb884b..1ec98e8 100644 --- a/src/server/lib/accounts.ts +++ b/src/server/lib/accounts.ts @@ -11,6 +11,7 @@ export let Accounts: Account[] = [] export interface Account { id : string username : string + email? : string password : { hash : string salt : string diff --git a/src/server/lib/files.ts b/src/server/lib/files.ts index 5e2eb3e..ce1b529 100644 --- a/src/server/lib/files.ts +++ b/src/server/lib/files.ts @@ -13,9 +13,9 @@ export let alphanum = Array.from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRST export type FileVisibility = "public" | "anonymous" | "private" -export function generateFileId() { +export function generateFileId(length:number=5) { let fid = "" - for (let i = 0; i < 5; i++) { + for (let i = 0; i < length; i++) { fid += alphanum[Math.floor(Math.random()*alphanum.length)] } return fid diff --git a/src/server/lib/mail.ts b/src/server/lib/mail.ts new file mode 100644 index 0000000..ecf1aa0 --- /dev/null +++ b/src/server/lib/mail.ts @@ -0,0 +1,35 @@ +import { createTransport } from "nodemailer"; + +let +mailConfig = + require( process.cwd() + "/config.json" ).mail, +transport = + createTransport( + { + ...mailConfig.transport, + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS + } + } + ) + +// lazy but + +export function sendMail(to: string, subject: string, content: string) { + return new Promise((resolve,reject) => { + transport.sendMail({ + to, + subject, + "from": mailConfig.send.from, + "html": `monofile accounts
Gain control of your uploads.

${ + content + .replace(/\/g, `@`) + .replace(/\/g,``) + }

If you do not believe that you are the intended recipient of this email, please disregard it.` + }, (err, info) => { + if (err) reject(err) + else resolve(info) + }) + }) +} \ No newline at end of file diff --git a/src/server/routes/authRoutes.ts b/src/server/routes/authRoutes.ts index 0fb5707..d74cde0 100644 --- a/src/server/routes/authRoutes.ts +++ b/src/server/routes/authRoutes.ts @@ -2,9 +2,10 @@ import bodyParser from "body-parser"; import { Router } from "express"; import * as Accounts from "../lib/accounts"; import * as auth from "../lib/auth"; +import { sendMail } from "../lib/mail"; import ServeError from "../lib/errors"; -import Files, { FileVisibility, id_check_regex } from "../lib/files"; +import Files, { FileVisibility, generateFileId, id_check_regex } from "../lib/files"; import { writeFile } from "fs"; @@ -219,6 +220,162 @@ authRoutes.post("/change_username", parser, (req,res) => { res.send("username changed") }) +// shit way to do this but... + +let verificationCodes = new Map() + +authRoutes.post("/request_email_change", parser, (req,res) => { + let acc = Accounts.getFromToken(req.cookies.auth) + if (!acc) { + ServeError(res, 401, "not logged in") + return + } + + if (typeof req.body.email != "string" || !req.body.email) { + ServeError(res,400, "supply an email") + return + } + + let vcode = verificationCodes.get(acc.id) + + if (vcode && vcode.requestedAt+(15*60*1000) > Date.now()) { + ServeError(res, 429, `Please wait a few moments to request another email change.`) + return + } + + + // delete previous if any + let e = vcode?.expiry + if (e) clearTimeout(e) + verificationCodes.delete(acc?.id||"") + + let code = generateFileId(12).toUpperCase() + + // set + + verificationCodes.set(acc.id, { + code, + email: req.body.email, + expiry: setTimeout( () => verificationCodes.delete(acc?.id||""), 15*60*1000), + requestedAt: Date.now() + }) + + // this is a mess but it's fine + + sendMail(req.body.email, `Hey there, ${acc.username} - let's connect your email`, `Hello there! You are recieving this message because you decided to link your email, ${req.body.email}, to your account, ${acc.username}. If you would like to continue, please click here, or go to https://${req.header("Host")}/auth/confirm_email/${code}.`).then(() => { + res.send("OK") + }).catch((err) => { + let e = verificationCodes.get(acc?.id||"")?.expiry + if (e) clearTimeout(e) + verificationCodes.delete(acc?.id||"") + ServeError(res, 500, err?.toString()) + }) +}) + +authRoutes.get("/confirm_email/:code", (req,res) => { + let acc = Accounts.getFromToken(req.cookies.auth) + if (!acc) { + ServeError(res, 401, "not logged in") + return + } + + let vcode = verificationCodes.get(acc.id) + + if (!vcode) { ServeError(res, 400, "nothing to confirm"); return } + + if (typeof req.params.code == "string" && req.params.code.toUpperCase() == vcode.code) { + acc.email = vcode.email + Accounts.save(); + + let e = verificationCodes.get(acc?.id||"")?.expiry + if (e) clearTimeout(e) + verificationCodes.delete(acc?.id||"") + + res.send(``) + } else { + ServeError(res, 400, "invalid code") + } +}) + +let pwReset = new Map() +let prcIdx = new Map() + +authRoutes.post("/request_emergency_login", parser, (req,res) => { + if (auth.validate(req.cookies.auth || "")) return + + if (typeof req.body.account != "string" || !req.body.account) { + ServeError(res,400, "supply a username") + return + } + + let acc = Accounts.getFromUsername(req.body.account) + if (!acc || !acc.email) { + ServeError(res, 400, "this account either does not exist or does not have an email attached; please contact the server's admin for a reset if you would still like to access it") + return + } + + let pResetCode = pwReset.get(acc.id) + + if (pResetCode && pResetCode.requestedAt+(15*60*1000) > Date.now()) { + ServeError(res, 429, `Please wait a few moments to request another emergency login.`) + return + } + + + // delete previous if any + let e = pResetCode?.expiry + if (e) clearTimeout(e) + pwReset.delete(acc?.id||"") + prcIdx.delete(pResetCode?.code||"") + + let code = generateFileId(12).toUpperCase() + + // set + + pwReset.set(acc.id, { + code, + expiry: setTimeout( () => { pwReset.delete(acc?.id||""); prcIdx.delete(pResetCode?.code||"") }, 15*60*1000), + requestedAt: Date.now() + }) + + prcIdx.set(code, acc.id) + + // this is a mess but it's fine + + sendMail(acc.email, `Emergency login requested for ${acc.username}`, `Hello there! You are recieving this message because you forgot your password to your monofile account, ${acc.username}. To log in, please click here, or go to https://${req.header("Host")}/auth/emergency_login/${code}. If it doesn't appear that you are logged in after visiting this link, please try refreshing. Once you have successfully logged in, you may reset your password.`).then(() => { + res.send("OK") + }).catch((err) => { + let e = pwReset.get(acc?.id||"")?.expiry + if (e) clearTimeout(e) + pwReset.delete(acc?.id||"") + prcIdx.delete(code||"") + ServeError(res, 500, err?.toString()) + }) +}) + +authRoutes.get("/emergency_login/:code", (req,res) => { + if (auth.validate(req.cookies.auth || "")) { + ServeError(res, 403, "already logged in") + return + } + + let vcode = prcIdx.get(req.params.code) + + if (!vcode) { ServeError(res, 400, "invalid emergency login code"); return } + + if (typeof req.params.code == "string" && vcode) { + res.cookie("auth",auth.create(vcode,(3*24*60*60*1000))) + res.redirect("/") + + let e = pwReset.get(vcode)?.expiry + if (e) clearTimeout(e) + pwReset.delete(vcode) + prcIdx.delete(req.params.code) + } else { + ServeError(res, 400, "invalid code") + } +}) + authRoutes.post("/change_password", parser, (req,res) => { let acc = Accounts.getFromToken(req.cookies.auth) if (!acc) { diff --git a/src/style/app/pulldown/accounts.scss b/src/style/app/pulldown/accounts.scss index 292f2c2..b6fe02b 100644 --- a/src/style/app/pulldown/accounts.scss +++ b/src/style/app/pulldown/accounts.scss @@ -54,6 +54,13 @@ flex-grow:1; } + button.flavor { + + padding: 0; + background: none; + + } + input[type=text],input[type=password] { border:none; border-radius:0; diff --git a/src/svelte/elem/prompts/account.js b/src/svelte/elem/prompts/account.js index e026fba..f3d5caa 100644 --- a/src/svelte/elem/prompts/account.js +++ b/src/svelte/elem/prompts/account.js @@ -86,6 +86,64 @@ export function userChange(optPicker) { }) } +export function forgotPassword(optPicker) { + optPicker.picker("Forgot your password?",[ + { + name: "Username", + icon: "/static/assets/icons/person.svg", + id: "user", + inputSettings: {} + }, + { + name: "OK", + icon: "/static/assets/icons/update.svg", + description: "", + id: true + } + ]).then((exp) => { + if (exp && exp.selected) { + fetch(`/auth/request_emergency_login`,{method:"POST", body:JSON.stringify({ + account:exp.user + })}).then((response) => { + if (response.status != 200) { + optPicker.picker(`${response.status} ${response.statusText}`,[]) + } else { + optPicker.picker(`Please follow the instructions sent to your inbox.`,[]) + } + }) + } + }) +} + +export function emailChange(optPicker) { + optPicker.picker("Change email",[ + { + name: "New email", + icon: "/static/assets/icons/mail.svg", + id: "email", + inputSettings: {} + }, + { + name: "Request email change", + icon: "/static/assets/icons/update.svg", + description: "", + id: true + } + ]).then((exp) => { + if (exp && exp.selected) { + fetch(`/auth/request_email_change`,{method:"POST", body:JSON.stringify({ + email:exp.email + })}).then((response) => { + if (response.status != 200) { + optPicker.picker(`${response.status} ${response.statusText}`,[]) + } else { + optPicker.picker(`Please continue to your inbox at ${exp.email.split("@")[1]} and click on the attached link.`,[]) + } + }) + } + }) +} + export function pwdChng(optPicker) { optPicker.picker("Change password",[ { diff --git a/src/svelte/elem/pulldowns/Accounts.svelte b/src/svelte/elem/pulldowns/Accounts.svelte index b6ec26c..b0cf738 100644 --- a/src/svelte/elem/pulldowns/Accounts.svelte +++ b/src/svelte/elem/pulldowns/Accounts.svelte @@ -95,6 +95,11 @@ + + {#if targetAction == "login"} + + {/if} + {:else} @@ -126,6 +131,11 @@

Change username

+ +