Skip to content

Commit

Permalink
feat: add rate limiting and form validation for bookmark submissions
Browse files Browse the repository at this point in the history
  • Loading branch information
suyalcinkaya committed Feb 9, 2025
1 parent 25f8af4 commit 4979d9d
Show file tree
Hide file tree
Showing 16 changed files with 258 additions and 187 deletions.
Binary file modified bun.lockb
Binary file not shown.
15 changes: 10 additions & 5 deletions components.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "@/globals.css",
"config": "",
"css": "src/globals.css",
"baseColor": "gray",
"cssVariables": false
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
3 changes: 2 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ const nextConfig = {
},
eslint: {
ignoreDuringBuilds: true
}
},
transpilePackages: ['geist']
}

export default next({ rsc: true })(nextConfig)
51 changes: 27 additions & 24 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,62 +19,65 @@
"node": "20.x"
},
"dependencies": {
"@contentful/rich-text-react-renderer": "^15.18.0",
"@hookform/resolvers": "^3.3.4",
"@arcjet/ip": "^1.0.0-beta.2",
"@contentful/rich-text-react-renderer": "^16.0.1",
"@hookform/resolvers": "^3.10.0",
"@million/lint": "^1.0.14",
"@next/third-parties": "15.1.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@supabase/supabase-js": "^2.39.8",
"@supabase/supabase-js": "^2.48.1",
"@tailwindcss/container-queries": "^0.1.1",
"@vercel/speed-insights": "^1.0.10",
"@vercel/speed-insights": "^1.1.0",
"class-variance-authority": "^0.7.1",
"classix": "^2.2.1",
"embla-carousel-react": "^8.1.3",
"embla-carousel-react": "^8.5.2",
"feed": "^4.2.2",
"framer-motion": "^11.0.0",
"geist": "^1.2.2",
"framer-motion": "^12.4.1",
"geist": "^1.3.1",
"isbot": "^5.1.22",
"lru-cache": "^11.0.2",
"lucide-react": "^0.475.0",
"markdown-to-jsx": "^7.3.2",
"markdown-to-jsx": "^7.7.3",
"million": "^3.1.11",
"next": "15.1.6",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.51.0",
"react-intersection-observer": "^9.8.1",
"react-tweet": "^3.2.0",
"react-wrap-balancer": "^1.1.0",
"react-hook-form": "^7.54.2",
"react-intersection-observer": "^9.15.1",
"react-tweet": "^3.2.1",
"react-wrap-balancer": "^1.1.1",
"server-only": "^0.0.1",
"sonner": "^1.4.3",
"sugar-high": "^0.6.0",
"sonner": "^1.7.4",
"sugar-high": "^0.9.2",
"tailwind-merge": "^3.0.1",
"vaul": "^1.0.0",
"zod": "^3.22.4"
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/compat": "^1.2.6",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@tailwindcss/postcss": "^4.0.0",
"cssnano": "^6.1.1",
"cssnano-preset-advanced": "^6.1.1",
"eslint": "^8",
"@eslint/js": "^9.20.0",
"@tailwindcss/postcss": "^4.0.5",
"cssnano": "^7.0.6",
"cssnano-preset-advanced": "^7.0.6",
"eslint": "^9.20.0",
"eslint-config-next": "15.1.6",
"eslint-config-prettier": "^10.0.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"globals": "^15.14.0",
"next-unused": "^0.0.6",
"postcss": "^8.4.31",
"prettier": "3.4.2",
"postcss": "^8.5.1",
"prettier": "^3.5.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.0",
"tailwindcss": "^4.0.5",
"tailwindcss-animate": "^1.0.7"
},
"overrides": {
Expand Down
51 changes: 0 additions & 51 deletions src/app/actions.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,7 @@
'use server'

import { cookies } from 'next/headers'

import { BOOKMARK_SUBMISSION_COUNT_COOKIE_NAME, MAX_BOOKMARK_SUBMISSIONS_PER_DAY } from '@/lib/constants'
import { getBookmarkItems } from '@/lib/raindrop'

export async function submitBookmark(formData) {
const cookieStore = await cookies()

// Fake promise to simulate submitting the form
await new Promise((resolve) => setTimeout(resolve, 2000))

const formSubmissionCountCookie = cookieStore.get(BOOKMARK_SUBMISSION_COUNT_COOKIE_NAME)
if (formSubmissionCountCookie?.value >= MAX_BOOKMARK_SUBMISSIONS_PER_DAY) {
throw new Error('You have reached the maximum number of submissions for today.')
}

try {
const response = await fetch(
`https://api.airtable.com/v0/${process.env.AIRTABLE_BASE_ID}/${process.env.AIRTABLE_BOOKMARKS_TABLE_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN}`
},
body: JSON.stringify({
fields: {
URL: formData.url,
Email: formData.email,
Date: new Date().toISOString(),
Type: formData.type || 'Other'
}
}),
signal: AbortSignal.timeout(5000)
}
)

cookieStore.set(
formSubmissionCountCookie?.name ?? BOOKMARK_SUBMISSION_COUNT_COOKIE_NAME, // Name
Number(formSubmissionCountCookie?.value ?? 0) + 1, // Value
{
maxAge: 60 * 60 * 24 // 24 hours
}
)

const data = await response.json()
return data
} catch (error) {
console.info(error)
throw new Error('Failed to submit bookmark')
}
}

export async function getBookmarkItemsByPageIndex(id, pageIndex) {
return await getBookmarkItems(id, pageIndex)
}
66 changes: 66 additions & 0 deletions src/app/api/submit-bookmark/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import ip from '@arcjet/ip'
import { isbot } from 'isbot'
import { NextResponse } from 'next/server'

import { formSchema } from '@/components/submit-bookmark/utils'
import rateLimit from '@/lib/rate-limit'

const limiter = rateLimit({
interval: 600 * 1000, // 10 minutes (600 seconds * 1000 ms)
uniqueTokenPerInterval: 500 // Max 500 IPs
})

export async function POST(req) {
const json = await req.json()
const data = await formSchema.safeParse(json)
if (!data.success) {
const { error } = data
return NextResponse.json({ error }, { status: 400 })
}

if (isbot(req.headers.get('User-Agent'))) {
return NextResponse.json({ error: 'Bots are not allowed.' }, { status: 403 })
}

// Use the @arcjet/ip package to get the client's IP address. This looks at
// the headers set by different hosting platforms to try and get the real IP
// address before falling back to the request's remote address. This is
// necessary because the IP headers could be spoofed. In non-production
// environments we allow private/internal IPs.
const clientIp = ip(req, req.headers)

try {
await limiter.check(5, clientIp) // Limit to 5 requests
} catch {
return NextResponse.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429 })
}

try {
const { url, email, type } = data.data

const response = await fetch(
`https://api.airtable.com/v0/${process.env.AIRTABLE_BASE_ID}/${process.env.AIRTABLE_BOOKMARKS_TABLE_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN}`
},
body: JSON.stringify({
fields: {
URL: url,
Email: email,
Date: new Date().toISOString(),
Type: type || 'Other'
}
})
}
)

const res = await response.json()
return NextResponse.json({ res })
} catch (error) {
console.info(error)
return NextResponse.json({ error: 'Error submitting bookmark.' }, { status: 500 })
}
}
1 change: 0 additions & 1 deletion src/app/bookmarks/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export default async function BookmarksLayout({ children }) {
</div>
<Toaster
closeButton
richColors
toastOptions={{
duration: 5000
}}
Expand Down
7 changes: 6 additions & 1 deletion src/app/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export default async function RootLayout({ children }) {
preloadGetAllPosts(isEnabled)

return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
<html
lang="en"
data-theme="light"
className={`${GeistSans.variable} ${GeistMono.variable}`}
suppressHydrationWarning
>
<body suppressHydrationWarning>
{/* eslint-disable-next-line react/no-unknown-property */}
<main vaul-drawer-wrapper="" className="min-h-screen bg-white">
Expand Down
2 changes: 1 addition & 1 deletion src/components/contentful/rich-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function options(links) {
},
// Must be a <div> instead of <p> to avoid descendant issue, hence to avoid mismatching UI between server and client on hydration.
[BLOCKS.PARAGRAPH]: (_, children) => (
<div className="leading-slacker mb-4 last:mb-0 [&:has(+ul)]:mb-1">{children}</div>
<div className="mb-4 leading-[1.75] last:mb-0 [&:has(+ul)]:mb-1">{children}</div>
),
[BLOCKS.UL_LIST]: (_, children) => <ul className="mb-4 flex list-disc flex-col gap-0.5 pl-6">{children}</ul>,
[BLOCKS.OL_LIST]: (_, children) => (
Expand Down
41 changes: 18 additions & 23 deletions src/components/submit-bookmark/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,53 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { AnimatePresence, motion } from 'framer-motion'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'

import { submitBookmark } from '@/app/actions'
import { formSchema } from '@/components/submit-bookmark/utils'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'

const formSchema = z.object({
url: z.string().url({
message: 'Invalid URL.'
}),
email: z.string().email({
message: 'Invalid email address.'
}),
type: z.string().optional()
})

export function SubmitBookmarkForm({ className, setFormOpen, bookmarks, currentBookmark }) {
const form = useForm({
resolver: zodResolver(formSchema),
// mode: 'onChange',
mode: 'onChange',
defaultValues: {
url: '',
email: '',
type: currentBookmark?.title ?? ''
}
})
const {
formState: { isSubmitting, errors },
setError
formState: { isSubmitting, errors }
} = form
const hasErrors = Object.keys(errors).length > 0

async function onSubmit(values) {
try {
await submitBookmark(values)
const response = await fetch('/api/submit-bookmark', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...values })
})

toast('Bookmark submitted!', {
type: 'success',
if (!response.ok) {
throw new Error('Error submitting bookmark.')
}

form.reset()
toast('Bookmark submitted', {
description: (
<span>
<span className="underline underline-offset-4">{values.url}</span> has been submitted. Thank you!
<span className="underline underline-offset-4">{values.url}</span> has been submitted. Thank you for your
contribution!
</span>
)
})
} catch (error) {
setError('api.limitError', {
type: 'manual',
message: error.message
})
toast.error(error.message)
} finally {
setFormOpen(false)
Expand Down
11 changes: 11 additions & 0 deletions src/components/submit-bookmark/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod'

export const formSchema = z.object({
url: z.string().url({
message: 'Invalid URL.'
}),
email: z.string().email({
message: 'Invalid email address.'
}),
type: z.string().optional().or(z.literal(''))
})
Loading

0 comments on commit 4979d9d

Please sign in to comment.