feat: ✨ webhooks
This commit is contained in:
parent
17814b3478
commit
092c70da5b
10
prisma/migrations/20241120195003_add_webhooks/migration.sql
Normal file
10
prisma/migrations/20241120195003_add_webhooks/migration.sql
Normal 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");
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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<Avatar, "id"> & Partial<Pick<Avatar, "altText" | "source">>)
|
||||
| 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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
3
src/lib/common.ts
Normal file
3
src/lib/common.ts
Normal 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()@:%_\+.~#?&//=]*)/
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
115
src/routes/webhooks/+page.server.ts
Normal file
115
src/routes/webhooks/+page.server.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
98
src/routes/webhooks/+page.svelte
Normal file
98
src/routes/webhooks/+page.svelte
Normal 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>
|
Loading…
Reference in a new issue