Experimental, and very bad & likely broken email

This commit is contained in:
May 2023-07-18 19:37:10 -07:00
parent 3ac2518578
commit 42931ab3cf
13 changed files with 305 additions and 9 deletions

1
.gitignore vendored
View file

@ -2,4 +2,3 @@ node_modules
.env .env
.data .data
out out
config.json

View file

@ -15,6 +15,11 @@ Then, add your bot token...
``` ```
echo "TOKEN=INSERT-TOKEN.HERE" > .env 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: Invite your bot to a server, and create a new `config.json` in the project root:
```js ```js

1
assets/icons/mail.svg Normal file
View file

@ -0,0 +1 @@
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22 8.608v8.142a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75V8.608l9.652 5.056a.75.75 0 0 0 .696 0L22 8.608ZM5.25 4h13.5a3.25 3.25 0 0 1 3.234 2.924L12 12.154l-9.984-5.23a3.25 3.25 0 0 1 3.048-2.919L5.25 4h13.5-13.5Z" fill="#DDDDDD"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View file

@ -16,8 +16,13 @@
}, },
"mail": { "mail": {
"transport": {
"host": "smtp.fastmail.com", "host": "smtp.fastmail.com",
"port": 465, "port": 465,
"secure": true "secure": true
},
"send": {
"from": "mono@fyle.uk"
}
} }
} }

17
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@types/body-parser": "^1.19.2", "@types/body-parser": "^1.19.2",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.8",
"axios": "^0.27.2", "axios": "^0.27.2",
"body-parser": "^1.20.0", "body-parser": "^1.20.0",
"bytes": "^3.1.2", "bytes": "^3.1.2",
@ -252,6 +253,14 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" "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": { "node_modules/@types/qs": {
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", "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", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg==" "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": { "@types/qs": {
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",

View file

@ -17,6 +17,7 @@
"@types/body-parser": "^1.19.2", "@types/body-parser": "^1.19.2",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.8",
"axios": "^0.27.2", "axios": "^0.27.2",
"body-parser": "^1.20.0", "body-parser": "^1.20.0",
"bytes": "^3.1.2", "bytes": "^3.1.2",

View file

@ -11,6 +11,7 @@ export let Accounts: Account[] = []
export interface Account { export interface Account {
id : string id : string
username : string username : string
email? : string
password : { password : {
hash : string hash : string
salt : string salt : string

View file

@ -13,9 +13,9 @@ export let alphanum = Array.from("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRST
export type FileVisibility = "public" | "anonymous" | "private" export type FileVisibility = "public" | "anonymous" | "private"
export function generateFileId() { export function generateFileId(length:number=5) {
let fid = "" let fid = ""
for (let i = 0; i < 5; i++) { for (let i = 0; i < length; i++) {
fid += alphanum[Math.floor(Math.random()*alphanum.length)] fid += alphanum[Math.floor(Math.random()*alphanum.length)]
} }
return fid return fid

35
src/server/lib/mail.ts Normal file
View file

@ -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": `<span style="font-size:x-large;font-weight:600;">monofile <span style="opacity:0.5">accounts</span></span><br><span style="opacity:0.5">Gain control of your uploads.</span><hr><br>${
content
.replace(/\<span username\>/g, `<span code><span style="color:#DDAA66;padding-right:3px;">@</span>`)
.replace(/\<span code\>/g,`<span style="font-family:monospace;padding:3px 5px 3px 5px;border-radius:8px;background-color:#1C1C1C;color:#DDDDDD;">`)
}<br><br><span style="opacity:0.5">If you do not believe that you are the intended recipient of this email, please disregard it.</span>`
}, (err, info) => {
if (err) reject(err)
else resolve(info)
})
})
}

View file

@ -2,9 +2,10 @@ import bodyParser from "body-parser";
import { Router } from "express"; import { Router } from "express";
import * as Accounts from "../lib/accounts"; import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth"; import * as auth from "../lib/auth";
import { sendMail } from "../lib/mail";
import ServeError from "../lib/errors"; 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"; import { writeFile } from "fs";
@ -219,6 +220,162 @@ authRoutes.post("/change_username", parser, (req,res) => {
res.send("username changed") res.send("username changed")
}) })
// shit way to do this but...
let verificationCodes = new Map<string, {code: string, email: string, expiry: NodeJS.Timeout, requestedAt:number}>()
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`, `<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${req.body.email}</span>, to your account, <span username>${acc.username}</span>. If you would like to continue, please <a href="https://${req.header("Host")}/auth/confirm_email/${code}"><span code>click here</span></a>, 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(`<script>window.close()</script>`)
} else {
ServeError(res, 400, "invalid code")
}
})
let pwReset = new Map<string, {code: string, expiry: NodeJS.Timeout, requestedAt:number}>()
let prcIdx = new Map<string, string>()
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}`, `<b>Hello there!</b> You are recieving this message because you forgot your password to your monofile account, <span username>${acc.username}</span>. To log in, please <a href="https://${req.header("Host")}/auth/emergency_login/${code}"><span code>click here</span></a>, 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) => { authRoutes.post("/change_password", parser, (req,res) => {
let acc = Accounts.getFromToken(req.cookies.auth) let acc = Accounts.getFromToken(req.cookies.auth)
if (!acc) { if (!acc) {

View file

@ -54,6 +54,13 @@
flex-grow:1; flex-grow:1;
} }
button.flavor {
padding: 0;
background: none;
}
input[type=text],input[type=password] { input[type=text],input[type=password] {
border:none; border:none;
border-radius:0; border-radius:0;

View file

@ -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) { export function pwdChng(optPicker) {
optPicker.picker("Change password",[ optPicker.picker("Change password",[
{ {

View file

@ -95,6 +95,11 @@
<input placeholder="username" type="text" bind:value={username}> <input placeholder="username" type="text" bind:value={username}>
<input placeholder="password" type="password" bind:value={password}> <input placeholder="password" type="password" bind:value={password}>
<button on:click={execute}>{ inProgress ? "• • •" : (targetAction=="login" ? "Log in" : "Create account") }</button> <button on:click={execute}>{ inProgress ? "• • •" : (targetAction=="login" ? "Log in" : "Create account") }</button>
{#if targetAction == "login"}
<button class="flavor" on:click={() => accOpts.forgotPassword(optPicker)}>I forgot my password</button>
{/if}
</div> </div>
{:else} {:else}
@ -126,6 +131,11 @@
<p>Change username</p> <p>Change username</p>
</button> </button>
<button on:click={() => accOpts.emailChange(optPicker)}>
<img src="/static/assets/icons/mail.svg" alt="change email">
<p>Change email{#if $account.email}<span class="monospaceText"><br />{$account.email}</span>{/if}</p>
</button>
<button on:click={() => accOpts.pwdChng(optPicker)}> <button on:click={() => accOpts.pwdChng(optPicker)}>
<img src="/static/assets/icons/change_password.svg" alt="change password"> <img src="/static/assets/icons/change_password.svg" alt="change password">
<p>Change password<span><br />You will be logged out of all sessions</span></p> <p>Change password<span><br />You will be logged out of all sessions</span></p>
@ -149,7 +159,7 @@
<button on:click={() => uplOpts.update_all_files(optPicker)}> <button on:click={() => uplOpts.update_all_files(optPicker)}>
<img src="/static/assets/icons/update.svg" alt="update"> <img src="/static/assets/icons/update.svg" alt="update">
<p>Make all of my files {$account.defaultFileVisibility || "public"}<span><br />Matches your default file visibility</p> <p>Make all of my files {$account.defaultFileVisibility || "public"}<span><br />Matches your default file visibility</span></p>
</button> </button>
<div class="category"> <div class="category">