This commit is contained in:
split / May 2024-03-09 12:43:05 -08:00
parent 86f5727d83
commit 00fcf4580f
7 changed files with 165 additions and 26 deletions

View file

@ -1,6 +1,7 @@
import { createTransport } from "nodemailer"
import "dotenv/config"
import config from "../../../config.json" assert {type:"json"}
import { generateFileId } from "./files.js"
let mailConfig = config.mail,
transport = createTransport({
@ -34,3 +35,63 @@ export function sendMail(to: string, subject: string, content: string) {
)}<br><br><span style="opacity:0.5">If you do not believe that you are the intended recipient of this email, please disregard this message.</span>`,
})
}
export namespace CodeMgr {
export const Intents = [
"verifyEmail",
"recoverAccount"
] as const
export type Intent = typeof Intents[number]
export function isIntent(intent: string): intent is Intent { return intent in Intents }
export let codes = Object.fromEntries(
Intents.map(e => [
e,
{byId: new Map<string, Code>(), byUser: new Map<string, Code[]>()}
])) as Record<Intent, { byId: Map<string, Code>, byUser: Map<string, Code[]> }>
// this is stupid whyd i write this
export class Code {
readonly id: string = generateFileId(12)
readonly for: string
readonly intent: Intent
readonly expiryClear: NodeJS.Timeout
readonly data: any
constructor(intent: Intent, forUser: string, data?: any, time: number = 15*60*1000) {
this.for = forUser;
this.intent = intent
this.expiryClear = setTimeout(this.terminate.bind(this), time)
this.data = data
codes[intent].byId.set(this.id, this);
let byUser = codes[intent].byUser.get(this.for)
if (!byUser) {
byUser = []
codes[intent].byUser.set(this.for, byUser);
}
byUser.push(this)
}
terminate() {
codes[this.intent].byId.delete(this.id);
let bu = codes[this.intent].byUser.get(this.id)!
bu.splice(bu.indexOf(this), 1)
clearTimeout(this.expiryClear)
}
check(forUser: string) {
return forUser === this.for
}
}
}

View file

@ -1,7 +1,8 @@
import * as Accounts from "./accounts.js"
import { Handler as RequestHandler } from "hono"
import type { Context, Handler as RequestHandler } from "hono"
import ServeError from "../lib/errors.js"
import * as auth from "./auth.js"
import { setCookie } from "hono/cookie"
/**
* @description Middleware which adds an account, if any, to ctx.get("account")
@ -92,6 +93,15 @@ export const assertAPI = function (
}
}
// Not really middleware but a utility
export const login = (ctx: Context, account: string) => setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), {
path: "/",
sameSite: "Strict",
secure: true,
httpOnly: true
})
type SchemeType = "array" | "object" | "string" | "number" | "boolean"
interface SchemeObject {

View file

@ -1,7 +1,7 @@
// Modules
import { Hono } from "hono"
import { type Context, Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
// Libs
@ -12,12 +12,13 @@ import * as auth from "../../../lib/auth.js"
import {
assertAPI,
getAccount,
login,
noAPIAccess,
requiresAccount,
requiresPermissions,
} from "../../../lib/middleware.js"
import ServeError from "../../../lib/errors.js"
import { sendMail } from "../../../lib/mail.js"
import { CodeMgr, sendMail } from "../../../lib/mail.js"
import Configuration from "../../../../../config.json" assert {type:"json"}
@ -29,7 +30,7 @@ const router = new Hono<{
}>()
type UserUpdateParameters = Partial<Omit<Accounts.Account, "password"> & { password: string, currentPassword?: string }>
type Message = [200 | 400 | 401 | 403 | 501, string]
type Message = [200 | 400 | 401 | 403 | 429 | 501, string]
// there's probably a less stupid way to do this than `K in keyof Pick<UserUpdateParameters, T>`
// @Jack5079 make typings better if possible
@ -42,12 +43,15 @@ type Validator<T extends keyof Partial<Accounts.Account>, ValueNotNull extends b
*/
(actor: Accounts.Account, target: Accounts.Account, params: UserUpdateParameters & (ValueNotNull extends true ? {
[K in keyof Pick<UserUpdateParameters, T>]-? : UserUpdateParameters[K]
} : {})) => Accounts.Account[T] | Message
} : {}), ctx: Context) => Accounts.Account[T] | Message
// this type is so stupid stg
interface ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> {
acceptsNull?: boolean,
validator: Validator<T, this["acceptsNull"] extends true ? true : false> // i give upp ill fix this later
type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> = {
acceptsNull: true,
validator: Validator<T, false>
} | {
acceptsNull?: false,
validator: Validator<T, true>
}
const validators: {
@ -61,7 +65,7 @@ const validators: {
},
email: {
acceptsNull: true,
validator: (actor, target, params) => {
validator: (actor, target, params, ctx) => {
if (!params.currentPassword // actor on purpose here to allow admins
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword)))
return [401, "current password incorrect"]
@ -76,6 +80,34 @@ const validators: {
}
return undefined
}
if (typeof params.email !== "string") return [400, "email must be string"]
if (actor.admin)
return params.email
// send verification email
if ((CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || 0) >= 2) return [429, "you have too many active codes"]
let code = new CodeMgr.Code("verifyEmail", target.id, params.email)
sendMail(
params.email,
`Hey there, ${target.username} - let's connect your email`,
`<b>Hello there!</b> You are recieving this message because you decided to link your email, <span code>${
params.email.split("@")[0]
}<span style="opacity:0.5">@${
params.email.split("@")[1]
}</span></span>, to your account, <span username>${
target.username
}</span>. If you would like to continue, please <a href="https://${ctx.req.header(
"Host"
)}/go/verify/${code.id}"><span code>click here</span></a>, or go to https://${ctx.req.header(
"Host"
)}/go/verify/${code.id}.`
)
return [200, "please check your inbox"]
}
},
password(actor, target, params) {
@ -144,6 +176,7 @@ const validators: {
}
},
embed(actor, target, params) {
if (typeof params.embed !== "object") return [400, "must use an object for embed"]
if (params.embed.color === undefined) {
params.embed.color = target.embed?.color
} else if (!((params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] ==
@ -236,13 +269,8 @@ export default function (files: Files) {
return Accounts.create(body.username, body.password)
.then((account) => {
setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), {
path: "/",
sameSite: "Strict",
secure: true,
httpOnly: true
})
return ctx.status(200)
login(ctx, account)
return ctx.text("logged in")
})
.catch(() => {
return ServeError(ctx, 500, "internal server error")
@ -280,7 +308,7 @@ export default function (files: Files) {
return [
x,
validator.validator(actor, target, body)
validator.validator(actor, target, body as any, ctx)
] as [keyof Accounts.Account, Accounts.Account[keyof Accounts.Account]]
})

View file

@ -11,6 +11,7 @@ import * as Accounts from "../../../lib/accounts.js"
import * as auth from "../../../lib/auth.js"
import {
getAccount,
login,
requiresAccount
} from "../../../lib/middleware.js"
import ServeError from "../../../lib/errors.js"
@ -45,13 +46,9 @@ export default function (files: Files) {
ServeError(ctx, 400, "username or password incorrect")
return
}
setCookie(ctx, "auth", auth.create(account.id, 3 * 24 * 60 * 60 * 1000), {
path: "/",
sameSite: "Strict",
secure: true,
httpOnly: true
})
ctx.status(200)
login(ctx, account.id)
return ctx.text("logged in")
})
router.get("/", requiresAccount, ctx => {

View file

@ -2,6 +2,7 @@
"name": "web",
"baseURL": "/",
"mount": [
{ "file": "preview", "to": "/download" }
{ "file": "preview", "to": "/download" },
"go"
]
}

View file

@ -0,0 +1,41 @@
import fs from "fs/promises"
import bytes from "bytes"
import ServeError from "../../../lib/errors.js"
import * as Accounts from "../../../lib/accounts.js"
import type Files from "../../../lib/files.js"
import pkg from "../../../../../package.json" assert {type:"json"}
import { CodeMgr } from "../../../lib/mail.js"
import { Hono } from "hono"
import { getAccount, login } from "../../../lib/middleware.js"
export let router = new Hono<{
Variables: {
account: Accounts.Account
}
}>()
export default function (files: Files) {
router.get("/verify/:code", getAccount, async (ctx) => {
let currentAccount = ctx.get("account")
let code = CodeMgr.codes.verifyEmail.byId.get(ctx.req.param("code"))
if (code) {
if (currentAccount != undefined && !code.check(currentAccount.id)) {
return ServeError(ctx, 403, "you are logged in on a different account")
}
if (!currentAccount) {
login(ctx, code.for)
let ac = Accounts.getFromId(code.for)
if (ac) currentAccount = ac
else return ServeError(ctx, 401, "could not locate account")
}
currentAccount.email = code.data
await Accounts.save()
return ctx.redirect('/')
} else return ServeError(ctx, 404, "code not found")
})
return router
}

View file

@ -5,6 +5,7 @@ import * as Accounts from "../../../lib/accounts.js"
import type Files from "../../../lib/files.js"
import pkg from "../../../../../package.json" assert {type:"json"}
import { Hono } from "hono"
import { getAccount } from "../../../lib/middleware.js"
export let router = new Hono<{
Variables: {
account: Accounts.Account
@ -12,7 +13,7 @@ export let router = new Hono<{
}>()
export default function (files: Files) {
router.get("/:fileId", async (ctx) => {
router.get("/:fileId", getAccount, async (ctx) => {
let acc = ctx.get("account") as Accounts.Account
const fileId = ctx.req.param("fileId")
const host = ctx.req.header("Host")