Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions apps/web/src/app/(main)/form/[id]/export-submissions-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,50 @@ type ExportSubmissionsDropDownButtonProps = {
submissions: FormData[];
formKeys: string[];
formTitle: string;
honeypotField: string;
};

const createCSVContent = (
submissions: FormData[],
formKeys: string[],
honeypotField: string,
): string => {
const filteredKeys = formKeys.filter((key) => key !== honeypotField);
const allKeys = [...filteredKeys, 'isSpam'];

let csvContent = 'data:text/csv;charset=utf-8,';
const header = formKeys.join(',') + '\n';
const header = allKeys.join(',') + '\n';
csvContent += header;

submissions.forEach((submission) => {
if (submission.data && typeof submission.data === 'object') {
const row = formKeys
.map((key) => (submission.data as Record<string, unknown>)[key] ?? '')
.join(',');
const dataValues = filteredKeys.map(
(key) => (submission.data as Record<string, unknown>)[key] ?? '',
);
const row = [...dataValues, submission.isSpam].join(',');
csvContent += row + '\n';
}
});

return csvContent;
};

const createJSONContent = (submissions: FormData[]): string => {
const createJSONContent = (
submissions: FormData[],
honeypotField: string,
): string => {
const jsonContent = JSON.stringify(
submissions.map((submission) =>
submission.data && typeof submission.data === 'object'
? submission.data
: {},
),
submissions.map((submission) => {
const data =
submission.data && typeof submission.data === 'object'
? { ...(submission.data as Record<string, unknown>) }
: {};
delete data[honeypotField];
return {
...data,
isSpam: submission.isSpam,
};
}),
null,
2,
);
Expand All @@ -70,17 +85,18 @@ export function ExportSubmissionsDropDownButton({
submissions,
formKeys,
formTitle,
honeypotField,
}: ExportSubmissionsDropDownButtonProps) {
const handleDownloadAsCSV = () => {
const csvContent = createCSVContent(submissions, formKeys);
const csvContent = createCSVContent(submissions, formKeys, honeypotField);
triggerDownload(csvContent, `${formTitle}_submissions.csv`);
toast('Submissions exported as CSV', {
icon: <FileDown className="h-4 w-4" />,
});
};

const handleDownloadAsJSON = () => {
const jsonContent = createJSONContent(submissions);
const jsonContent = createJSONContent(submissions, honeypotField);
triggerDownload(jsonContent, `${formTitle}_submissions.json`);
toast('Submissions exported as JSON', {
icon: <FileDown className="h-4 w-4" />,
Expand Down
87 changes: 86 additions & 1 deletion apps/web/src/app/(main)/form/[id]/form-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useRouter } from 'next/navigation';

import { zodResolver } from '@hookform/resolvers/zod';
import { BellRing, ExternalLink, FolderPen, FolderX } from 'lucide-react';
import { BellRing, ExternalLink, FolderPen, FolderX, Shield } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
Expand Down Expand Up @@ -52,6 +52,10 @@ const defaultSubmissionEmailSchema = z.object({
defaultSubmissionEmail: z.string().email().optional(),
});

const honeypotFieldSchema = z.object({
honeypotField: z.string().min(1).optional(),
});

type FormNameSchema = z.infer<typeof formNameSchema>;
type EnableFormSubmissionsSchema = z.infer<typeof enableFormSubmissionsSchema>;
type _EnableSubmissionsRetentionSchema = z.infer<typeof enableRetentionSchema>;
Expand Down Expand Up @@ -103,6 +107,11 @@ export function FormSettings({ form, user }: FormSettingsProps) {
/>
)}

<HoneypotFieldSetting
formId={form.id}
honeypotField={form.honeypotField}
/>

<div className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">Delete Form</Label>
Expand Down Expand Up @@ -509,3 +518,79 @@ const EnableFormNotifications = ({
</Form>
);
};

type HoneypotFieldSchema = z.infer<typeof honeypotFieldSchema>;

const HoneypotFieldSetting = ({
formId,
honeypotField,
}: {
formId: string;
honeypotField: string;
}) => {
const router = useRouter();

const honeypotFieldForm = useForm<HoneypotFieldSchema>({
resolver: zodResolver(honeypotFieldSchema),
defaultValues: {
honeypotField,
},
});

const { mutateAsync: updateHoneypotField, isPending: isUpdating } =
api.form.update.useMutation();

async function handleHoneypotFieldSubmit(data: HoneypotFieldSchema) {
try {
await updateHoneypotField({
id: formId,
honeypotField: data.honeypotField,
});

toast('Honeypot field name has been updated', {
icon: <Shield className="h-4 w-4" />,
});

router.refresh();
} catch {
toast('Failed to update honeypot field', {
description: 'Please try again later',
icon: <FolderX className="h-4 w-4" />,
});
}
}

return (
<Form {...honeypotFieldForm}>
<form onSubmit={honeypotFieldForm.handleSubmit(handleHoneypotFieldSubmit)}>
<FormField
control={honeypotFieldForm.control}
name="honeypotField"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Honeypot Field Name</FormLabel>
<FormDescription>
Hidden field used for spam detection. Bots fill this field,
humans don&apos;t.
</FormDescription>
</div>
<FormControl>
<div className="flex gap-2">
<Input className="w-[250px]" {...field} min={1} />
<LoadingButton
loading={isUpdating}
type="submit"
variant="default"
>
Save
</LoadingButton>
</div>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
};
1 change: 1 addition & 0 deletions apps/web/src/app/(main)/form/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default async function FormPage({
submissions={formSubmissions as FormData[]}
formKeys={form?.keys ?? []}
formTitle={form?.title ?? ''}
honeypotField={form?.honeypotField ?? '_gotcha'}
/>
</div>
<SubmissionsTable
Expand Down
80 changes: 79 additions & 1 deletion apps/web/src/app/(main)/form/[id]/submissions-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Trash2, TrashIcon } from 'lucide-react';
import { AlertTriangle, CheckCircle, Trash2, TrashIcon } from 'lucide-react';
import { toast } from 'sonner';

import { type FormData } from '@formbase/db/schema';
import { Badge } from '@formbase/ui/primitives/badge';
import { Button } from '@formbase/ui/primitives/button';
import { Checkbox } from '@formbase/ui/primitives/checkbox';
import {
Expand Down Expand Up @@ -69,6 +70,8 @@ export function SubmissionsTable({
const { mutateAsync: deleteFormSubmission } =
api.formData.delete.useMutation();

const { mutateAsync: markAsSpam } = api.formData.markAsSpam.useMutation();

const handleFormSubmissionDelete = async ({
submissionId,
}: {
Expand All @@ -90,6 +93,36 @@ export function SubmissionsTable({
);
};

const handleToggleSpam = async ({
submissionId,
isSpam,
}: {
submissionId: string;
isSpam: boolean;
}) => {
await markAsSpam(
{
id: submissionId,
isSpam,
},
{
onSuccess: () => {
router.refresh();
toast.success(
isSpam ? 'Marked as spam' : 'Marked as not spam',
{
icon: isSpam ? (
<AlertTriangle className="h-4 w-4" />
) : (
<CheckCircle className="h-4 w-4" />
),
},
);
},
},
);
};

const columns: Array<ColumnDef<FormData>> = [
{
id: 'select',
Expand Down Expand Up @@ -227,12 +260,38 @@ export function SubmissionsTable({
},
},

{
accessorKey: 'isSpam',
header: () => {
return (
<Button
variant="ghost"
className="px-0 py-0 hover:bg-transparent"
>
Status
</Button>
);
},
cell: ({ row }: { row: Row<FormData> }) => {
const isSpam = row.original.isSpam;
if (!isSpam) {
return null;
}
return (
<Badge variant="destructive" className="text-xs">
Spam
</Badge>
);
},
},

{
id: 'actions',
enableHiding: false,
size: 20,
cell: ({ row }: { row: Row<FormData> }) => {
const submissionId = row.original.id;
const isSpam = row.original.isSpam;

if (!submissionId) {
return null;
Expand All @@ -247,6 +306,25 @@ export function SubmissionsTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-4 p-0">
<DropdownMenuItem
onClick={() =>
handleToggleSpam({ submissionId, isSpam: !isSpam })
}
>
<span className="flex items-center gap-2 p-1 py-0.5">
{isSpam ? (
<>
<CheckCircle className="size-4" />
Mark as not spam
</>
) : (
<>
<AlertTriangle className="size-4" />
Mark as spam
</>
)}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="focus:bg-destructive/5 focus:text-destructive-foreground"
onClick={() => handleFormSubmissionDelete({ submissionId })}
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/app/api/s/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { userAgent } from "next/server";

import { sendMail } from "~/lib/email/mailer";
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 { type RouterOutputs } from "@formbase/api";
Expand Down Expand Up @@ -120,17 +121,25 @@ export async function POST(
await processFileUploads(formData, formDataResult.rawFormData);
}

const formDataKeys = Object.keys(formData);
const honeypotField = form.honeypotField;
const spamResult = checkForSpam(formData as Record<string, unknown>, honeypotField);
const cleanedFormData = stripHoneypotField(formData as Record<string, unknown>, honeypotField);

const formDataKeys = Object.keys(cleanedFormData);
const formKeys = form.keys;
const updatedKeys = [...new Set([...formKeys, ...formDataKeys])];

await api.formData.setFormData({
data: formData as Json,
data: cleanedFormData as Json,
formId,
keys: updatedKeys,
isSpam: spamResult.isSpam,
spamReason: spamResult.spamReason,
});

void handleEmailNotifications(form, formData as Record<string, unknown>);
if (!spamResult.isSpam) {
void handleEmailNotifications(form, cleanedFormData);
}
const { browser } = userAgent(request);

if (!browser.name) {
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/lib/spam-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import domains from 'disposable-email-domains';

const disposableDomains = new Set(domains);

export type SpamCheckResult = {
isSpam: boolean;
spamReason: string | null;
};

export function checkForSpam(
formData: Record<string, unknown>,
honeypotField: string,
): SpamCheckResult {
if (honeypotField in formData && formData[honeypotField]) {
return { isSpam: true, spamReason: 'honeypot' };
}

const emailValue = formData['email'];
if (typeof emailValue === 'string' && emailValue.includes('@')) {
const domain = emailValue.split('@')[1]?.toLowerCase();
if (domain && disposableDomains.has(domain)) {
return { isSpam: true, spamReason: 'disposable_email' };
}
}

return { isSpam: false, spamReason: null };
}

export function stripHoneypotField(
formData: Record<string, unknown>,
honeypotField: string,
): Record<string, unknown> {
const { [honeypotField]: _, ...rest } = formData;
return rest;
}
Loading
Loading