rate limits for demo (#695)
This commit is contained in:
@@ -7,3 +7,12 @@
|
|||||||
# Snapshot ID with agent-browser + Chromium pre-installed (optional, speeds up startup)
|
# Snapshot ID with agent-browser + Chromium pre-installed (optional, speeds up startup)
|
||||||
# Create one with: npx tsx scripts/create-snapshot.ts
|
# Create one with: npx tsx scripts/create-snapshot.ts
|
||||||
# AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
# AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||||
|
|
||||||
|
# --- Rate Limiting (Upstash / Vercel KV) ---
|
||||||
|
# Automatically populated when you add Vercel KV to your project
|
||||||
|
KV_REST_API_URL=
|
||||||
|
KV_REST_API_TOKEN=
|
||||||
|
|
||||||
|
# Optional: override default limits
|
||||||
|
# RATE_LIMIT_PER_MINUTE=10
|
||||||
|
# RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
import * as serverless from "@/lib/agent-browser";
|
import * as serverless from "@/lib/agent-browser";
|
||||||
import * as sandbox from "@/lib/agent-browser-sandbox";
|
import * as sandbox from "@/lib/agent-browser-sandbox";
|
||||||
|
import { ALLOWED_URLS } from "@/lib/constants";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
async function checkRateLimit(): Promise<string | null> {
|
||||||
|
const h = await headers();
|
||||||
|
const ip = h.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const minute = await minuteRateLimit.limit(ip);
|
||||||
|
if (!minute.success) {
|
||||||
|
return "Too many requests. Please wait a moment before trying again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = await dailyRateLimit.limit(ip);
|
||||||
|
if (!daily.success) {
|
||||||
|
return "Daily limit reached. Please try again tomorrow.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedUrl(url: string): boolean {
|
||||||
|
return (ALLOWED_URLS as readonly string[]).includes(url);
|
||||||
|
}
|
||||||
|
|
||||||
export type EnvStatus = {
|
export type EnvStatus = {
|
||||||
serverless: {
|
serverless: {
|
||||||
@@ -52,6 +76,15 @@ export async function takeScreenshot(
|
|||||||
url: string,
|
url: string,
|
||||||
mode: Mode = "serverless",
|
mode: Mode = "serverless",
|
||||||
): Promise<ScreenshotResult> {
|
): Promise<ScreenshotResult> {
|
||||||
|
if (!isAllowedUrl(url)) {
|
||||||
|
return { ok: false, error: "URL not allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitError = await checkRateLimit();
|
||||||
|
if (rateLimitError) {
|
||||||
|
return { ok: false, error: rateLimitError };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "sandbox") {
|
if (mode === "sandbox") {
|
||||||
const { screenshot, title } = await sandbox.screenshotUrl(url);
|
const { screenshot, title } = await sandbox.screenshotUrl(url);
|
||||||
@@ -78,6 +111,15 @@ export async function takeSnapshot(
|
|||||||
url: string,
|
url: string,
|
||||||
mode: Mode = "serverless",
|
mode: Mode = "serverless",
|
||||||
): Promise<SnapshotResult> {
|
): Promise<SnapshotResult> {
|
||||||
|
if (!isAllowedUrl(url)) {
|
||||||
|
return { ok: false, error: "URL not allowed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitError = await checkRateLimit();
|
||||||
|
if (rateLimitError) {
|
||||||
|
return { ok: false, error: rateLimitError };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "sandbox") {
|
if (mode === "sandbox") {
|
||||||
const { snapshot, title } = await sandbox.snapshotUrl(url, {
|
const { snapshot, title } = await sandbox.snapshotUrl(url, {
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import * as ab from "@/lib/agent-browser";
|
import * as ab from "@/lib/agent-browser";
|
||||||
|
import { ALLOWED_URLS } from "@/lib/constants";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/browse
|
|
||||||
*
|
|
||||||
* Programmatic API route for browser automation.
|
|
||||||
* Uses @sparticuz/chromium + puppeteer-core directly in the function.
|
|
||||||
*
|
|
||||||
* Body: { "action": "screenshot", "url": "https://example.com" }
|
|
||||||
* Or: { "action": "snapshot", "url": "https://example.com" }
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip =
|
||||||
|
req.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
const minute = await minuteRateLimit.limit(ip);
|
||||||
|
if (!minute.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests. Please wait a moment before trying again." },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = await dailyRateLimit.limit(ip);
|
||||||
|
if (!daily.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Daily limit reached. Please try again tomorrow." },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const url = body.url;
|
const url = body.url;
|
||||||
|
|
||||||
@@ -19,6 +31,10 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(ALLOWED_URLS as readonly string[]).includes(url)) {
|
||||||
|
return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
if (body.action === "screenshot") {
|
if (body.action === "screenshot") {
|
||||||
const result = await ab.screenshotUrl(url, {
|
const result = await ab.screenshotUrl(url, {
|
||||||
fullPage: body.fullPage,
|
fullPage: body.fullPage,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
|
import { ALLOWED_URLS } from "@/lib/constants";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||||
@@ -179,7 +179,7 @@ function ModeCard({
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [url, setUrl] = useState("https://example.com");
|
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [action, setAction] = useState<Action>("screenshot");
|
const [action, setAction] = useState<Action>("screenshot");
|
||||||
const [mode, setMode] = useState<Mode>("serverless");
|
const [mode, setMode] = useState<Mode>("serverless");
|
||||||
@@ -238,22 +238,26 @@ export default function Home() {
|
|||||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="url-input"
|
htmlFor="url-select"
|
||||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
URL
|
URL
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<select
|
||||||
id="url-input"
|
id="url-select"
|
||||||
type="url"
|
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setUrl(e.target.value);
|
setUrl(e.target.value);
|
||||||
clearResults();
|
clearResults();
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com"
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
required
|
>
|
||||||
/>
|
{ALLOWED_URLS.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{u.replace("https://", "")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const ALLOWED_URLS = [
|
||||||
|
"https://example.com",
|
||||||
|
"https://ai-sdk.dev",
|
||||||
|
"https://useworkflow.dev",
|
||||||
|
"https://vercel.com",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AllowedUrl = (typeof ALLOWED_URLS)[number];
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Ratelimit } from "@upstash/ratelimit";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
let _minuteRateLimit: Ratelimit | null = null;
|
||||||
|
let _dailyRateLimit: Ratelimit | null = null;
|
||||||
|
|
||||||
|
function getRedis(): Redis | null {
|
||||||
|
const url = process.env.KV_REST_API_URL;
|
||||||
|
const token = process.env.KV_REST_API_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Redis({ url, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopRateLimiter = {
|
||||||
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
export const minuteRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_minuteRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_minuteRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
|
prefix: "ratelimit:minute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _minuteRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dailyRateLimit = {
|
||||||
|
limit: async (identifier: string) => {
|
||||||
|
if (!_dailyRateLimit) {
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return noopRateLimiter.limit();
|
||||||
|
_dailyRateLimit = new Ratelimit({
|
||||||
|
redis,
|
||||||
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
|
prefix: "ratelimit:daily",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _dailyRateLimit.limit(identifier);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@sparticuz/chromium": "^143.0.4",
|
"@sparticuz/chromium": "^143.0.4",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.36.4",
|
||||||
"@vercel/sandbox": "^1.0.0",
|
"@vercel/sandbox": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+36
@@ -17,6 +17,12 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
'@upstash/ratelimit':
|
||||||
|
specifier: ^2.0.8
|
||||||
|
version: 2.0.8(@upstash/redis@1.36.4)
|
||||||
|
'@upstash/redis':
|
||||||
|
specifier: ^1.36.4
|
||||||
|
version: 1.36.4
|
||||||
'@vercel/sandbox':
|
'@vercel/sandbox':
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.8.0
|
version: 1.8.0
|
||||||
@@ -693,6 +699,18 @@ packages:
|
|||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
|
'@upstash/core-analytics@0.0.10':
|
||||||
|
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
'@upstash/ratelimit@2.0.8':
|
||||||
|
resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@upstash/redis': ^1.34.3
|
||||||
|
|
||||||
|
'@upstash/redis@1.36.4':
|
||||||
|
resolution: {integrity: sha512-w4s/msmyMqxOxaVhC8TQ2whJ77+Zd8YaSFokXL4mULQopaYb4xNJcm/PedtFQyLJn65nneySw9IwYnlMBBmFHg==}
|
||||||
|
|
||||||
'@vercel/oidc@3.2.0':
|
'@vercel/oidc@3.2.0':
|
||||||
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -2122,6 +2140,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uncrypto@0.1.3:
|
||||||
|
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -2878,6 +2899,19 @@ snapshots:
|
|||||||
'@types/node': 22.19.15
|
'@types/node': 22.19.15
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@upstash/core-analytics@0.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@upstash/redis': 1.36.4
|
||||||
|
|
||||||
|
'@upstash/ratelimit@2.0.8(@upstash/redis@1.36.4)':
|
||||||
|
dependencies:
|
||||||
|
'@upstash/core-analytics': 0.0.10
|
||||||
|
'@upstash/redis': 1.36.4
|
||||||
|
|
||||||
|
'@upstash/redis@1.36.4':
|
||||||
|
dependencies:
|
||||||
|
uncrypto: 0.1.3
|
||||||
|
|
||||||
'@vercel/oidc@3.2.0': {}
|
'@vercel/oidc@3.2.0': {}
|
||||||
|
|
||||||
'@vercel/sandbox@1.8.0':
|
'@vercel/sandbox@1.8.0':
|
||||||
@@ -4310,6 +4344,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
uncrypto@0.1.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
undici@7.22.0: {}
|
undici@7.22.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user