feat: webhooks

This commit is contained in:
May 2024-11-20 13:03:46 -08:00
parent 17814b3478
commit 092c70da5b
Signed by: split
GPG key ID: C325C61F0BF517C0
9 changed files with 305 additions and 11 deletions

View file

@ -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");

View file

@ -23,6 +23,7 @@ model User {
identifier String identifier String
name String? name String?
avatars Avatar[] avatars Avatar[]
webhooks Webhook[]
currentAvatarId String? @unique currentAvatarId String? @unique
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id]) currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
@ -38,3 +39,12 @@ model Avatar {
altText String? altText String?
source String? source String?
} }
model Webhook {
userId String
user User @relation(fields: [userId], references: [userId])
url String
enabled Boolean @default(true)
@@unique([url, userId])
}

View file

@ -146,14 +146,20 @@ export async function getPathToAvatarForUid(
return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt) return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt)
} }
function sanitizeAvatar(avatar: Avatar | null) { export function sanitizeAvatar(
avatar:
| (Pick<Avatar, "id"> & Partial<Pick<Avatar, "altText" | "source">>)
| null
) {
return avatar return avatar
? { ? {
id: avatar.id,
altText: avatar.altText || "", altText: avatar.altText || "",
source: avatar.source || "", source: avatar.source || "",
default: false, default: false,
} }
: { : {
id: "default",
altText: "Default profile picture", altText: "Default profile picture",
source: "https://git.sucks.win/split/ava", source: "https://git.sucks.win/split/ava",
default: true, default: true,
@ -290,12 +296,21 @@ export async function createNewAvatar(
}, },
}) })
.then(() => .then(() =>
// set the user's avatar
prisma.user.update({ prisma.user.update({
where: { userId: uid }, where: { userId: uid },
data: { currentAvatarId: avatarId }, data: { currentAvatarId: avatarId },
}) })
) )
// execute webhooks
executeHooksForUser(uid, {
id: avatarId,
default: false,
...metadata,
})
return time return time
} }
@ -305,3 +320,27 @@ export function deleteAvatar(id: string) {
.delete({ where: { id } }) .delete({ where: { id } })
.then(_ => rm(targetAvatarDirectory, { recursive: true, force: true })) .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
)
)
)
}

3
src/lib/common.ts Normal file
View file

@ -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()@:%_\+.~#?&//=]*)/

View file

@ -5,7 +5,9 @@ import {
avatarDirectory, avatarDirectory,
createNewAvatar, createNewAvatar,
deleteAvatar, deleteAvatar,
executeHooksForUser,
getMetadataForUserId, getMetadataForUserId,
sanitizeAvatar,
} from "$lib/avatars.js" } from "$lib/avatars.js"
import { join } from "path" import { join } from "path"
import { prisma } from "$lib/clientsingleton" import { prisma } from "$lib/clientsingleton"
@ -31,6 +33,7 @@ export const actions = {
let user = await getRequestUser(request, cookies) let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" }) if (!user) return fail(401, { error: "unauthenticated" })
// check if user can edit
if ( if (
!(await prisma.avatar.findUnique({ !(await prisma.avatar.findUnique({
where: { id, userId: user.sub }, where: { id, userId: user.sub },
@ -44,21 +47,28 @@ export const actions = {
(await request.formData()).entries() (await request.formData()).entries()
) )
let data = {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
}
if (action == "Save") { if (action == "Save") {
await prisma.avatar.update({ await prisma.avatar.update({
where: { where: {
id, id,
}, },
data: { data,
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
},
}) })
return redirect(302, "/set")
} else if (action == "Delete") { } else if (action == "Delete") {
await deleteAvatar(id) await deleteAvatar(id)
return redirect(302, "/set")
} }
// execute webhooks
executeHooksForUser(
user.sub,
sanitizeAvatar(action == "Save" ? { id, ...data } : null)
)
return redirect(302, "/set")
}, },
} }

View file

@ -1,7 +1,12 @@
import { getRequestUser, launchLogin } from "$lib/oidc" import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js" import configuration from "$lib/configuration.js"
import { fail } from "@sveltejs/kit" 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 { join } from "path"
import { prisma } from "$lib/clientsingleton" import { prisma } from "$lib/clientsingleton"
export async function load({ request, parent, url }) { export async function load({ request, parent, url }) {
@ -43,6 +48,8 @@ export const actions = {
}, },
}) })
executeHooksForUser(user.sub, sanitizeAvatar(null))
return { return {
success: true, success: true,
message: "Avatar cleared successfully", message: "Avatar cleared successfully",
@ -72,6 +79,8 @@ export const actions = {
}, },
}) })
executeHooksForUser(user.sub, sanitizeAvatar(avatar))
return { return {
success: true, success: true,
message: "New avatar set", message: "New avatar set",

View file

@ -37,7 +37,7 @@
border: 1px solid var(--crust); border: 1px solid var(--crust);
padding: 5px; padding: 5px;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
border-radius: 100%; border-radius: 8px;
cursor: pointer; cursor: pointer;
background-color: var(--background); background-color: var(--background);
fill: var(--text); fill: var(--text);

View file

@ -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",
}
}
},
}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
webhooks: {id: string, url: string, enabled: boolean}[]
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let files: FileList | undefined = $state();
let fileSrc = $derived(files && files.length >= 0 ? URL.createObjectURL(files.item(0)!) : "")
</script>
<style>
form {
display: flex;
gap: 10px;
}
form > input[type="url"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form {
cursor: pointer;
}
/*form input[name="id"] {
display: none;
}*/
form 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);
}
form input[type="submit"].enabled {
border: 1px solid var(--green);
background-color: color-mix(in srgb, var(--green) 20%, var(--background) 80%);
color: var(--green);
}
form input[type="submit"].disabled {
border: 1px solid var(--text);
background-color: var(--background);
opacity: 0.25;
}
form input[type="submit"].disabled, form input[type="submit"].enabled {
font-size: 1;
padding: 0.25em .5em;
flex-shrink: 0;
aspect-ratio: 1 / 1;
}
div {
display: flex;
flex-direction: column;
gap: .5em;
}
</style>
<ReversibleHeading to="/set">Manage webhooks</ReversibleHeading>
{#if form}
<br>
<StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
<br>
{/if}
<div>
{#each data.webhooks as webhook}
<form method="post" enctype="multipart/form-data" action="?/manage">
<input type="submit" name="toggle" class="{webhook.enabled ? "enabled" : "disabled"}" value="⏻" aria-label={webhook.enabled ? "Enabled" : "Disabled"}>
<input type="url" name="url" readonly value={webhook.url}>
<input type="submit" name="action" value="Delete">
</form>
{/each}
</div>
<br>
<form method="post" enctype="multipart/form-data" action="?/create">
<input type="url" name="url" placeholder="URL">
<input type="submit" name="action" value="Add">
</form>
<br>
<hr>
<br>
URLs added to this page will be sent a POST request with the following payload when you change or edit your current profile picture:
<pre>
{`{
"id": string
"altText": string
"source": string
"default": boolean
}`}
</pre>