From fc6cc0b33366c31deac02e46337c67e6e10312d5 Mon Sep 17 00:00:00 2001 From: split Date: Fri, 22 Nov 2024 19:31:11 -0800 Subject: [PATCH] feat: :sparkles: multi emails --- package-lock.json | 57 +++++--- package.json | 6 +- .../migration.sql | 26 ++++ .../20241123022424_add_id/migration.sql | 27 ++++ prisma/schema.prisma | 28 ++-- src/lib/oidc.ts | 71 +++++----- src/routes/+layout.svelte | 1 + src/routes/emails/+page.server.ts | 126 ++++++++++++++++++ src/routes/emails/+page.svelte | 96 +++++++++++++ src/routes/set/+page.svelte | 2 +- 10 files changed, 376 insertions(+), 64 deletions(-) create mode 100644 prisma/migrations/20241123022340_update_emailhashes/migration.sql create mode 100644 prisma/migrations/20241123022424_add_id/migration.sql create mode 100644 src/routes/emails/+page.server.ts create mode 100644 src/routes/emails/+page.svelte diff --git a/package-lock.json b/package-lock.json index b0845e9..4f4cfbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e99f51d..cfc1846 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/migrations/20241123022340_update_emailhashes/migration.sql b/prisma/migrations/20241123022340_update_emailhashes/migration.sql new file mode 100644 index 0000000..9abdbcb --- /dev/null +++ b/prisma/migrations/20241123022340_update_emailhashes/migration.sql @@ -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; diff --git a/prisma/migrations/20241123022424_add_id/migration.sql b/prisma/migrations/20241123022424_add_id/migration.sql new file mode 100644 index 0000000..7d88a21 --- /dev/null +++ b/prisma/migrations/20241123022424_add_id/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 051f616..e86c2d6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,12 +19,13 @@ model Token { } model User { - userId String @id @unique - identifier String - name String? - avatars Avatar[] - webhooks Webhook[] - emailHashes EmailHashes? + userId String @id @unique + identifier String + name String? + avatars Avatar[] + webhooks Webhook[] + 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]) - sha256 Bytes - md5 Bytes + + email String @unique @default("unknown:${uuid()}") + sha256 Bytes + md5 Bytes + + isPrimaryForUserId String? @unique + isPrimaryFor User? @relation("PrimaryEmail", fields: [isPrimaryForUserId], references: [userId]) + + @@unique([sha256, md5]) } diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts index a07bb55..9b17b17 100644 --- a/src/lib/oidc.ts +++ b/src/lib/oidc.ts @@ -126,20 +126,6 @@ export async function getUserInfo(id: string) { userInfo = await userInfoRequest.json() - // get emailHashes - - let emailHashes: Omit | 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 | 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) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dd46cfd..4b1e282 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -82,6 +82,7 @@ {@html ava} Set avatar {#if data.user} + Public page Log out {/if} diff --git a/src/routes/emails/+page.server.ts b/src/routes/emails/+page.server.ts new file mode 100644 index 0000000..6e2fc42 --- /dev/null +++ b/src/routes/emails/+page.server.ts @@ -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", + } + } + }, +} diff --git a/src/routes/emails/+page.svelte b/src/routes/emails/+page.svelte new file mode 100644 index 0000000..6a34cf7 --- /dev/null +++ b/src/routes/emails/+page.svelte @@ -0,0 +1,96 @@ + + + + + + Manage emails + +{#if form} +
+ + {form.success ? form.message : form.error} + +
+{/if} +{#if primaryEmail} +
+
+

Your primary email

+

+ This email is provided by your OIDC provider and cannot be changed. Go to your OIDC provider to change it. +

+
+
+ +
+
+
+
+
+{/if} +{#if otherEmails.length > 0} +
+ {#each otherEmails as email} +
+ + + +
+ {/each} +
+
+{/if} +
+ + +
+
+
+
+These emails redirect to your avatar in apps that use the libravatar API. Use this page to add any email aliases you may have. diff --git a/src/routes/set/+page.svelte b/src/routes/set/+page.svelte index 6c29f2f..9007e11 100644 --- a/src/routes/set/+page.svelte +++ b/src/routes/set/+page.svelte @@ -94,5 +94,5 @@ • Webhooks • - See user page + Emails \ No newline at end of file