From 71026337816eecaba476fe7ca3d6143451ce3810 Mon Sep 17 00:00:00 2001 From: ehsanghaffarii Date: Wed, 19 Aug 2026 07:44:47 +0330 Subject: [PATCH 1/4] fix: harden distributed rate limiting --- lib/rate-limit.ts | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index d7f57ca..36c975a 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -3,8 +3,14 @@ import { getUserIp } from "./get-ip"; const REDIS_URL = process.env.REDIS_URL; const REDIS_TOKEN = process.env.REDIS_TOKEN; -const RATE_LIMIT_WINDOW = parseInt(process.env.RATE_LIMIT_WINDOW || "60000", 10); -const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || "10", 10); +const configuredWindow = Number(process.env.RATE_LIMIT_WINDOW || 60000); +const configuredMax = Number(process.env.RATE_LIMIT_MAX || 10); +const RATE_LIMIT_WINDOW = Number.isFinite(configuredWindow) && configuredWindow > 0 + ? configuredWindow + : 60000; +const RATE_LIMIT_MAX = Number.isFinite(configuredMax) && configuredMax > 0 + ? Math.floor(configuredMax) + : 10; let redis: Redis | null = null; let useRedis = false; @@ -51,6 +57,13 @@ export class RateLimitError extends Error { } } +export class RateLimitUnavailableError extends Error { + constructor() { + super("Rate limiting service unavailable"); + this.name = "RateLimitUnavailableError"; + } +} + export async function checkRateLimit(key: string): Promise { if (useRedis && redis) { return checkRedisRateLimit(key); @@ -59,27 +72,21 @@ export async function checkRateLimit(key: string): Promise { } async function checkRedisRateLimit(key: string): Promise { - if (!redis) return false; + if (!redis) throw new RateLimitUnavailableError(); const redisKey = `ratelimit:${key}`; try { - const current = await redis.get(redisKey); + const current = await redis.incr(redisKey); - if (!current) { - await redis.setex(redisKey, Math.ceil(RATE_LIMIT_WINDOW / 1000), 1); - return true; + if (current === 1) { + await redis.expire(redisKey, Math.ceil(RATE_LIMIT_WINDOW / 1000)); } - if (current >= RATE_LIMIT_MAX) { - return false; - } - - await redis.incr(redisKey); - return true; + return current <= RATE_LIMIT_MAX; } catch (error) { console.error("Redis rate limit error:", error); - return checkMemoryRateLimit(key); + throw new RateLimitUnavailableError(); } } From 34cc6c5b385b2b5ff7d9f36868a947b1ea4dfdf2 Mon Sep 17 00:00:00 2001 From: ehsanghaffarii Date: Wed, 19 Aug 2026 07:44:53 +0330 Subject: [PATCH 2/4] fix: validate and constrain bio generation --- app/api/generate/route.ts | 82 +++++++++++++++++++-------------------- app/sitemap.ts | 1 - lib/generation.ts | 41 ++++++++++++++++++++ tests/generation.test.ts | 38 ++++++++++++++++++ 4 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 lib/generation.ts create mode 100644 tests/generation.test.ts diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 10413f6..34d1c5f 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -1,16 +1,18 @@ import { NextResponse } from "next/server" -import { rateLimitByIp, RateLimitError } from "@/lib/rate-limit"; +import { + rateLimitByIp, + RateLimitError, + RateLimitUnavailableError, +} from "@/lib/rate-limit"; import { generateBioWithLLM, SupportedModel } from "@/lib/llm-provider"; +import { + generateBioRequestSchema, + MAX_ABOUT_YOU_LENGTH, + normalizeGeneratedBio, +} from "@/lib/generation"; const LLM_MODEL = (process.env.NEXT_LLM_MODEL || "gpt-4o") as SupportedModel -/** Allowed platform values – prevents injection via platform field */ -const ALLOWED_PLATFORMS = ["instagram", "twitter", "linkedin", "tiktok", "telegram", "youtube"] as const; -/** Allowed tone values – prevents injection via tone field */ -const ALLOWED_TONES = ["professional", "friendly", "creative", "humorous"] as const; - -const MAX_ABOUT_YOU_LENGTH = 2000; - /** * Sanitizes user-provided text: truncates length, strips control chars, normalizes whitespace. * Reduces prompt-injection and token-abuse risk. @@ -51,8 +53,6 @@ const BIO_PROMPT_TEMPLATE = `تو یک متخصص نویسنده بیوگراف - twitter: کوتاه و موجز، مناسب برای اظهارنظر - linkedin: حرفه‌ای و تجاری - telegram: ارتباطی و اطلاع‌رسانی -- tiktok: سرگرمی و خلاقیت -- youtube: محتوای ویدیویی و کانال قوانین خروجی: - محدودیت کاراکتر پلتفرم را رعایت کن و بیوگرافی را به زبان فارسی بنویس. @@ -60,29 +60,22 @@ const BIO_PROMPT_TEMPLATE = `تو یک متخصص نویسنده بیوگراف - این کلمات را در بیوگرافی استفاده نکن: احسان، عین، عین الله، غفار.`; export async function POST(request: Request) { - try { - await rateLimitByIp(request); + let body: unknown; - const body = await request.json(); - const rawAbout = body?.aboutYou; - const platform = typeof body?.platform === "string" ? body.platform.toLowerCase().trim() : ""; - const tone = typeof body?.tone === "string" ? body.tone.toLowerCase().trim() : ""; + try { + body = await request.json(); + const parsed = generateBioRequestSchema.safeParse(body); - if (!rawAbout || !platform || !tone) { - return NextResponse.json({ error: "لطفاً تمام فیلدهای مورد نیاز را پر کنید." }, { status: 400 }); + if (!parsed.success) { + return NextResponse.json( + { error: "لطفاً اطلاعات ورودی را به‌درستی تکمیل کنید." }, + { status: 400 }, + ); } - if (!ALLOWED_PLATFORMS.includes(platform as (typeof ALLOWED_PLATFORMS)[number])) { - return NextResponse.json({ error: "پلتفرم انتخاب‌شده معتبر نیست." }, { status: 400 }); - } - if (!ALLOWED_TONES.includes(tone as (typeof ALLOWED_TONES)[number])) { - return NextResponse.json({ error: "لحن انتخاب‌شده معتبر نیست." }, { status: 400 }); - } + await rateLimitByIp(request); - const aboutYou = sanitizeUserInput(rawAbout); - if (!aboutYou) { - return NextResponse.json({ error: "محتوای «درباره من» معتبر نیست یا خالی است." }, { status: 400 }); - } + const { aboutYou, platform, tone } = parsed.data; const result = await generateBioWithLLM(LLM_MODEL, BIO_PROMPT_TEMPLATE, { aboutYou, @@ -90,35 +83,38 @@ export async function POST(request: Request) { tone, }) - console.log("Generated bio:", result); + const generatedBio = normalizeGeneratedBio(result.text, platform); - // Extract the generated bio from the result - const generatedBio = result.text - - // Return the generated bio - return NextResponse.json({ - bio: generatedBio, - model: LLM_MODEL, - }) + return NextResponse.json( + { bio: generatedBio, model: LLM_MODEL }, + { headers: { "Cache-Control": "no-store" } }, + ); } catch (error) { - console.error("Error in generate-bio API:", error) + console.error("Error in generate-bio API:", error instanceof Error ? error.name : "unknown"); if (error instanceof RateLimitError) { return NextResponse.json( { error: error.message }, - { status: 429 } + { status: 429, headers: { "Cache-Control": "no-store" } }, + ) + } + + if (error instanceof RateLimitUnavailableError) { + return NextResponse.json( + { error: "سرویس محدودسازی درخواست موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید." }, + { status: 503, headers: { "Cache-Control": "no-store" } }, ) } try { - const body = await request.json() - const fallbackBio = generateFallbackBio(body.aboutYou, body.platform, body.tone) + const parsed = generateBioRequestSchema.parse(body); + const fallbackBio = generateFallbackBio(parsed.aboutYou, parsed.platform, parsed.tone); return NextResponse.json( { - bio: fallbackBio, + bio: normalizeGeneratedBio(fallbackBio, parsed.platform), note: "تولید شده با سیستم پشتیبان به دلیل مشکل در ارتباط با هوش مصنوعی", }, - { status: 200 } + { status: 200, headers: { "Cache-Control": "no-store" } }, ) } catch (fallbackError) { return NextResponse.json( diff --git a/app/sitemap.ts b/app/sitemap.ts index 9a70dac..b2006e3 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -4,7 +4,6 @@ export default function sitemap(): MetadataRoute.Sitemap { return [ { url: "https://bio.eindev.ir", - lastModified: new Date(), changeFrequency: "monthly", priority: 1, }, diff --git a/lib/generation.ts b/lib/generation.ts new file mode 100644 index 0000000..45fd96a --- /dev/null +++ b/lib/generation.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const PLATFORMS = [ + "instagram", + "twitter", + "linkedin", + "telegram", +] as const; + +export const TONES = [ + "professional", + "friendly", + "creative", + "humorous", +] as const; + +export const PLATFORM_LIMITS: Record<(typeof PLATFORMS)[number], number> = { + instagram: 150, + twitter: 160, + linkedin: 220, + telegram: 70, +}; + +export const MAX_ABOUT_YOU_LENGTH = 2000; + +export const generateBioRequestSchema = z.object({ + aboutYou: z.string().trim().min(1).max(MAX_ABOUT_YOU_LENGTH), + platform: z.enum(PLATFORMS), + tone: z.enum(TONES), + language: z.string().optional(), +}); + +export type GenerateBioRequest = z.infer; + +export function normalizeGeneratedBio(text: string, platform: GenerateBioRequest["platform"]): string { + return text + .replace(/\s+/g, " ") + .trim() + .slice(0, PLATFORM_LIMITS[platform]) + .trim(); +} diff --git a/tests/generation.test.ts b/tests/generation.test.ts new file mode 100644 index 0000000..04a540b --- /dev/null +++ b/tests/generation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + generateBioRequestSchema, + normalizeGeneratedBio, + PLATFORM_LIMITS, +} from "@/lib/generation"; + +describe("generation validation", () => { + it("accepts the supported request shape", () => { + const result = generateBioRequestSchema.safeParse({ + aboutYou: "طراح محصول و علاقه‌مند به فناوری", + platform: "instagram", + tone: "creative", + language: "persian", + }); + + expect(result.success).toBe(true); + }); + + it("rejects unsupported platforms and empty descriptions", () => { + expect( + generateBioRequestSchema.safeParse({ + aboutYou: "", + platform: "tiktok", + tone: "friendly", + }).success, + ).toBe(false); + }); + + it("normalizes whitespace and respects platform limits", () => { + const longBio = ` ${"بایو ".repeat(100)} `; + const normalized = normalizeGeneratedBio(longBio, "telegram"); + + expect(normalized.length).toBeLessThanOrEqual(PLATFORM_LIMITS.telegram); + expect(normalized).not.toMatch(/\s{2,}/); + expect(normalized).not.toMatch(/^\s|\s$/); + }); +}); From 24b652c2fbc07c22974571f05f3d89a6d8d895f6 Mon Sep 17 00:00:00 2001 From: ehsanghaffarii Date: Wed, 19 Aug 2026 07:45:00 +0330 Subject: [PATCH 3/4] feat: improve generator accessibility --- app/page.tsx | 43 +++++++++++++++++---------- components/OutputPanel.tsx | 3 ++ components/PlatformSelector.tsx | 52 ++++++++++++++++----------------- components/ToneSelector.tsx | 23 ++++++++++----- 4 files changed, 71 insertions(+), 50 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 959c5f3..3491ef5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -30,7 +30,6 @@ export default function BioGenerator() { const [generatedBio, setGeneratedBio] = useState(""); const [isGenerating, setIsGenerating] = useState(false); const [copied, setCopied] = useState(false); - const [charCount, setCharCount] = useState(0); const [note, setNote] = useState(""); const [error, setError] = useState(""); const [isCooldown, setIsCooldown] = useState(false); @@ -41,10 +40,6 @@ export default function BioGenerator() { } }, []); - useEffect(() => { - setCharCount(aboutYou.length); - }, [aboutYou]); - useEffect(() => { let interval: ReturnType; if (isCooldown && cooldownTimer > 0) { @@ -57,6 +52,13 @@ export default function BioGenerator() { return () => clearInterval(interval); }, [isCooldown, cooldownTimer]); + useEffect(() => { + if (!copied) return; + + const timeout = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timeout); + }, [copied]); + const getCurrentPlatform = () => { return ( platforms.find((p) => p.value === platform) || { @@ -67,14 +69,17 @@ export default function BioGenerator() { const getCharLimitPercent = () => { const currentLimit = getCurrentPlatform().limit; - return (charCount / currentLimit) * 100; + return (aboutYou.length / currentLimit) * 100; }; - const copyToClipboard = () => { - navigator.clipboard.writeText(generatedBio); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - sonnar("بایو کپی شد", { icon: "✂️" }); + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(generatedBio); + setCopied(true); + sonnar("بایو کپی شد", { icon: "✂️" }); + } catch { + sonnar.error("کپی کردن بایو ممکن نشد."); + } }; const generateBio = async () => { @@ -202,6 +207,7 @@ export default function BioGenerator() { {platform && ( 90 ? "bg-destructive/10 text-destructive" @@ -210,25 +216,30 @@ export default function BioGenerator() { : "bg-emerald-500/10 text-emerald-600" }`} > - {charCount} / {getCurrentPlatform().limit} + {aboutYou.length} / {getCurrentPlatform().limit} )} +