feat: multi emails

This commit is contained in:
May 2024-11-22 19:31:11 -08:00
parent 882065381a
commit fc6cc0b333
Signed by: split
GPG key ID: C325C61F0BF517C0
10 changed files with 376 additions and 64 deletions

57
package-lock.json generated
View file

@ -1,17 +1,17 @@
{
"name": "ava",
"version": "2.0.0",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ava",
"version": "2.0.0",
"version": "2.0.2",
"dependencies": {
"@fluentui/svg-icons": "^1.1.265",
"@fontsource-variable/inter": "^5.0.18",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@prisma/client": "5.16.2",
"@prisma/client": "^5.22.0",
"@sveltejs/kit": "^2.5.27",
"mime": "^4.0.4",
"sharp": "^0.33.5"
@ -22,7 +22,7 @@
"@types/node": "^20.14.10",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
"prisma": "^5.16.2",
"prisma": "^5.22.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tslib": "^2.4.1",
@ -512,7 +512,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -528,43 +530,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.16.2",
"@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/fetch-engine": "5.16.2",
"@prisma/get-platform": "5.16.2"
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.16.2",
"@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303",
"@prisma/get-platform": "5.16.2"
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.16.2"
"@prisma/debug": "5.22.0"
}
},
"node_modules/@rollup/plugin-commonjs": {
@ -1630,18 +1642,23 @@
}
},
"node_modules/prisma": {
"version": "5.16.2",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.16.2"
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/readdirp": {

View file

@ -1,6 +1,6 @@
{
"name": "ava",
"version": "2.0.2",
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
@ -17,7 +17,7 @@
"@types/node": "^20.14.10",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
"prisma": "^5.16.2",
"prisma": "^5.22.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tslib": "^2.4.1",
@ -29,7 +29,7 @@
"@fluentui/svg-icons": "^1.1.265",
"@fontsource-variable/inter": "^5.0.18",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@prisma/client": "5.16.2",
"@prisma/client": "^5.22.0",
"@sveltejs/kit": "^2.5.27",
"mime": "^4.0.4",
"sharp": "^0.33.5"

View file

@ -0,0 +1,26 @@
/*
Warnings:
- The primary key for the `EmailHashes` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_EmailHashes" (
"forUserId" TEXT NOT NULL,
"email" TEXT NOT NULL DEFAULT 'unknown:${uuid()}',
"sha256" BLOB NOT NULL,
"md5" BLOB NOT NULL,
"isPrimaryForUserId" TEXT,
CONSTRAINT "EmailHashes_forUserId_fkey" FOREIGN KEY ("forUserId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "EmailHashes_isPrimaryForUserId_fkey" FOREIGN KEY ("isPrimaryForUserId") REFERENCES "User" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_EmailHashes" ("forUserId", "md5", "sha256") SELECT "forUserId", "md5", "sha256" FROM "EmailHashes";
DROP TABLE "EmailHashes";
ALTER TABLE "new_EmailHashes" RENAME TO "EmailHashes";
CREATE UNIQUE INDEX "EmailHashes_email_key" ON "EmailHashes"("email");
CREATE UNIQUE INDEX "EmailHashes_isPrimaryForUserId_key" ON "EmailHashes"("isPrimaryForUserId");
CREATE UNIQUE INDEX "EmailHashes_sha256_md5_key" ON "EmailHashes"("sha256", "md5");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -0,0 +1,27 @@
/*
Warnings:
- The required column `id` was added to the `EmailHashes` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_EmailHashes" (
"id" TEXT NOT NULL PRIMARY KEY,
"forUserId" TEXT NOT NULL,
"email" TEXT NOT NULL DEFAULT 'unknown:${uuid()}',
"sha256" BLOB NOT NULL,
"md5" BLOB NOT NULL,
"isPrimaryForUserId" TEXT,
CONSTRAINT "EmailHashes_forUserId_fkey" FOREIGN KEY ("forUserId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "EmailHashes_isPrimaryForUserId_fkey" FOREIGN KEY ("isPrimaryForUserId") REFERENCES "User" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_EmailHashes" ("email", "forUserId", "isPrimaryForUserId", "md5", "sha256") SELECT "email", "forUserId", "isPrimaryForUserId", "md5", "sha256" FROM "EmailHashes";
DROP TABLE "EmailHashes";
ALTER TABLE "new_EmailHashes" RENAME TO "EmailHashes";
CREATE UNIQUE INDEX "EmailHashes_email_key" ON "EmailHashes"("email");
CREATE UNIQUE INDEX "EmailHashes_isPrimaryForUserId_key" ON "EmailHashes"("isPrimaryForUserId");
CREATE UNIQUE INDEX "EmailHashes_sha256_md5_key" ON "EmailHashes"("sha256", "md5");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -24,7 +24,8 @@ model User {
name String?
avatars Avatar[]
webhooks Webhook[]
emailHashes EmailHashes?
emailHashes EmailHashes[]
primaryEmail EmailHashes? @relation("PrimaryEmail")
currentAvatarId String? @unique
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
@ -51,8 +52,17 @@ model Webhook {
}
model EmailHashes {
forUserId String @id
id String @id @default(uuid())
forUserId String
user User @relation(fields: [forUserId], references: [userId])
email String @unique @default("unknown:${uuid()}")
sha256 Bytes
md5 Bytes
isPrimaryForUserId String? @unique
isPrimaryFor User? @relation("PrimaryEmail", fields: [isPrimaryForUserId], references: [userId])
@@unique([sha256, md5])
}

View file

@ -126,20 +126,6 @@ export async function getUserInfo(id: string) {
userInfo = await userInfoRequest.json()
// get emailHashes
let emailHashes: Omit<EmailHashes, "forUserId"> | undefined = undefined
if (userInfo.email) {
emailHashes = {
sha256: crypto
.createHash("sha256")
.update(userInfo.email)
.digest(),
md5: crypto.createHash("md5").update(userInfo.email).digest(),
}
}
// update user
await prisma.user.upsert({
where: {
@ -148,31 +134,54 @@ export async function getUserInfo(id: string) {
update: {
identifier: userInfo[configuration.userinfo.identifier],
name: userInfo.name,
...(emailHashes
? {
emailHashes: {
upsert: {
create: emailHashes,
update: emailHashes,
},
},
}
: {}),
},
create: {
userId: userInfo.sub,
identifier: userInfo[configuration.userinfo.identifier],
name: userInfo.name,
...(emailHashes
? {
emailHashes: {
create: emailHashes,
},
}
: {}),
},
})
// get emailHashes
let emailHashes: Omit<EmailHashes, "id"> | undefined = undefined
if (userInfo.email) {
emailHashes = {
sha256: crypto
.createHash("sha256")
.update(userInfo.email)
.digest(),
md5: crypto.createHash("md5").update(userInfo.email).digest(),
email: userInfo.email,
isPrimaryForUserId: userInfo.sub,
forUserId: userInfo.sub,
}
// manual upsert here so we can use findFirst
let f = await prisma.emailHashes.findFirst({
where: {
OR: [
{
isPrimaryForUserId: userInfo.sub,
},
{
sha256: emailHashes.sha256,
md5: emailHashes.md5,
},
],
},
})
await (f
? prisma.emailHashes.update({
where: { id: f.id },
data: emailHashes,
})
: prisma.emailHashes.create({ data: emailHashes }))
}
// cache userinfo
userInfoCache.set(tokenInfo.owner, userInfo)
setTimeout(() => userInfoCache.delete(tokenInfo.owner), 15 * 60 * 1000)

View file

@ -82,6 +82,7 @@
<a href="/">{@html ava}</a>
<a href="/set">Set avatar</a>
{#if data.user}
<a href="/user/{data.user.identifier}">Public page</a>
<a href="/logout">Log out</a>
{/if}
</nav>

View file

@ -0,0 +1,126 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
getMetadataForUserId,
} from "$lib/avatars.js"
import { join } from "path"
import { prisma } from "$lib/clientsingleton"
import { URL_REGEX } from "$lib/common.js"
import crypto from "node:crypto"
export async function load({ request, parent, url }) {
const { user } = await parent()
if (!user) return launchLogin(url.toString())
return {
url: url.toString(),
emails: (
await prisma.emailHashes.findMany({
where: { forUserId: user.sub },
})
).map(e =>
Object.fromEntries([
...Object.entries(e).filter(([k]) =>
["email", "id"].includes(k)
),
["isPrimary", Boolean(e.isPrimaryForUserId)],
])
),
}
}
export const actions = {
create: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { email } = Object.fromEntries((await request.formData()).entries())
if (!email || email instanceof File)
return fail(400, { error: "no email supplied" })
email = email.toLowerCase()
const sha256 = crypto.createHash("sha256").update(email).digest(),
md5 = crypto.createHash("md5").update(email).digest()
// check hashes because 2.0 didn't store email string
let { user: ownedBy } =
(await prisma.emailHashes.findUnique({
where: {
sha256_md5: {
sha256,
md5,
},
},
select: {
user: {
select: {
name: true,
identifier: true,
},
},
},
})) || {}
if (ownedBy)
return fail(409, {
error: `Email already owned by ${user.name} (${user.identifier})`,
})
await prisma.emailHashes.create({
data: {
email,
sha256,
md5,
forUserId: user.sub,
},
})
return {
success: true,
message: "Email claimed",
}
},
manage: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { action, id } = Object.fromEntries(
(await request.formData()).entries()
)
if (!id || id instanceof File)
return fail(400, { error: "No email supplied" })
id = id.toLowerCase()
let emhash = await prisma.emailHashes.findUnique({
where: {
id,
forUserId: user.sub,
},
})
if (!emhash) return fail(404, { error: "Email doesn't exist" })
if (emhash.isPrimaryForUserId == user.sub)
return fail(403, { error: "You can't delete the primary email" })
if (action == "Delete") {
await prisma.emailHashes.delete({
where: {
id,
forUserId: user.sub,
},
})
return {
success: true,
message: "Email removed from account",
}
}
},
}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
emails: {isPrimary: boolean, email: string, id: string}[]
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let otherEmails = data.emails.filter(e => !e.isPrimary)
let primaryEmail = data.emails.find(e => e.isPrimary)
</script>
<style>
form {
display: flex;
gap: 10px;
}
input[type="email"] {
flex-basis: 100%;
min-height: 1em;
}
input[type="submit"], form {
cursor: pointer;
}
form input[name="id"] {
display: none;
}
input {
font-family: "Inter Variable", "Inter", sans-serif;
padding: 0.5em 1em;
border-radius: 8px;
border: 1px solid var(--link);
color: var(--text);
background-color: var(--crust);
}
div.flex {
display: flex;
flex-direction: column;
gap: .5em;
}
</style>
<ReversibleHeading to="/set">
Manage emails
</ReversibleHeading>
{#if form}
<br>
<StatusBanner status={form.success ? "success" : "error"}>
{form.success ? form.message : form.error}
</StatusBanner>
<br>
{/if}
{#if primaryEmail}
<div>
<hgroup>
<h2>Your primary email</h2>
<p>
This email is provided by your OIDC provider and cannot be changed. Go to your OIDC provider to change it.
</p>
</hgroup>
<form method="post" enctype="multipart/form-data">
<input type="email" name="email" readonly value={primaryEmail.email}>
</form>
</div>
<br>
<hr>
<br>
{/if}
{#if otherEmails.length > 0}
<div class="flex">
{#each otherEmails as email}
<form method="post" enctype="multipart/form-data" action="?/manage">
<input name="id" readonly value={email.id}>
<input type="email" name="email" readonly value={email.email}>
<input type="submit" name="action" value="Delete">
</form>
{/each}
</div>
<br>
{/if}
<form method="post" enctype="multipart/form-data" action="?/create">
<input type="email" name="email" placeholder="Email">
<input type="submit" name="action" value="Add">
</form>
<br>
<hr>
<br>
These emails redirect to your avatar in apps that use the libravatar API. Use this page to add any email aliases you may have.

View file

@ -94,5 +94,5 @@
&bullet;
<a href="/webhooks">Webhooks</a>
&bullet;
<a href="/user/{data.user.identifier}" target="_blank">See user page</a>
<a href="/emails">Emails</a>
</footer>