Add free tier rate limiting with Upstash Redis and API key handling

This commit is contained in:
Riccardo Giorato
2025-12-25 16:04:00 +01:00
parent d5b163e586
commit 79bd750630
14 changed files with 571 additions and 74 deletions
+3
View File
@@ -9,3 +9,6 @@ DATABASE_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
+37 -4
View File
@@ -7,6 +7,7 @@ import {
createPage,
getNextPageNumber,
} from "@/lib/db-actions";
import { freeTierRateLimit } from "@/lib/rate-limit";
const NEW_MODEL = false;
const IMAGE_MODEL = NEW_MODEL
@@ -122,15 +123,47 @@ export async function POST(request: NextRequest) {
previousContext = "",
} = await request.json();
console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId });
console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId, hasApiKey: !!apiKey });
if (!prompt || !apiKey) {
if (!prompt) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Determine which API key to use
let finalApiKey = apiKey;
const isUsingFreeTier = !apiKey;
if (isUsingFreeTier) {
// Using free tier - apply rate limiting
const { success, reset } = await freeTierRateLimit.limit(userId);
if (!success) {
const resetDate = new Date(reset);
const timeUntilReset = Math.ceil((reset - Date.now()) / (1000 * 60 * 60 * 24)); // days
return NextResponse.json(
{
error: `Free tier limit reached. You can generate 1 comic per week. Try again in ${timeUntilReset} day(s), or provide your own API key for unlimited access.`,
resetDate: resetDate.toISOString(),
isRateLimited: true,
},
{ status: 429 }
);
}
// Use default API key for free tier
finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT;
if (!finalApiKey) {
return NextResponse.json(
{ error: "Server configuration error - default API key not available" },
{ status: 500 }
);
}
}
let page;
let story;
@@ -235,9 +268,9 @@ COMPOSITION:
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
console.log("Generating comic with prompt length:", fullPrompt.length);
console.log("Generating comic with prompt length:", fullPrompt.length, "using tier:", isUsingFreeTier ? "free" : "paid");
const client = new Together({ apiKey });
const client = new Together({ apiKey: finalApiKey });
let response;
try {
+20
View File
@@ -0,0 +1,20 @@
import { db } from './lib/db';
import { stories, pages } from './lib/schema';
async function clearDatabase() {
try {
console.log('Clearing all pages...');
await db.delete(pages);
console.log('Clearing all stories...');
await db.delete(stories);
console.log('Database cleared successfully!');
} catch (error) {
console.error('Error clearing database:', error);
} finally {
process.exit(0);
}
}
clearDatabase();
+1 -1
View File
@@ -113,7 +113,7 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
<div className="mt-4 p-3 glass-panel rounded-lg">
<p className="text-xs text-muted-foreground text-center">
Your API key is stored locally and never sent to our servers.
Your API key is stored locally and never stored on our servers.
</p>
</div>
</DialogContent>
+92 -50
View File
@@ -1,44 +1,66 @@
"use client"
"use client";
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { ArrowRight, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
import { useS3Upload } from "next-s3-upload"
import { useAuth, SignInButton } from "@clerk/nextjs"
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { useS3Upload } from "next-s3-upload";
import { useAuth, SignInButton } from "@clerk/nextjs";
interface CreateButtonProps {
prompt: string
style: string
characterFiles: File[]
prompt: string;
style: string;
characterFiles: File[];
}
export function CreateButton({ prompt, style, characterFiles }: CreateButtonProps) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [loadingStep, setLoadingStep] = useState(0)
const { toast } = useToast()
const { uploadToS3 } = useS3Upload()
const { isSignedIn } = useAuth()
export function CreateButton({
prompt,
style,
characterFiles,
}: CreateButtonProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const { toast } = useToast();
const { uploadToS3 } = useS3Upload();
const { isSignedIn } = useAuth();
const [hasApiKey, setHasApiKey] = useState(false);
// Check if user has their own API key set
useEffect(() => {
const checkApiKey = () => {
const apiKey = localStorage.getItem("together_api_key");
setHasApiKey(!!apiKey);
};
checkApiKey();
// Listen for storage changes
window.addEventListener("storage", checkApiKey);
return () => window.removeEventListener("storage", checkApiKey);
}, []);
useEffect(() => {
if (!isLoading) return
if (!isLoading) return;
const steps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."]
let currentStep = 0
const steps = [
"Enhancing prompt...",
"Generating scenes...",
"Creating your comic...",
];
let currentStep = 0;
const interval = setInterval(() => {
currentStep += 1
currentStep += 1;
if (currentStep < steps.length) {
setLoadingStep(currentStep)
setLoadingStep(currentStep);
} else {
clearInterval(interval)
clearInterval(interval);
}
}, 2500)
}, 2500);
return () => clearInterval(interval)
}, [isLoading])
return () => clearInterval(interval);
}, [isLoading]);
const handleCreate = async () => {
if (!prompt.trim()) {
@@ -47,16 +69,18 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
description: "Please enter a prompt to generate your comic",
variant: "destructive",
duration: 3000,
})
return
});
return;
}
setIsLoading(true)
setLoadingStep(0)
setIsLoading(true);
setLoadingStep(0);
try {
const apiKey = localStorage.getItem("together_api_key")
const characterUploads = await Promise.all(characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)))
const apiKey = localStorage.getItem("together_api_key");
const characterUploads = await Promise.all(
characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
);
// Use API to create story and generate first page
const response = await fetch("/api/generate-comic", {
@@ -70,46 +94,56 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
style,
characterImages: characterUploads,
}),
})
});
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to create story")
const errorData = await response.json();
if (response.status === 429 && errorData.isRateLimited) {
throw new Error(errorData.error);
}
throw new Error(errorData.error || "Failed to create story");
}
const result = await response.json()
const result = await response.json();
// Redirect to the story editor using slug
router.push(`/editor/${result.storySlug}`)
router.push(`/editor/${result.storySlug}`);
} catch (error) {
console.error("Error creating comic:", error)
console.error("Error creating comic:", error);
toast({
title: "Creation failed",
description: error instanceof Error ? error.message : "Failed to create comic. Please try again.",
description:
error instanceof Error
? error.message
: "Failed to create comic. Please try again.",
variant: "destructive",
duration: 4000,
})
setIsLoading(false)
}
});
setIsLoading(false);
}
};
const loadingSteps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."]
const loadingSteps = [
"Enhancing prompt...",
"Generating scenes...",
"Creating your comic...",
];
return (
<div className="pt-2">
{isSignedIn ? (
<div className="flex items-center justify-between gap-3 w-full">
<Button
onClick={handleCreate}
disabled={isLoading || !prompt.trim()}
className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
className="bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm font-medium tracking-tight">{loadingSteps[loadingStep]}</span>
<span className="text-sm font-medium tracking-tight">
{loadingSteps[loadingStep]}
</span>
</>
) : (
<>
@@ -118,6 +152,14 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
</>
)}
</Button>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{hasApiKey ? (
<>Using your API key (~$0.01 per comic)</>
) : (
<>1 credit weekly</>
)}
</div>
</div>
) : (
<SignInButton mode="modal">
<Button className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight">
@@ -127,5 +169,5 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
</SignInButton>
)}
</div>
)
);
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "stories" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "stories" ALTER COLUMN "user_id" SET NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "stories" ALTER COLUMN "user_id" DROP NOT NULL;
+164
View File
@@ -0,0 +1,164 @@
{
"id": "6e2800e4-49f1-4069-aee3-00451ac4eb01",
"prevId": "5ab9181f-d5fe-4f00-8822-d2c4f5be3322",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"story_id": {
"name": "story_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_number": {
"name": "page_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"character_image_urls": {
"name": "character_image_urls",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"generated_image_url": {
"name": "generated_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pages_story_id_stories_id_fk": {
"name": "pages_story_id_stories_id_fk",
"tableFrom": "pages",
"tableTo": "stories",
"columnsFrom": [
"story_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.stories": {
"name": "stories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"stories_slug_unique": {
"name": "stories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+164
View File
@@ -0,0 +1,164 @@
{
"id": "d4fd5445-071f-469e-9bc8-ccb05ead5ef9",
"prevId": "6e2800e4-49f1-4069-aee3-00451ac4eb01",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"story_id": {
"name": "story_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"page_number": {
"name": "page_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"character_image_urls": {
"name": "character_image_urls",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"generated_image_url": {
"name": "generated_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pages_story_id_stories_id_fk": {
"name": "pages_story_id_stories_id_fk",
"tableFrom": "pages",
"tableTo": "stories",
"columnsFrom": [
"story_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.stories": {
"name": "stories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"stories_slug_unique": {
"name": "stories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+14
View File
@@ -15,6 +15,20 @@
"when": 1766492848044,
"tag": "0001_windy_ezekiel",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1766674906873,
"tag": "0002_romantic_blob",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1766674926877,
"tag": "0003_harsh_molecule_man",
"breakpoints": true
}
]
}
+15
View File
@@ -0,0 +1,15 @@
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
// Free tier: 1 comic per week (fixed window)
export const freeTierRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(1, "7 d"),
analytics: true,
prefix: "ratelimit:free-comics",
})
+1 -1
View File
@@ -7,7 +7,7 @@ export const stories = pgTable('stories', {
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
userId: uuid('user_id').notNull(), // Required Clerk user ID
userId: text('user_id').notNull(), // Required Clerk user ID
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
+2
View File
@@ -40,6 +40,8 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.36.0",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
+39 -2
View File
@@ -101,6 +101,12 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.1.6
version: 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@upstash/ratelimit':
specifier: ^2.0.7
version: 2.0.7(@upstash/redis@1.36.0)
'@upstash/redis':
specifier: ^1.36.0
version: 1.36.0
'@vercel/analytics':
specifier: 1.3.1
version: 1.3.1(next@16.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
@@ -121,7 +127,7 @@ importers:
version: 4.1.0
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)
version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(@upstash/redis@1.36.0)
embla-carousel-react:
specifier: 8.5.1
version: 8.5.1(react@19.2.0)
@@ -1994,6 +2000,18 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@upstash/core-analytics@0.0.10':
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
engines: {node: '>=16.0.0'}
'@upstash/ratelimit@2.0.7':
resolution: {integrity: sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==}
peerDependencies:
'@upstash/redis': ^1.34.3
'@upstash/redis@1.36.0':
resolution: {integrity: sha512-9zN2UV9QJGPnXfWU3yZBLVQaqqENDh7g+Y4J2vJuSxBCi9FQ0aUOtaXlzuFhnsiZvCqM+eS27ic+tgmkWUsfOg==}
'@vercel/analytics@1.3.1':
resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==}
peerDependencies:
@@ -2709,6 +2727,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -4890,6 +4911,19 @@ snapshots:
dependencies:
csstype: 3.2.3
'@upstash/core-analytics@0.0.10':
dependencies:
'@upstash/redis': 1.36.0
'@upstash/ratelimit@2.0.7(@upstash/redis@1.36.0)':
dependencies:
'@upstash/core-analytics': 0.0.10
'@upstash/redis': 1.36.0
'@upstash/redis@1.36.0':
dependencies:
uncrypto: 0.1.3
'@vercel/analytics@1.3.1(next@16.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
dependencies:
server-only: 0.0.1
@@ -5027,10 +5061,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0):
drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(@upstash/redis@1.36.0):
optionalDependencies:
'@neondatabase/serverless': 1.0.2
'@types/pg': 8.16.0
'@upstash/redis': 1.36.0
electron-to-chromium@1.5.267: {}
@@ -5512,6 +5547,8 @@ snapshots:
typescript@5.9.3: {}
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):