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