Add free tier rate limiting with Upstash Redis and API key handling
This commit is contained in:
@@ -9,3 +9,6 @@ DATABASE_URL=
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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,54 +94,72 @@ 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 ? (
|
||||
<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"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm font-medium tracking-tight">{loadingSteps[loadingStep]}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Generate
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Generate
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "stories" ALTER COLUMN "user_id" DROP NOT NULL;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+39
-2
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user