diff --git a/.gitignore b/.gitignore index bcf596a4..6604f6ff 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ web_modules/ .next .open-next next-env.d.ts +cloudflare-env.d.ts out # Nuxt.js build / generate output diff --git a/apps/web/next.config.js b/apps/web/next.config.js index e562592c..5c851070 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,10 +1,22 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'; initOpenNextCloudflareForDev(); +const monorepoRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + outputFileTracingRoot: monorepoRoot, + turbopack: { + root: monorepoRoot, + }, images: { remotePatterns: [ { diff --git a/apps/web/src/app/(main)/form/[id]/form-settings.tsx b/apps/web/src/app/(main)/form/[id]/form-settings.tsx index 3f807b5a..61239794 100644 --- a/apps/web/src/app/(main)/form/[id]/form-settings.tsx +++ b/apps/web/src/app/(main)/form/[id]/form-settings.tsx @@ -3,12 +3,14 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { formatDistanceToNow } from 'date-fns'; import { BellRing, ExternalLink, FolderPen, FolderX, Shield, + Webhook, } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; @@ -16,6 +18,7 @@ import { z } from 'zod'; import { type RouterOutputs } from '@formbase/api'; import { type User } from '@formbase/auth'; +import { Badge } from '@formbase/ui/primitives/badge'; import { Form, FormControl, @@ -27,7 +30,9 @@ import { import { Input } from '@formbase/ui/primitives/input'; import { Label } from '@formbase/ui/primitives/label'; import { Switch } from '@formbase/ui/primitives/switch'; +import { isValidWebhookUrl } from '@formbase/utils/webhook'; +import { CopyButton } from '~/components/copy-button'; import { LoadingButton } from '~/components/loading-button'; import { api } from '~/lib/trpc/react'; @@ -58,6 +63,18 @@ const honeypotFieldSchema = z.object({ honeypotField: z.string().min(1).optional(), }); +const webhookSettingsSchema = z.object({ + enableWebhook: z.boolean().default(false).optional(), + webhookUrl: z + .string() + .url() + .refine(isValidWebhookUrl, { + message: 'URL must use HTTPS (localhost allowed for development)', + }) + .optional() + .or(z.literal('')), +}); + type FormNameSchema = z.infer; type EnableFormSubmissionsSchema = z.infer; type EnableFormNotificationsSchema = z.infer; @@ -113,6 +130,13 @@ export function FormSettings({ form, user }: FormSettingsProps) { honeypotField={form.honeypotField} /> + +
@@ -597,3 +621,238 @@ const HoneypotFieldSetting = ({ ); }; + +type WebhookSettingsSchema = z.infer; + +const WebhookSettings = ({ + formId, + enableWebhook, + webhookUrl, + webhookSecret, +}: { + formId: string; + enableWebhook: boolean; + webhookUrl: string; + webhookSecret: string | null; +}) => { + const router = useRouter(); + + const webhookForm = useForm({ + resolver: zodResolver(webhookSettingsSchema), + defaultValues: { + enableWebhook, + webhookUrl, + }, + }); + + const watchEnableWebhook = webhookForm.watch('enableWebhook'); + const watchWebhookUrl = webhookForm.watch('webhookUrl'); + + const { mutateAsync: updateWebhookSettings, isPending: isUpdating } = + api.form.update.useMutation(); + + const { mutateAsync: testWebhook, isPending: isTesting } = + api.form.testWebhook.useMutation(); + + const { data: deliveries } = api.form.listDeliveries.useQuery( + { formId }, + { enabled: watchEnableWebhook ?? false }, + ); + + async function handleEnableWebhookChange(isChecked: boolean) { + webhookForm.setValue('enableWebhook', isChecked); + + try { + await updateWebhookSettings({ + id: formId, + enableWebhook: isChecked, + }); + + toast( + isChecked ? 'Webhook has been enabled' : 'Webhook has been disabled', + { icon: }, + ); + + router.refresh(); + } catch { + toast('Failed to update webhook settings', { + description: 'Please try again later', + icon: , + }); + } + } + + async function handleWebhookUrlSubmit(data: WebhookSettingsSchema) { + try { + await updateWebhookSettings({ + id: formId, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + webhookUrl: data.webhookUrl || null, + }); + + toast('Webhook URL has been updated', { + icon: , + }); + + webhookForm.reset({ + enableWebhook: webhookForm.getValues('enableWebhook'), + webhookUrl: data.webhookUrl ?? '', + }); + router.refresh(); + } catch { + toast('Failed to update webhook URL', { + description: 'URL must use HTTPS (localhost allowed for development)', + icon: , + }); + } + } + + async function handleTestWebhook() { + try { + await testWebhook({ formId }); + toast('Test webhook has been queued', { + description: 'Check your webhook endpoint for the delivery', + icon: , + }); + } catch { + toast('Failed to send test webhook', { + description: 'Make sure webhook is enabled and URL is configured', + icon: , + }); + } + } + + return ( +
+
+ ( + +
+ Enable Webhook + + Send an HTTP POST request on every form submission + +
+ + + +
+ )} + /> + + + {watchEnableWebhook && ( + <> +
+ + ( + + Webhook URL + +
+ + + Save + + + Test + +
+
+ + Must use HTTPS (localhost allowed for development) + +
+ )} + /> + + + + {webhookSecret && ( +
+ +
+ + {webhookSecret} + + +
+

+ Used to verify the X-Formbase-Signature header on each request +

+
+ )} + +
+ + {deliveries && deliveries.length > 0 ? ( +
+ {deliveries.map((delivery) => ( +
+
+ + {delivery.status} + + {delivery.statusCode !== null && ( + + HTTP {delivery.statusCode} + + )} + + {delivery.attempts} attempt + {delivery.attempts === 1 ? '' : 's'} + +
+ + {formatDistanceToNow(delivery.createdAt, { + addSuffix: true, + })} + +
+ ))} +
+ ) : ( +

No deliveries yet

+ )} +
+ + )} +
+ ); +}; diff --git a/apps/web/src/app/api/s/[id]/route.ts b/apps/web/src/app/api/s/[id]/route.ts index be897bd9..b9fdfdc0 100644 --- a/apps/web/src/app/api/s/[id]/route.ts +++ b/apps/web/src/app/api/s/[id]/route.ts @@ -1,3 +1,4 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; import { after, userAgent } from 'next/server'; import { type RouterOutputs } from '@formbase/api'; @@ -7,6 +8,7 @@ import { renderNewSubmissionEmail } from '~/lib/email/templates/new-submission'; import { checkForSpam, stripHoneypotField } from '~/lib/spam-detection'; import { api } from '~/lib/trpc/server'; import { assignFileOrImage, uploadFileFromBlob } from '~/lib/upload-file'; +import { enqueueWebhook } from '~/lib/webhooks/producer'; type FormDataResult = | { @@ -133,7 +135,7 @@ export async function POST( const formKeys = form.keys; const updatedKeys = [...new Set([...formKeys, ...formDataKeys])]; - await api.formData.setFormData({ + const { id: formDataId } = await api.formData.setFormData({ data: cleanedFormData, formId, keys: updatedKeys, @@ -148,6 +150,21 @@ export async function POST( }), ); } + + if (!spamResult.isSpam && form.enableWebhook && form.webhookUrl) { + const { env } = getCloudflareContext(); + const queue = env.WEBHOOK_QUEUE; + const webhookUrl = form.webhookUrl; + after(() => + enqueueWebhook(queue, { + formId, + formDataId, + webhookUrl, + }).catch((error: unknown) => { + console.error('Failed to enqueue webhook', error); + }), + ); + } const { browser } = userAgent(request); if (!browser.name) { diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index ace5e097..2fd4313a 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -2,12 +2,15 @@ import { type NextRequest } from 'next/server'; import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; + import { appRouter, createTRPCContext } from '@formbase/api'; import { env } from '@formbase/env'; const createContext = async (req: NextRequest) => { return createTRPCContext({ headers: req.headers, + webhookQueue: getCloudflareContext().env.WEBHOOK_QUEUE, }); }; diff --git a/apps/web/src/lib/trpc/server.ts b/apps/web/src/lib/trpc/server.ts index e1227680..72fb2d79 100644 --- a/apps/web/src/lib/trpc/server.ts +++ b/apps/web/src/lib/trpc/server.ts @@ -3,6 +3,8 @@ import 'server-only'; import { cache } from 'react'; import { headers } from 'next/headers'; +import { getCloudflareContext } from '@opennextjs/cloudflare'; + import { createCaller, createTRPCContext } from '@formbase/api'; const createContext = cache(async () => { @@ -11,6 +13,7 @@ const createContext = cache(async () => { return createTRPCContext({ headers: heads, + webhookQueue: getCloudflareContext().env.WEBHOOK_QUEUE, }); }); diff --git a/apps/web/src/lib/webhooks/consumer.ts b/apps/web/src/lib/webhooks/consumer.ts new file mode 100644 index 00000000..c4c435ed --- /dev/null +++ b/apps/web/src/lib/webhooks/consumer.ts @@ -0,0 +1,87 @@ +import type { + WebhookPayload, + WebhookQueueMessage, +} from '@formbase/api/lib/webhook'; + +import { db } from '@formbase/db'; +import { + getDeliveryLog, + getWebhookSecret, + markFailed, + markPending, + markSuccess, +} from '@formbase/api/lib/webhook'; + +import { deliverWebhook } from './deliver'; + +const BACKOFF_S = [60, 600, 3600, 21600, 43200]; + +type WebhookMessage = { + body: WebhookQueueMessage; + attempts: number; + ack(): void; + retry(opts?: { delaySeconds?: number }): void; +}; + +export async function handleWebhookBatch( + batch: { queue: string; messages: WebhookMessage[] }, + _env: unknown, + _ctx: unknown, +): Promise { + if (batch.queue.endsWith('-dlq')) { + for (const msg of batch.messages) { + try { + await markFailed(db, msg.body.deliveryLogId, { + error: 'Exhausted retries (moved to DLQ)', + }); + } catch (error) { + console.error('Failed to mark webhook delivery as failed', error); + } finally { + msg.ack(); + } + } + return; + } + + for (const msg of batch.messages) { + const { deliveryLogId } = msg.body; + try { + const log = await getDeliveryLog(db, deliveryLogId); + if (!log || log.status === 'success') { + msg.ack(); + continue; + } + + const secret = await getWebhookSecret(db, log.formId); + const result = await deliverWebhook({ + webhookUrl: log.webhookUrl, + payload: JSON.parse(log.payload) as WebhookPayload, + secret, + }); + const attempt = msg.attempts; + + if (result.success) { + await markSuccess(db, deliveryLogId, { + statusCode: result.statusCode ?? 0, + ...(result.body !== undefined && { body: result.body }), + }); + msg.ack(); + } else { + const delay = + BACKOFF_S[Math.min(attempt - 1, BACKOFF_S.length - 1)] ?? 60; + await markPending( + db, + deliveryLogId, + result, + new Date(Date.now() + delay * 1000), + ); + msg.retry({ delaySeconds: delay }); + } + } catch { + const attempt = msg.attempts; + const delay = + BACKOFF_S[Math.min(attempt - 1, BACKOFF_S.length - 1)] ?? 60; + msg.retry({ delaySeconds: delay }); + } + } +} diff --git a/apps/web/src/lib/webhooks/deliver.ts b/apps/web/src/lib/webhooks/deliver.ts new file mode 100644 index 00000000..bbd717b8 --- /dev/null +++ b/apps/web/src/lib/webhooks/deliver.ts @@ -0,0 +1,76 @@ +import type { WebhookPayload } from '@formbase/api/lib/webhook'; + +const WEBHOOK_TIMEOUT_MS = 10000; + +async function signBody(secret: string, message: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(message), + ); + return [...new Uint8Array(signature)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +export async function deliverWebhook({ + webhookUrl, + payload, + secret, +}: { + webhookUrl: string; + payload: WebhookPayload; + secret: string | null; +}): Promise<{ + success: boolean; + statusCode?: number; + body?: string; + error?: string; +}> { + const body = JSON.stringify(payload); + const timestamp = String(Date.now()); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Formbase-Event': payload.event, + 'X-Formbase-Timestamp': timestamp, + }; + + if (secret) { + headers['X-Formbase-Signature'] = + 'sha256=' + (await signBody(secret, timestamp + '.' + body)); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, WEBHOOK_TIMEOUT_MS); + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + const responseBody = await response.text(); + return { + success: response.ok, + statusCode: response.status, + body: responseBody, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/web/src/lib/webhooks/producer.ts b/apps/web/src/lib/webhooks/producer.ts new file mode 100644 index 00000000..7829fc1b --- /dev/null +++ b/apps/web/src/lib/webhooks/producer.ts @@ -0,0 +1,29 @@ +import type { WebhookQueue } from '@formbase/api/lib/webhook'; + +import { db } from '@formbase/db'; +import { + buildWebhookPayload, + createDeliveryLogRow, +} from '@formbase/api/lib/webhook'; + +export async function enqueueWebhook( + queue: WebhookQueue, + { + formId, + formDataId, + webhookUrl, + }: { formId: string; formDataId: string; webhookUrl: string }, +): Promise { + const payload = await buildWebhookPayload(db, formId, formDataId); + if (!payload) return null; + + const deliveryLogId = await createDeliveryLogRow(db, { + formId, + formDataId, + webhookUrl, + payload, + }); + + await queue.send({ deliveryLogId, webhookUrl }); + return deliveryLogId; +} diff --git a/apps/web/src/lib/webhooks/scheduled.ts b/apps/web/src/lib/webhooks/scheduled.ts new file mode 100644 index 00000000..0ad783b0 --- /dev/null +++ b/apps/web/src/lib/webhooks/scheduled.ts @@ -0,0 +1,36 @@ +import type { WebhookQueue } from '@formbase/api/lib/webhook'; + +import { db } from '@formbase/db'; +import { + cleanupOldWebhookLogs, + findStuckDeliveries, +} from '@formbase/api/lib/webhook'; + +async function sweepStuckWebhooks(queue: WebhookQueue): Promise { + const stuck = await findStuckDeliveries(db, { + olderThanMs: 120000, + leaseMs: 900000, + }); + if (stuck.length) { + await queue.sendBatch( + stuck.map((s) => ({ + body: { deliveryLogId: s.id, webhookUrl: s.webhookUrl }, + })), + ); + } +} + +export async function handleScheduled( + controller: { cron: string }, + env: { WEBHOOK_QUEUE: WebhookQueue }, + _ctx: unknown, +): Promise { + switch (controller.cron) { + case '*/5 * * * *': + await sweepStuckWebhooks(env.WEBHOOK_QUEUE); + break; + case '0 3 * * *': + await cleanupOldWebhookLogs(db); + break; + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 36bd5a1d..4038a4f0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,6 +1,7 @@ { "extends": ["@formbase/tsconfig/next.json"], "compilerOptions": { + "checkJs": false, "paths": { "~/*": ["./src/*"] } diff --git a/apps/web/worker.ts b/apps/web/worker.ts new file mode 100644 index 00000000..11a1e0d5 --- /dev/null +++ b/apps/web/worker.ts @@ -0,0 +1,32 @@ +// @ts-ignore generated at build time +import { default as handler } from './.open-next/worker.js'; +// @ts-ignore generated at build time +export { + DOQueueHandler, + DOShardedTagCache, + BucketCachePurge, +} from './.open-next/worker.js'; + +function hydrateProcessEnv(env: Record) { + for (const [k, v] of Object.entries(env)) { + if (typeof v === 'string') process.env[k] = v; + } +} + +export default { + fetch: handler.fetch, + async queue(batch: unknown, env: Record, ctx: unknown) { + hydrateProcessEnv(env); + const { handleWebhookBatch } = await import('./src/lib/webhooks/consumer'); + await handleWebhookBatch(batch as never, env, ctx); + }, + async scheduled( + controller: { cron: string }, + env: Record, + ctx: unknown, + ) { + hydrateProcessEnv(env); + const { handleScheduled } = await import('./src/lib/webhooks/scheduled'); + await handleScheduled(controller, env as never, ctx); + }, +}; diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index d0649ccd..28f9faab 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "formbase-web", - "main": ".open-next/worker.js", + "main": "./worker.ts", "workers_dev": true, "preview_urls": false, "compatibility_date": "2026-05-01", @@ -28,4 +28,32 @@ "observability": { "enabled": true, }, + "queues": { + "producers": [ + { + "queue": "formbase-webhooks", + "binding": "WEBHOOK_QUEUE", + }, + ], + "consumers": [ + { + "queue": "formbase-webhooks", + "max_batch_size": 10, + "max_batch_timeout": 5, + "max_retries": 5, + "max_concurrency": 5, + "dead_letter_queue": "formbase-webhooks-dlq", + "retry_delay": 60, + }, + { + "queue": "formbase-webhooks-dlq", + "max_batch_size": 10, + "max_batch_timeout": 30, + "max_retries": 1, + }, + ], + }, + "triggers": { + "crons": ["*/5 * * * *", "0 3 * * *"], + }, } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..57f75baf --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "hoisted" diff --git a/eslint.config.mjs b/eslint.config.mjs index 641b011a..38731e00 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,8 @@ export default [ '**/test-results/**', '**/playwright-report/**', 'apps/web/next-env.d.ts', + 'apps/web/cloudflare-env.d.ts', + 'apps/web/worker.ts', 'packages/core/.tsup/**', ], }, diff --git a/packages/api/lib/webhook.ts b/packages/api/lib/webhook.ts new file mode 100644 index 00000000..7191a3bd --- /dev/null +++ b/packages/api/lib/webhook.ts @@ -0,0 +1,276 @@ +import type { db as database } from '@formbase/db'; +import type { WebhookDeliveryLog } from '@formbase/db/schema'; + +import { drizzlePrimitives } from '@formbase/db'; +import { formDatas, forms, webhookDeliveryLogs } from '@formbase/db/schema'; +import { generateId } from '@formbase/utils/generate-id'; + +import { parseJsonObject } from '../utils/json'; + +type Database = typeof database; + +const { and, eq, isNull, lt, or, sql } = drizzlePrimitives; + +export const SUBMISSION_CREATED = 'submission.created'; + +export interface WebhookPayload { + event: 'submission.created'; + payload: { + id: string; + formId: string; + formTitle: string; + data: Record; + fileUrls: string[]; + isSpam: boolean; + spamReason: string | null; + createdAt: string; + }; +} + +export interface WebhookQueueMessage { + deliveryLogId: string; + webhookUrl: string; +} + +export interface WebhookQueue { + send(message: WebhookQueueMessage): Promise; + sendBatch(messages: Array<{ body: WebhookQueueMessage }>): Promise; +} + +function truncate(value: string | undefined): string | null { + if (value === undefined) return null; + return value.slice(0, 10000); +} + +export async function buildWebhookPayload( + db: Database, + formId: string, + formDataId: string, +): Promise { + const form = await db.query.forms.findFirst({ + where: eq(forms.id, formId), + columns: { id: true, title: true }, + }); + + if (!form) return null; + + const submission = await db.query.formDatas.findFirst({ + where: eq(formDatas.id, formDataId), + columns: { + id: true, + data: true, + isSpam: true, + spamReason: true, + createdAt: true, + }, + }); + + if (!submission) return null; + + const data = parseJsonObject(submission.data) ?? {}; + const fileUrls = Object.values(data).filter( + (value): value is string => + typeof value === 'string' && value.startsWith('http'), + ); + + return { + event: SUBMISSION_CREATED, + payload: { + id: submission.id, + formId: form.id, + formTitle: form.title, + data, + fileUrls, + isSpam: submission.isSpam, + spamReason: submission.spamReason, + createdAt: submission.createdAt.toISOString(), + }, + }; +} + +export function buildMockPayload(form: { + id: string; + title: string; +}): WebhookPayload { + return { + event: SUBMISSION_CREATED, + payload: { + id: `test_${generateId(10)}`, + formId: form.id, + formTitle: form.title, + data: { message: 'This is a test webhook from formbase' }, + fileUrls: [], + isSpam: false, + spamReason: null, + createdAt: new Date().toISOString(), + }, + }; +} + +export async function createDeliveryLogRow( + db: Database, + { + formId, + formDataId, + webhookUrl, + payload, + }: { + formId: string; + formDataId: string | null; + webhookUrl: string; + payload: WebhookPayload; + }, +): Promise { + const id = generateId(15); + await db.insert(webhookDeliveryLogs).values({ + id, + formId, + formDataId, + webhookUrl, + payload: JSON.stringify(payload), + status: 'pending', + attempts: 0, + }); + return id; +} + +export async function getDeliveryLog( + db: Database, + id: string, +): Promise { + return db.query.webhookDeliveryLogs.findFirst({ + where: eq(webhookDeliveryLogs.id, id), + }); +} + +export async function getWebhookSecret( + db: Database, + formId: string, +): Promise { + const form = await db.query.forms.findFirst({ + where: eq(forms.id, formId), + columns: { webhookSecret: true }, + }); + return form?.webhookSecret ?? null; +} + +export async function markSuccess( + db: Database, + id: string, + { statusCode, body }: { statusCode: number; body?: string }, +) { + await db + .update(webhookDeliveryLogs) + .set({ + status: 'success', + statusCode, + responseBody: truncate(body), + completedAt: new Date(), + attempts: sql`${webhookDeliveryLogs.attempts} + 1`, + }) + .where(eq(webhookDeliveryLogs.id, id)); +} + +export async function markPending( + db: Database, + id: string, + result: { statusCode?: number; body?: string; error?: string }, + nextRetryAt: Date, +) { + await db + .update(webhookDeliveryLogs) + .set({ + status: 'pending', + statusCode: result.statusCode ?? null, + responseBody: truncate(result.body), + errorMessage: result.error ?? null, + nextRetryAt, + attempts: sql`${webhookDeliveryLogs.attempts} + 1`, + }) + .where(eq(webhookDeliveryLogs.id, id)); +} + +export async function markFailed( + db: Database, + id: string, + result: { statusCode?: number; body?: string; error?: string }, +) { + await db + .update(webhookDeliveryLogs) + .set({ + status: 'failed', + statusCode: result.statusCode ?? null, + responseBody: truncate(result.body), + errorMessage: result.error ?? null, + completedAt: new Date(), + attempts: sql`${webhookDeliveryLogs.attempts} + 1`, + }) + .where(eq(webhookDeliveryLogs.id, id)); +} + +export async function findStuckDeliveries( + db: Database, + { olderThanMs, leaseMs }: { olderThanMs: number; leaseMs: number }, +): Promise> { + const now = new Date(); + const staleBefore = new Date(now.getTime() - leaseMs); + const nextRetryAt = new Date(now.getTime() + leaseMs); + return db + .update(webhookDeliveryLogs) + .set({ nextRetryAt }) + .where( + and( + eq(webhookDeliveryLogs.status, 'pending'), + lt(webhookDeliveryLogs.attempts, 5), + or( + isNull(webhookDeliveryLogs.nextRetryAt), + lt(webhookDeliveryLogs.nextRetryAt, staleBefore), + ), + lt( + webhookDeliveryLogs.createdAt, + new Date(now.getTime() - olderThanMs), + ), + ), + ) + .returning({ + id: webhookDeliveryLogs.id, + webhookUrl: webhookDeliveryLogs.webhookUrl, + }); +} + +export async function cleanupOldWebhookLogs(db: Database) { + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + await db + .delete(webhookDeliveryLogs) + .where( + and( + drizzlePrimitives.inArray(webhookDeliveryLogs.status, [ + 'success', + 'failed', + ]), + lt(webhookDeliveryLogs.createdAt, ninetyDaysAgo), + ), + ); +} + +export async function listWebhookDeliveries( + db: Database, + formId: string, + limit = 20, +) { + return db + .select({ + id: webhookDeliveryLogs.id, + status: webhookDeliveryLogs.status, + statusCode: webhookDeliveryLogs.statusCode, + attempts: webhookDeliveryLogs.attempts, + webhookUrl: webhookDeliveryLogs.webhookUrl, + errorMessage: webhookDeliveryLogs.errorMessage, + createdAt: webhookDeliveryLogs.createdAt, + completedAt: webhookDeliveryLogs.completedAt, + }) + .from(webhookDeliveryLogs) + .where(eq(webhookDeliveryLogs.formId, formId)) + .orderBy(sql`${webhookDeliveryLogs.createdAt} desc`) + .limit(limit); +} diff --git a/packages/api/routers/form.ts b/packages/api/routers/form.ts index d819382e..15e05e3d 100644 --- a/packages/api/routers/form.ts +++ b/packages/api/routers/form.ts @@ -1,8 +1,17 @@ +import { TRPCError } from "@trpc/server"; + import { drizzlePrimitives } from "@formbase/db"; import { formDatas, forms, onboardingForms } from "@formbase/db/schema"; import { generateId } from "@formbase/utils/generate-id"; +import { isValidWebhookUrl } from "@formbase/utils/webhook"; import { z } from "zod"; +import { + buildMockPayload, + buildWebhookPayload, + createDeliveryLogRow, + listWebhookDeliveries, +} from "../lib/webhook"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { parseJsonArray, serializeJson } from "../utils/json"; import { assertFormOwnership } from "./form-ownership"; @@ -127,11 +136,27 @@ export const formRouter = createTRPCRouter({ returnUrl: z.string().optional(), defaultSubmissionEmail: z.string().optional(), honeypotField: z.string().optional(), + enableWebhook: z.boolean().optional(), + webhookUrl: z + .string() + .url() + .refine(isValidWebhookUrl, { + message: + "Webhook URL must use HTTPS (localhost allowed only in development)", + }) + .optional() + .nullable(), }), ) .mutation(async ({ ctx, input }) => { const form = await assertFormOwnership(ctx, input.id); + const enablingWebhook = + input.enableWebhook === true && !form.webhookSecret; + const webhookSecret = enablingWebhook + ? (crypto.randomUUID() + crypto.randomUUID()).replace(/-/g, "") + : undefined; + await ctx.db .update(forms) .set({ @@ -145,6 +170,10 @@ export const formRouter = createTRPCRouter({ defaultSubmissionEmail: input.defaultSubmissionEmail ?? form.defaultSubmissionEmail, honeypotField: input.honeypotField ?? form.honeypotField, + enableWebhook: input.enableWebhook ?? form.enableWebhook, + webhookUrl: + input.webhookUrl !== undefined ? input.webhookUrl : form.webhookUrl, + ...(webhookSecret ? { webhookSecret } : {}), }) .where(eq(forms.id, input.id)); }), @@ -245,6 +274,23 @@ export const formRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const form = await ctx.db.query.forms.findFirst({ where: (table) => eq(table.id, input.formId), + columns: { + id: true, + userId: true, + title: true, + description: true, + createdAt: true, + updatedAt: true, + returnUrl: true, + enableEmailNotifications: true, + keys: true, + enableSubmissions: true, + enableRetention: true, + defaultSubmissionEmail: true, + honeypotField: true, + enableWebhook: true, + webhookUrl: true, + }, }); if (!form) return null; @@ -254,4 +300,56 @@ export const formRouter = createTRPCRouter({ keys: parseJsonArray(form.keys), }; }), + + testWebhook: protectedProcedure + .input(z.object({ formId: z.string() })) + .mutation(async ({ ctx, input }) => { + const form = await assertFormOwnership(ctx, input.formId); + + if (!form.enableWebhook || !form.webhookUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Webhook is not enabled or URL is not configured", + }); + } + + if (!ctx.webhookQueue) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Webhook queue unavailable", + }); + } + + const latest = await ctx.db.query.formDatas.findFirst({ + where: (table) => eq(table.formId, form.id), + orderBy: (table, { desc }) => desc(table.createdAt), + }); + + const payload = + (latest && (await buildWebhookPayload(ctx.db, form.id, latest.id))) ?? + buildMockPayload({ id: form.id, title: form.title }); + + const deliveryLogId = await createDeliveryLogRow(ctx.db, { + formId: form.id, + formDataId: latest?.id ?? null, + webhookUrl: form.webhookUrl, + payload, + }); + + await ctx.webhookQueue.send({ deliveryLogId, webhookUrl: form.webhookUrl }); + + return { deliveryLogId }; + }), + + listDeliveries: protectedProcedure + .input( + z.object({ + formId: z.string(), + limit: z.number().min(1).max(100).optional(), + }), + ) + .query(async ({ ctx, input }) => { + await assertFormOwnership(ctx, input.formId); + return listWebhookDeliveries(ctx.db, input.formId, input.limit ?? 20); + }), }); diff --git a/packages/api/routers/formData.ts b/packages/api/routers/formData.ts index 1c92da43..fea5e873 100644 --- a/packages/api/routers/formData.ts +++ b/packages/api/routers/formData.ts @@ -120,11 +120,13 @@ export const formDataRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const [formdata] = await ctx.db.batch([ + const id = generateId(15); + + await ctx.db.batch([ ctx.db.insert(formDatas).values({ data: serializeJson(input.data), formId: input.formId, - id: generateId(15), + id, createdAt: new Date(), isSpam: input.isSpam, spamReason: input.spamReason ?? null, @@ -138,7 +140,7 @@ export const formDataRouter = createTRPCRouter({ .where(eq(forms.id, input.formId)), ]); - return formdata; + return { id }; }), markAsSpam: protectedProcedure diff --git a/packages/api/trpc.ts b/packages/api/trpc.ts index feac7fa8..b14f8f7f 100644 --- a/packages/api/trpc.ts +++ b/packages/api/trpc.ts @@ -5,7 +5,12 @@ import { ZodError } from 'zod'; import { getSession } from '@formbase/auth/server'; import { db } from '@formbase/db'; -export const createTRPCContext = async (opts: { headers: Headers }) => { +import type { WebhookQueue } from './lib/webhook'; + +export const createTRPCContext = async (opts: { + headers: Headers; + webhookQueue?: WebhookQueue; +}) => { const session = await getSession(); const user = session?.user ?? null; @@ -13,6 +18,7 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { db, session, user, + webhookQueue: opts.webhookQueue, ...opts, }; }; diff --git a/packages/db/drizzle/0003_awesome_inhumans.sql b/packages/db/drizzle/0003_awesome_inhumans.sql new file mode 100644 index 00000000..f76a7746 --- /dev/null +++ b/packages/db/drizzle/0003_awesome_inhumans.sql @@ -0,0 +1,24 @@ +CREATE TABLE `webhook_delivery_logs` ( + `id` text PRIMARY KEY NOT NULL, + `form_id` text NOT NULL, + `form_data_id` text, + `webhook_url` text NOT NULL, + `payload` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `status_code` integer, + `response_body` text, + `error_message` text, + `attempts` integer DEFAULT 0 NOT NULL, + `next_retry_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + `completed_at` integer, + FOREIGN KEY (`form_id`) REFERENCES `forms`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`form_data_id`) REFERENCES `form_datas`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `webhook_log_form_idx` ON `webhook_delivery_logs` (`form_id`);--> statement-breakpoint +CREATE INDEX `webhook_log_status_idx` ON `webhook_delivery_logs` (`status`);--> statement-breakpoint +CREATE INDEX `webhook_log_next_retry_idx` ON `webhook_delivery_logs` (`next_retry_at`);--> statement-breakpoint +ALTER TABLE `forms` ADD `enable_webhook` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `forms` ADD `webhook_url` text;--> statement-breakpoint +ALTER TABLE `forms` ADD `webhook_secret` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..389b4515 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1081 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e5a2551b-fbc6-4b6b-8a86-1a8d86b5a3e3", + "prevId": "57d4c97e-b93c-4e5d-977d-b619497f66a1", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "account_provider_account_idx": { + "name": "account_provider_account_idx", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_audit_logs": { + "name": "api_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "audit_api_key_idx": { + "name": "audit_api_key_idx", + "columns": [ + "api_key_id" + ], + "isUnique": false + }, + "audit_user_idx": { + "name": "audit_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_created_at_idx": { + "name": "audit_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_audit_logs_api_key_id_api_keys_id_fk": { + "name": "api_audit_logs_api_key_id_api_keys_id_fk", + "tableFrom": "api_audit_logs", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_key_user_idx": { + "name": "api_key_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_key_hash_idx": { + "name": "api_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_user_id_fk": { + "name": "api_keys_user_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_datas": { + "name": "form_datas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_spam": { + "name": "is_spam", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "spam_reason": { + "name": "spam_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "form_idx": { + "name": "form_idx", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "form_data_created_at_idx": { + "name": "form_data_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "form_datas_form_id_forms_id_fk": { + "name": "form_datas_form_id_forms_id_fk", + "tableFrom": "form_datas", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forms": { + "name": "forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "send_email_for_new_submissions": { + "name": "send_email_for_new_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "keys": { + "name": "keys", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enable_submissions": { + "name": "enable_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_retention": { + "name": "enable_retention", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_submission_email": { + "name": "default_submission_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "honeypot_field": { + "name": "honeypot_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'_gotcha'" + }, + "enable_webhook": { + "name": "enable_webhook", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "form_user_idx": { + "name": "form_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "form_created_at_idx": { + "name": "form_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "forms_user_id_user_id_fk": { + "name": "forms_user_id_user_id_fk", + "tableFrom": "forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding_forms": { + "name": "onboarding_forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "onboarding_forms_user_id_user_id_fk": { + "name": "onboarding_forms_user_id_user_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_forms_form_id_forms_id_fk": { + "name": "onboarding_forms_form_id_forms_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_delivery_logs": { + "name": "webhook_delivery_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "form_data_id": { + "name": "form_data_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_log_form_idx": { + "name": "webhook_log_form_idx", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "webhook_log_status_idx": { + "name": "webhook_log_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "webhook_log_next_retry_idx": { + "name": "webhook_log_next_retry_idx", + "columns": [ + "next_retry_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhook_delivery_logs_form_id_forms_id_fk": { + "name": "webhook_delivery_logs_form_id_forms_id_fk", + "tableFrom": "webhook_delivery_logs", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_delivery_logs_form_data_id_form_datas_id_fk": { + "name": "webhook_delivery_logs_form_data_id_form_datas_id_fk", + "tableFrom": "webhook_delivery_logs", + "tableTo": "form_datas", + "columnsFrom": [ + "form_data_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 52f25755..00cdd534 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1768165812454, "tag": "0002_majestic_the_hood", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1780431327310, + "tag": "0003_awesome_inhumans", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 7ad88854..6311db92 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,7 +6,7 @@ "scripts": { "db:check": "dotenv -e ../../apps/web/.env.local drizzle-kit check", "db:generate": "dotenv -e ../../apps/web/.env.local drizzle-kit generate", - "db:migrate": "dotenv -e ../../apps/web/.env.local pnpm tsx migrate.ts", + "db:migrate": "dotenv -e ../../apps/web/.env.local tsx migrate.ts", "db:migrate:drop": "dotenv -e ../../apps/web/.env.local drizzle-kit drop", "db:pull": "dotenv -e ../../apps/web/.env.local drizzle-kit introspect", "db:push": "dotenv -e ../../apps/web/.env.local drizzle-kit push", diff --git a/packages/db/schema/forms.ts b/packages/db/schema/forms.ts index 3071b6a2..7baf1293 100644 --- a/packages/db/schema/forms.ts +++ b/packages/db/schema/forms.ts @@ -38,6 +38,13 @@ export const forms = sqliteTable( .notNull(), defaultSubmissionEmail: text('default_submission_email'), honeypotField: text('honeypot_field').default('_gotcha').notNull(), + enableWebhook: integer('enable_webhook', { + mode: 'boolean', + }) + .default(false) + .notNull(), + webhookUrl: text('webhook_url'), + webhookSecret: text('webhook_secret'), }, (t) => [ index('form_user_idx').on(t.userId), diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 1cf51693..72b9fa13 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -8,3 +8,4 @@ export * from './relations'; export * from './sessions'; export * from './users'; export * from './verification'; +export * from './webhook-delivery-logs'; diff --git a/packages/db/schema/relations.ts b/packages/db/schema/relations.ts index afe81b6d..c2044597 100644 --- a/packages/db/schema/relations.ts +++ b/packages/db/schema/relations.ts @@ -7,6 +7,7 @@ import { formDatas } from './form-data'; import { forms } from './forms'; import { sessions } from './sessions'; import { users } from './users'; +import { webhookDeliveryLogs } from './webhook-delivery-logs'; export const userRelations = relations(users, ({ many }) => ({ forms: many(forms), @@ -36,6 +37,7 @@ export const formRelations = relations(forms, ({ one, many }) => ({ references: [users.id], }), formData: many(formDatas), + webhookDeliveryLogs: many(webhookDeliveryLogs), })); export const formDataRelations = relations(formDatas, ({ one }) => ({ @@ -44,3 +46,17 @@ export const formDataRelations = relations(formDatas, ({ one }) => ({ references: [forms.id], }), })); + +export const webhookDeliveryLogRelations = relations( + webhookDeliveryLogs, + ({ one }) => ({ + form: one(forms, { + fields: [webhookDeliveryLogs.formId], + references: [forms.id], + }), + formData: one(formDatas, { + fields: [webhookDeliveryLogs.formDataId], + references: [formDatas.id], + }), + }), +); diff --git a/packages/db/schema/webhook-delivery-logs.ts b/packages/db/schema/webhook-delivery-logs.ts new file mode 100644 index 00000000..3e826aae --- /dev/null +++ b/packages/db/schema/webhook-delivery-logs.ts @@ -0,0 +1,39 @@ +import { sql } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +import { formDatas } from './form-data'; +import { forms } from './forms'; + +export const webhookDeliveryLogs = sqliteTable( + 'webhook_delivery_logs', + { + id: text('id').primaryKey(), + formId: text('form_id') + .references(() => forms.id, { onDelete: 'cascade' }) + .notNull(), + formDataId: text('form_data_id').references(() => formDatas.id, { + onDelete: 'set null', + }), + webhookUrl: text('webhook_url').notNull(), + payload: text('payload').notNull(), + status: text('status', { enum: ['pending', 'success', 'failed'] }) + .default('pending') + .notNull(), + statusCode: integer('status_code'), + responseBody: text('response_body'), + errorMessage: text('error_message'), + attempts: integer('attempts').default(0).notNull(), + nextRetryAt: integer('next_retry_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + completedAt: integer('completed_at', { mode: 'timestamp_ms' }), + }, + (t) => [ + index('webhook_log_form_idx').on(t.formId), + index('webhook_log_status_idx').on(t.status), + index('webhook_log_next_retry_idx').on(t.nextRetryAt), + ], +); + +export type WebhookDeliveryLog = typeof webhookDeliveryLogs.$inferSelect; diff --git a/packages/utils/package.json b/packages/utils/package.json index e66f9719..5aace83a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -8,7 +8,8 @@ "./server": "./server.ts", "./flatten-object": "./flatten-object.ts", "./generate-id": "./generate-id.ts", - "./url": "./url.ts" + "./url": "./url.ts", + "./webhook": "./webhook.ts" }, "scripts": { "lint": "eslint . --cache --max-warnings 0", diff --git a/packages/utils/webhook.ts b/packages/utils/webhook.ts new file mode 100644 index 00000000..14feee01 --- /dev/null +++ b/packages/utils/webhook.ts @@ -0,0 +1,62 @@ +export function isValidWebhookUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + const hostname = parsed.hostname.toLowerCase(); + const isProduction = process.env['NODE_ENV'] === 'production'; + + if (!isProduction) { + if (parsed.protocol === 'https:') return true; + if (parsed.protocol === 'http:') { + return hostname === 'localhost' || hostname === '127.0.0.1'; + } + return false; + } + + if (parsed.protocol !== 'https:') return false; + + const isIpv6Literal = hostname.startsWith('[') && hostname.endsWith(']'); + const host = isIpv6Literal ? hostname.slice(1, -1) : hostname; + + if (isIpv6Literal) { + if ( + host === '::1' || + host.startsWith('fc') || + host.startsWith('fd') || + host.startsWith('fe8') || + host.startsWith('fe9') || + host.startsWith('fea') || + host.startsWith('feb') + ) { + return false; + } + } + + if ( + host === 'localhost' || + host === '0.0.0.0' || + host.endsWith('.local') || + host.startsWith('127.') || + host.startsWith('10.') || + host.startsWith('192.168.') || + host.startsWith('169.254.') + ) { + return false; + } + + if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) { + return false; + } + + const match172 = /^172\.(\d{1,3})\./.exec(host); + if (match172) { + const secondOctet = Number(match172[1]); + if (secondOctet >= 16 && secondOctet <= 31) return false; + } + + return true; +}