rate limits for demo (#695)
This commit is contained in:
@@ -1,7 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import * as serverless from "@/lib/agent-browser";
|
||||
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 = {
|
||||
serverless: {
|
||||
@@ -52,6 +76,15 @@ export async function takeScreenshot(
|
||||
url: string,
|
||||
mode: Mode = "serverless",
|
||||
): Promise<ScreenshotResult> {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return { ok: false, error: "URL not allowed" };
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit();
|
||||
if (rateLimitError) {
|
||||
return { ok: false, error: rateLimitError };
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === "sandbox") {
|
||||
const { screenshot, title } = await sandbox.screenshotUrl(url);
|
||||
@@ -78,6 +111,15 @@ export async function takeSnapshot(
|
||||
url: string,
|
||||
mode: Mode = "serverless",
|
||||
): Promise<SnapshotResult> {
|
||||
if (!isAllowedUrl(url)) {
|
||||
return { ok: false, error: "URL not allowed" };
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit();
|
||||
if (rateLimitError) {
|
||||
return { ok: false, error: rateLimitError };
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === "sandbox") {
|
||||
const { snapshot, title } = await sandbox.snapshotUrl(url, {
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) {
|
||||
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 url = body.url;
|
||||
|
||||
@@ -19,6 +31,10 @@ export async function POST(req: NextRequest) {
|
||||
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") {
|
||||
const result = await ab.screenshotUrl(url, {
|
||||
fullPage: body.fullPage,
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ALLOWED_URLS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -179,7 +179,7 @@ function ModeCard({
|
||||
|
||||
export default function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
const [url, setUrl] = useState("https://example.com");
|
||||
const [url, setUrl] = useState<string>(ALLOWED_URLS[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<Action>("screenshot");
|
||||
const [mode, setMode] = useState<Mode>("serverless");
|
||||
@@ -238,22 +238,26 @@ export default function Home() {
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="url-input"
|
||||
htmlFor="url-select"
|
||||
className="text-[11px] text-muted-foreground uppercase tracking-wider"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url-input"
|
||||
type="url"
|
||||
<select
|
||||
id="url-select"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
clearResults();
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
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"
|
||||
>
|
||||
{ALLOWED_URLS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u.replace("https://", "")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
Reference in New Issue
Block a user