diff --git a/prisma/migrations/20241120195003_add_webhooks/migration.sql b/prisma/migrations/20241120195003_add_webhooks/migration.sql new file mode 100644 index 0000000..6589a03 --- /dev/null +++ b/prisma/migrations/20241120195003_add_webhooks/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "Webhook" ( + "userId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Webhook_url_userId_key" ON "Webhook"("url", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97ede38..d1dcdec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,10 +19,11 @@ model Token { } model User { - userId String @id @unique + userId String @id @unique identifier String name String? avatars Avatar[] + webhooks Webhook[] currentAvatarId String? @unique currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id]) @@ -38,3 +39,12 @@ model Avatar { altText String? source String? } + +model Webhook { + userId String + user User @relation(fields: [userId], references: [userId]) + url String + enabled Boolean @default(true) + + @@unique([url, userId]) +} diff --git a/src/lib/avatars.ts b/src/lib/avatars.ts index b474eaa..b758433 100644 --- a/src/lib/avatars.ts +++ b/src/lib/avatars.ts @@ -146,14 +146,20 @@ export async function getPathToAvatarForUid( return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt) } -function sanitizeAvatar(avatar: Avatar | null) { +export function sanitizeAvatar( + avatar: + | (Pick & Partial>) + | null +) { return avatar ? { + id: avatar.id, altText: avatar.altText || "", source: avatar.source || "", default: false, } : { + id: "default", altText: "Default profile picture", source: "https://git.sucks.win/split/ava", default: true, @@ -290,12 +296,21 @@ export async function createNewAvatar( }, }) .then(() => + // set the user's avatar prisma.user.update({ where: { userId: uid }, data: { currentAvatarId: avatarId }, }) ) + // execute webhooks + + executeHooksForUser(uid, { + id: avatarId, + default: false, + ...metadata, + }) + return time } @@ -305,3 +320,27 @@ export function deleteAvatar(id: string) { .delete({ where: { id } }) .then(_ => rm(targetAvatarDirectory, { recursive: true, force: true })) } + +export async function executeHooksForUser( + userId: string, + payload: { id: string; altText?: string; source?: string; default: boolean } +) { + let hooks = await prisma.webhook.findMany({ + where: { + enabled: true, + userId, + }, + }) + + hooks.forEach(async hook => + fetch(hook.url, { + method: "POST", + body: JSON.stringify(payload), + }).catch(e => + console.error( + `error executing webhook ${hook.url} for userid ${userId}:`, + e + ) + ) + ) +} diff --git a/src/lib/common.ts b/src/lib/common.ts new file mode 100644 index 0000000..77618b7 --- /dev/null +++ b/src/lib/common.ts @@ -0,0 +1,3 @@ +// https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url +export const URL_REGEX = + /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ diff --git a/src/routes/avatars/[id]/+page.server.ts b/src/routes/avatars/[id]/+page.server.ts index 415b418..661f6fd 100644 --- a/src/routes/avatars/[id]/+page.server.ts +++ b/src/routes/avatars/[id]/+page.server.ts @@ -5,7 +5,9 @@ import { avatarDirectory, createNewAvatar, deleteAvatar, + executeHooksForUser, getMetadataForUserId, + sanitizeAvatar, } from "$lib/avatars.js" import { join } from "path" import { prisma } from "$lib/clientsingleton" @@ -31,6 +33,7 @@ export const actions = { let user = await getRequestUser(request, cookies) if (!user) return fail(401, { error: "unauthenticated" }) + // check if user can edit if ( !(await prisma.avatar.findUnique({ where: { id, userId: user.sub }, @@ -44,21 +47,28 @@ export const actions = { (await request.formData()).entries() ) + let data = { + altText: altText instanceof File ? undefined : altText, + source: source instanceof File ? undefined : source, + } + if (action == "Save") { await prisma.avatar.update({ where: { id, }, - data: { - altText: altText instanceof File ? undefined : altText, - source: source instanceof File ? undefined : source, - }, + data, }) - - return redirect(302, "/set") } else if (action == "Delete") { await deleteAvatar(id) - return redirect(302, "/set") } + + // execute webhooks + executeHooksForUser( + user.sub, + sanitizeAvatar(action == "Save" ? { id, ...data } : null) + ) + + return redirect(302, "/set") }, } diff --git a/src/routes/set/+page.server.ts b/src/routes/set/+page.server.ts index 7b6391a..aa3a775 100644 --- a/src/routes/set/+page.server.ts +++ b/src/routes/set/+page.server.ts @@ -1,7 +1,12 @@ import { getRequestUser, launchLogin } from "$lib/oidc" import configuration from "$lib/configuration.js" import { fail } from "@sveltejs/kit" -import { avatarDirectory, getMetadataForUserId } from "$lib/avatars.js" +import { + avatarDirectory, + executeHooksForUser, + getMetadataForUserId, + sanitizeAvatar, +} from "$lib/avatars.js" import { join } from "path" import { prisma } from "$lib/clientsingleton" export async function load({ request, parent, url }) { @@ -43,6 +48,8 @@ export const actions = { }, }) + executeHooksForUser(user.sub, sanitizeAvatar(null)) + return { success: true, message: "Avatar cleared successfully", @@ -72,6 +79,8 @@ export const actions = { }, }) + executeHooksForUser(user.sub, sanitizeAvatar(avatar)) + return { success: true, message: "New avatar set", diff --git a/src/routes/set/+page.svelte b/src/routes/set/+page.svelte index bc89926..6c29f2f 100644 --- a/src/routes/set/+page.svelte +++ b/src/routes/set/+page.svelte @@ -37,7 +37,7 @@ border: 1px solid var(--crust); padding: 5px; aspect-ratio: 1 / 1; - border-radius: 100%; + border-radius: 8px; cursor: pointer; background-color: var(--background); fill: var(--text); diff --git a/src/routes/webhooks/+page.server.ts b/src/routes/webhooks/+page.server.ts new file mode 100644 index 0000000..9fa2b9f --- /dev/null +++ b/src/routes/webhooks/+page.server.ts @@ -0,0 +1,115 @@ +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" +export async function load({ request, parent, url }) { + const { user } = await parent() + if (!user) return launchLogin(url.toString()) + + return { + url: url.toString(), + webhooks: await prisma.webhook.findMany({ + where: { userId: user.sub }, + }), + } +} + +export const actions = { + create: async ({ request, cookies }) => { + let user = await getRequestUser(request, cookies) + if (!user) return fail(401, { error: "unauthenticated" }) + + let { url } = Object.fromEntries((await request.formData()).entries()) + + if (!url || url instanceof File) + return fail(400, { error: "no url supplied" }) + if (url.match(URL_REGEX)?.[0] !== url) + return fail(400, { error: "bad url" }) + + url = new URL(url).toString() + + if ( + await prisma.webhook.findFirst({ + where: { + userId: user.sub, + url, + }, + }) + ) + return fail(409, { error: "Webhook already exists" }) + + await prisma.webhook.create({ + data: { + url, + userId: user.sub, + }, + }) + + return { + success: true, + message: "New webhook created", + } + }, + manage: async ({ request, cookies }) => { + let user = await getRequestUser(request, cookies) + if (!user) return fail(401, { error: "unauthenticated" }) + + let { action, toggle, url } = Object.fromEntries( + (await request.formData()).entries() + ) + + if (!url || url instanceof File) + return fail(400, { error: "no url supplied" }) + + let whk = await prisma.webhook.findUnique({ + where: { + url_userId: { + url, + userId: user.sub, + }, + }, + }) + + if (!whk) return fail(404, { error: "webhook doesn't exist" }) + + if (action == "Delete") { + await prisma.webhook.delete({ + where: { + url_userId: { + url, + userId: user.sub, + }, + }, + }) + + return { + success: true, + message: "Webhook deleted", + } + } else if (toggle) { + await prisma.webhook.update({ + where: { + url_userId: { + url, + userId: user.sub, + }, + }, + data: { + enabled: !whk.enabled, + }, + }) + + return { + success: true, + message: "Webhook updated", + } + } + }, +} diff --git a/src/routes/webhooks/+page.svelte b/src/routes/webhooks/+page.svelte new file mode 100644 index 0000000..4efc1e5 --- /dev/null +++ b/src/routes/webhooks/+page.svelte @@ -0,0 +1,98 @@ + + + + +Manage webhooks +{#if form} +
+ {form.success ? form.message : form.error} +
+{/if} +
+ {#each data.webhooks as webhook} +
+ + + +
+ {/each} +
+
+
+ + +
+
+
+
+URLs added to this page will be sent a POST request with the following payload when you change or edit your current profile picture: +
+{`{
+    "id": string
+    "altText": string
+    "source": string
+    "default": boolean
+}`}
+
\ No newline at end of file