diff --git a/next.config.mjs b/next.config.mjs index 78b28611..9909ce10 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,12 +1,63 @@ /** @type {import('next').NextConfig} */ const productionUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL; + +const securityHeaders = [ + // Prevent MIME-type sniffing + { key: "X-Content-Type-Options", value: "nosniff" }, + // Block site from being framed (clickjacking protection) + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + // Enable XSS filter in older browsers + { key: "X-XSS-Protection", value: "1; mode=block" }, + // Control referrer information + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + // Restrict browser features + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(self), interest-cohort=()", + }, + // Force HTTPS in production + ...(productionUrl + ? [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ] + : []), + // Content Security Policy + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob: https://res.cloudinary.com https://lh3.googleusercontent.com https://avatars.githubusercontent.com", + "connect-src 'self' https://www.google-analytics.com https://api.mixpanel.com", + "frame-src 'self' https://www.youtube.com https://platform.twitter.com", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "upgrade-insecure-requests", + ].join("; "), + }, +]; + const nextConfig = { env: { NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL || (productionUrl ? `https://${productionUrl}` : "http://localhost:3000"), }, + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/api/auth/send-verification/route.ts b/src/app/api/auth/send-verification/route.ts index 73661364..813e99f2 100644 --- a/src/app/api/auth/send-verification/route.ts +++ b/src/app/api/auth/send-verification/route.ts @@ -8,8 +8,22 @@ import { NextResponse } from "next/server"; import { NextRequest } from "next/server"; import { sendEmail } from "@/src/lib/send-email"; import { getSettings } from "@/src/lib/queries/settings"; +import { getClientIp, rateLimit } from "@/src/lib/rate-limit"; export async function POST(req: NextRequest) { + // Rate limit: 3 verification emails per IP per 10 minutes + const ip = getClientIp(req); + const rl = rateLimit(`send-verification:${ip}`, { limit: 3, windowSecs: 600 }); + if (!rl.success) { + return NextResponse.json( + { + success: false, + message: "Too many requests. Please try again later.", + }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + const { email } = await req.json(); if (!email) { diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 1344462d..e2d2585a 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,16 +1,55 @@ import { NextRequest, NextResponse } from "next/server"; import { createUser } from "@/src/lib/queries/create-user"; import { getUser } from "@/src/lib/queries/get-user"; +import { getClientIp, rateLimit } from "@/src/lib/rate-limit"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 8; +const MAX_NAME_LENGTH = 100; +const MAX_EMAIL_LENGTH = 255; export async function POST(req: NextRequest) { + // Rate limit: 5 signup attempts per IP per 10 minutes + const ip = getClientIp(req); + const rl = rateLimit(`signup:${ip}`, { limit: 5, windowSecs: 600 }); + if (!rl.success) { + return NextResponse.json( + { data: null, message: "Too many requests. Please try again later." }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + try { const { name, email, password } = await req.json(); + if (!name || !email || !password) { return NextResponse.json( { data: null, message: "Missing required fields" }, { status: 400 } ); } + + if (typeof name !== "string" || name.trim().length === 0 || name.length > MAX_NAME_LENGTH) { + return NextResponse.json( + { data: null, message: `Name must be between 1 and ${MAX_NAME_LENGTH} characters` }, + { status: 400 } + ); + } + + if (typeof email !== "string" || email.length > MAX_EMAIL_LENGTH || !EMAIL_REGEX.test(email)) { + return NextResponse.json( + { data: null, message: "Invalid email address" }, + { status: 400 } + ); + } + + if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) { + return NextResponse.json( + { data: null, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }, + { status: 400 } + ); + } + const username = email.split("@")[0]; const existingUser = await getUser(email); @@ -20,7 +59,7 @@ export async function POST(req: NextRequest) { { status: 400 } ); } - const user = await createUser({ name, email, password, username }); + const user = await createUser({ name: name.trim(), email, password, username }); return NextResponse.json({ data: user, message: "User created successfully", diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index a7599763..9f58b04c 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -1,9 +1,23 @@ import { db } from "@/src/db"; import { contactMessages } from "@/src/db/schemas/contact.sql"; +import { getClientIp, rateLimit } from "@/src/lib/rate-limit"; import { sanitizeAndEncodeHtml } from "@/src/utils"; import { NextRequest, NextResponse } from "next/server"; +const MAX_MESSAGE_LENGTH = 2000; +const MAX_NAME_LENGTH = 100; + export async function POST(request: NextRequest) { + // Rate limit: 3 contact submissions per IP per 15 minutes + const ip = getClientIp(request); + const rl = rateLimit(`contact:${ip}`, { limit: 3, windowSecs: 900 }); + if (!rl.success) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } } + ); + } + try { const { name, email, message } = await request.json(); @@ -22,8 +36,22 @@ export async function POST(request: NextRequest) { ); } + if (typeof name !== "string" || name.trim().length === 0 || name.length > MAX_NAME_LENGTH) { + return NextResponse.json( + { error: `Name must be between 1 and ${MAX_NAME_LENGTH} characters` }, + { status: 400 } + ); + } + + if (typeof message !== "string" || message.trim().length === 0 || message.length > MAX_MESSAGE_LENGTH) { + return NextResponse.json( + { error: `Message must be between 1 and ${MAX_MESSAGE_LENGTH} characters` }, + { status: 400 } + ); + } + await db.insert(contactMessages).values({ - name, + name: sanitizeAndEncodeHtml(name.trim()), email, message: sanitizeAndEncodeHtml(message), }); diff --git a/src/app/api/posts/[slugOrPostId]/revisions/route.ts b/src/app/api/posts/[slugOrPostId]/revisions/route.ts new file mode 100644 index 00000000..fd5e6c98 --- /dev/null +++ b/src/app/api/posts/[slugOrPostId]/revisions/route.ts @@ -0,0 +1,83 @@ +import { db } from "@/src/db"; +import { posts, postRevisions } from "@/src/db/schemas"; +import { checkPermission } from "@/src/lib/auth/check-permission"; +import { desc, eq, or, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET( + req: NextRequest, + { params }: { params: { slugOrPostId: string } } +) { + const { slugOrPostId } = params; + return await checkPermission( + { requiredPermission: "posts:edit" }, + async () => { + try { + const post = await db.query.posts.findFirst({ + where: or( + eq(posts.slug, slugOrPostId), + eq(posts.post_id, slugOrPostId) + ), + columns: { id: true }, + }); + + if (!post) { + return NextResponse.json( + { data: null, message: "Post not found" }, + { status: 404 } + ); + } + + const { searchParams } = new URL(req.url); + const page = Math.max(1, Number(searchParams.get("page")) || 1); + const limit = Math.min( + 50, + Math.max(1, Number(searchParams.get("limit")) || 10) + ); + const offset = (page - 1) * limit; + + const [totalResult, revisions] = await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(postRevisions) + .where(eq(postRevisions.post_id, post.id)), + db.query.postRevisions.findMany({ + where: eq(postRevisions.post_id, post.id), + orderBy: [desc(postRevisions.revision_number)], + columns: { + id: true, + post_id: true, + title: true, + summary: true, + revision_number: true, + revised_by: true, + created_at: true, + }, + limit, + offset, + }), + ]); + + const total = Number(totalResult[0].count); + + return NextResponse.json({ + data: revisions, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + message: "Post revisions fetched successfully", + }); + } catch (error) { + return NextResponse.json( + { data: null, error: "Failed to fetch revisions" }, + { status: 500 } + ); + } + } + ); +} diff --git a/src/app/api/posts/[slugOrPostId]/route.ts b/src/app/api/posts/[slugOrPostId]/route.ts index 82f09ac1..7692ac33 100644 --- a/src/app/api/posts/[slugOrPostId]/route.ts +++ b/src/app/api/posts/[slugOrPostId]/route.ts @@ -1,5 +1,5 @@ import { db } from "@/src/db"; -import { posts } from "@/src/db/schemas"; +import { posts, postRevisions } from "@/src/db/schemas"; import { checkPermission } from "@/src/lib/auth/check-permission"; import { getSession } from "@/src/lib/auth/next-auth"; import { @@ -68,23 +68,34 @@ export async function PUT( { status: 404 } ); - await db - .update(posts) - .set({ - ...body, - scheduled_at: body.scheduled_at - ? new Date(body.scheduled_at) - : null, - reading_time: body?.content - ? calculateReadingTime( - stripHtml(decodeAndSanitizeHtml(body?.content || "")) - ) - : oldPost?.reading_time, - updated_at: new Date(), - }) - .where( - or(eq(posts.slug, slugOrPostId), eq(posts.post_id, slugOrPostId)) - ); + await db.transaction(async (tx) => { + // Snapshot the current post state as a revision before overwriting + await tx.insert(postRevisions).values({ + post_id: oldPost.id, + title: oldPost.title, + content: oldPost.content, + summary: oldPost.summary, + revised_by: session?.user?.id ?? oldPost.author_id, + }); + + await tx + .update(posts) + .set({ + ...body, + scheduled_at: body.scheduled_at + ? new Date(body.scheduled_at) + : null, + reading_time: body?.content + ? calculateReadingTime( + stripHtml(decodeAndSanitizeHtml(body?.content || "")) + ) + : oldPost?.reading_time, + updated_at: new Date(), + }) + .where( + or(eq(posts.slug, slugOrPostId), eq(posts.post_id, slugOrPostId)) + ); + }); const post = await getPostForEditing(slugOrPostId); revalidateTag("getPostWithCache"); revalidateTag("getPlainPostWithCache"); diff --git a/src/app/api/taxonomies/categories/[id]/route.ts b/src/app/api/taxonomies/categories/[id]/route.ts index f2e233af..df5abef1 100644 --- a/src/app/api/taxonomies/categories/[id]/route.ts +++ b/src/app/api/taxonomies/categories/[id]/route.ts @@ -54,7 +54,7 @@ export async function PUT( } await db .update(categories) - .set({ name: body.name, slug: body.slug }) + .set({ name: body.name, slug: body.slug, description: body.description }) .where(eq(categories.id, id)); revalidateTag("queryCategoriesWithFilters"); return NextResponse.json({ diff --git a/src/app/api/taxonomies/categories/route.ts b/src/app/api/taxonomies/categories/route.ts index 8569c2de..b696b142 100644 --- a/src/app/api/taxonomies/categories/route.ts +++ b/src/app/api/taxonomies/categories/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { { requiredPermission: "posts:create" }, async () => { try { - const { name, slug } = await request.json(); + const { name, slug, description } = await request.json(); if (!name || !slug) { return NextResponse.json( @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { const newCategory = await db .insert(categories) - .values({ name, slug }) + .values({ name, slug, description }) .onDuplicateKeyUpdate({ set: { name: sql`name`, slug: sql`slug` } }); revalidateTag("queryCategoriesWithFilters"); diff --git a/src/app/api/taxonomies/tags/route.ts b/src/app/api/taxonomies/tags/route.ts index ad2a5ced..c7a9c0ec 100644 --- a/src/app/api/taxonomies/tags/route.ts +++ b/src/app/api/taxonomies/tags/route.ts @@ -41,7 +41,7 @@ export async function POST(request: NextRequest) { { requiredPermission: "posts:create" }, async () => { try { - const { name, slug } = await request.json(); + const { name, slug, description } = await request.json(); if (!name || !slug) { return NextResponse.json( @@ -52,7 +52,7 @@ export async function POST(request: NextRequest) { const newCategory = await db .insert(tags) - .values({ name, slug }) + .values({ name, slug, description }) .onDuplicateKeyUpdate({ set: { name: sql`name`, slug: sql`slug` } }); revalidateTag("queryTagsWithFilters"); diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 00000000..84c5005a --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,14 @@ +import { getSiteUrl } from "@/src/utils/url"; +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + const siteUrl = getSiteUrl(); + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/dashboard/", "/api/", "/auth/"], + }, + sitemap: `${siteUrl}/sitemap.xml`, + }; +} diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts new file mode 100644 index 00000000..86e6c9a9 --- /dev/null +++ b/src/app/rss.xml/route.ts @@ -0,0 +1,92 @@ +import { getPosts } from "@/src/lib/queries/posts"; +import { getSettings } from "@/src/lib/queries/settings"; +import { generatePostUrl } from "@/src/utils"; +import { getSiteUrl } from "@/src/utils/url"; +import { NextResponse } from "next/server"; + +export const revalidate = 3600; // rebuild at most once per hour + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export async function GET() { + try { + const [{ data: posts }, settings] = await Promise.all([ + getPosts({ + limit: 50, + status: "published", + sortBy: "published_at", + sortOrder: "desc", + }), + getSettings(), + ]); + + const siteUrl = getSiteUrl(); + const siteName = escapeXml(settings.siteName?.value || "Penstack"); + const siteDescription = escapeXml( + settings.siteDescription?.value || "" + ); + + const items = posts + .map((post) => { + const postUrl = generatePostUrl(post as any); + const pubDate = new Date( + (post as any).published_at || post.created_at! + ).toUTCString(); + const title = escapeXml(post.title || ""); + const description = post.summary ? escapeXml(post.summary) : ""; + const authorName = post.author?.name + ? escapeXml(post.author.name) + : ""; + const categoryName = post.category?.name + ? escapeXml(post.category.name) + : ""; + const tagItems = ((post.tags as Array<{ name: string }>) || []) + .map((t) => ` ${escapeXml(t.name)}`) + .join("\n"); + + return ` + ${title} + ${postUrl} + ${postUrl} + ${pubDate}${ + description ? `\n ${description}` : "" + }${authorName ? `\n ${authorName}` : ""}${ + categoryName + ? `\n ${categoryName}` + : "" + }${tagItems ? `\n${tagItems}` : ""} + `; + }) + .join("\n"); + + const xml = ` + + + ${siteName} + ${siteUrl} + ${siteDescription} + en + ${new Date().toUTCString()} + +${items} + +`; + + return new NextResponse(xml, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }); + } catch (error) { + console.error("RSS feed generation failed:", error); + return new NextResponse("Failed to generate RSS feed", { status: 500 }); + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 00000000..faa3dbde --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,73 @@ +import { db } from "@/src/db"; +import { categories, posts, tags } from "@/src/db/schemas"; +import { generatePostUrl } from "@/src/utils"; +import { getSiteUrl } from "@/src/utils/url"; +import { eq } from "drizzle-orm"; +import type { MetadataRoute } from "next"; + +export const revalidate = 3600; + +export default async function sitemap(): Promise { + const siteUrl = getSiteUrl(); + + const [publishedPosts, allCategories, allTags] = await Promise.all([ + db.query.posts.findMany({ + where: eq(posts.status, "published"), + columns: { + slug: true, + updated_at: true, + published_at: true, + category_id: true, + }, + with: { + category: { columns: { slug: true } }, + tags: { with: { tag: { columns: { slug: true } } } }, + }, + }), + db.query.categories.findMany({ + columns: { slug: true, updated_at: true }, + }), + db.query.tags.findMany({ + columns: { slug: true, updated_at: true }, + }), + ]); + + const postUrls: MetadataRoute.Sitemap = publishedPosts.map((post) => ({ + url: generatePostUrl(post as any), + lastModified: post.updated_at ?? new Date(), + changeFrequency: "weekly", + priority: 0.8, + })); + + const categoryUrls: MetadataRoute.Sitemap = allCategories.map((cat) => ({ + url: `${siteUrl}/category/${cat.slug}`, + lastModified: cat.updated_at ?? new Date(), + changeFrequency: "weekly", + priority: 0.6, + })); + + const tagUrls: MetadataRoute.Sitemap = allTags.map((tag) => ({ + url: `${siteUrl}/tags/${tag.slug}`, + lastModified: tag.updated_at ?? new Date(), + changeFrequency: "weekly", + priority: 0.5, + })); + + return [ + { + url: siteUrl, + lastModified: new Date(), + changeFrequency: "daily", + priority: 1, + }, + { + url: `${siteUrl}/articles`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.9, + }, + ...postUrls, + ...categoryUrls, + ...tagUrls, + ]; +} diff --git a/src/db/schemas/index.ts b/src/db/schemas/index.ts index 0c6da9e2..951ff291 100644 --- a/src/db/schemas/index.ts +++ b/src/db/schemas/index.ts @@ -7,3 +7,4 @@ export * from "./posts-reactions.sql"; export * from "./settings.sql"; export * from "./contact.sql"; export * from "./verification-tokens.sql"; +export * from "./post-revisions.sql"; diff --git a/src/db/schemas/post-revisions.sql.ts b/src/db/schemas/post-revisions.sql.ts new file mode 100644 index 00000000..f0b1bcae --- /dev/null +++ b/src/db/schemas/post-revisions.sql.ts @@ -0,0 +1,49 @@ +import { relations } from "drizzle-orm"; +import { + index, + int, + longtext, + mysqlTable, + varchar, +} from "drizzle-orm/mysql-core"; +import { id, created_at } from "../schema-helper"; +import { posts } from "./posts.sql"; +import { users } from "./users.sql"; + +/** + * Stores a snapshot of post content each time a post is updated. + * Enables revision history similar to Ghost CMS. + * + * `revision_number` is auto-incremented by the database to avoid race + * conditions when concurrent requests update the same post simultaneously. + */ +export const postRevisions = mysqlTable( + "PostRevisions", + { + id, + post_id: int("post_id").notNull(), + title: varchar("title", { length: 255 }), + content: longtext("content"), + summary: varchar("summary", { length: 500 }), + // DB-level auto-increment ensures unique, monotonically increasing + // revision numbers without application-level locking. + revision_number: int("revision_number").autoincrement().notNull(), + revised_by: varchar("revised_by", { length: 100 }).notNull(), + created_at, + }, + (table) => ({ + idxPostId: index("idx_revision_post_id").on(table.post_id), + idxRevisedBy: index("idx_revision_revised_by").on(table.revised_by), + }) +); + +export const postRevisionsRelations = relations(postRevisions, ({ one }) => ({ + post: one(posts, { + fields: [postRevisions.post_id], + references: [posts.id], + }), + revisedBy: one(users, { + fields: [postRevisions.revised_by], + references: [users.auth_id], + }), +})); diff --git a/src/db/schemas/posts.sql.ts b/src/db/schemas/posts.sql.ts index 18301aa4..55dcfc50 100644 --- a/src/db/schemas/posts.sql.ts +++ b/src/db/schemas/posts.sql.ts @@ -37,7 +37,7 @@ export const posts = mysqlTable( scheduled_at: timestamp("scheduled_at"), schedule_id: varchar("schedule_id", { length: 50 }), author_id: varchar("author_id", { length: 100 }).notNull(), - visibility: mysqlEnum("visibility", ["public", "private"]).default( + visibility: mysqlEnum("visibility", ["public", "members", "private"]).default( "public" ), category_id: int("category_id"), @@ -46,6 +46,7 @@ export const posts = mysqlTable( allow_comments: boolean("allow_comments").default(false), send_newsletter: boolean("send_newsletter").default(true), newsletter_sent_at: timestamp("newsletter_sent_at"), + access_password: varchar("access_password", { length: 255 }), featured_image_id: int("featured_image_id"), created_at, published_at: timestamp("published_at").generatedAlwaysAs( @@ -118,6 +119,7 @@ export const categories = mysqlTable( id, name: varchar("name", { length: 100 }).notNull(), slug: varchar("slug", { length: 255 }).notNull().unique(), + description: varchar("description", { length: 500 }), created_at, updated_at, }, @@ -139,6 +141,7 @@ export const tags = mysqlTable( id, name: varchar("name", { length: 100 }).notNull(), slug: varchar("slug", { length: 255 }).notNull().unique(), + description: varchar("description", { length: 500 }), created_at, updated_at, }, diff --git a/src/lib/queries/settings/config.ts b/src/lib/queries/settings/config.ts index 3dc2194b..de1b5afa 100644 --- a/src/lib/queries/settings/config.ts +++ b/src/lib/queries/settings/config.ts @@ -31,4 +31,6 @@ export const DEFAULT_SETTINGS: SiteSettings = { emailFromName: { value: "", enabled: true, encrypted: false }, localPostAnalytics: { value: "", enabled: false, encrypted: false }, showSiteNameWithLogo: { value: "", enabled: false, encrypted: false }, + codeInjectionHead: { value: "", enabled: false, encrypted: false }, + codeInjectionFoot: { value: "", enabled: false, encrypted: false }, }; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 00000000..929a63f2 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,74 @@ +/** + * Lightweight in-memory sliding-window rate limiter. + * + * Suitable for single-instance deployments (Vercel serverless, single Node + * process). For multi-instance production use, replace the store with a shared + * backend such as Redis/Upstash. + */ + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +// Module-level store shared across requests in the same process +const store = new Map(); + +export interface RateLimitOptions { + /** Maximum number of requests allowed within the window */ + limit: number; + /** Window duration in seconds */ + windowSecs: number; +} + +export interface RateLimitResult { + success: boolean; + /** Remaining requests in the current window */ + remaining: number; + /** Unix timestamp (ms) when the window resets */ + resetAt: number; +} + +/** + * Check whether a given key (usually `"endpoint:ip"`) has exceeded its quota. + * + * @example + * const result = rateLimit("signup:" + ip, { limit: 5, windowSecs: 60 }); + * if (!result.success) return Response.json({ error: "Too many requests" }, { status: 429 }); + */ +export function rateLimit( + key: string, + { limit, windowSecs }: RateLimitOptions +): RateLimitResult { + const now = Date.now(); + const windowMs = windowSecs * 1000; + + // Look up the existing entry for this key (no global sweep; entries for + // other keys are only reclaimed when they are next accessed) + const entry = store.get(key); + + if (!entry || now > entry.resetAt) { + const resetAt = now + windowMs; + store.set(key, { count: 1, resetAt }); + return { success: true, remaining: limit - 1, resetAt }; + } + + if (entry.count >= limit) { + return { success: false, remaining: 0, resetAt: entry.resetAt }; + } + + entry.count += 1; + return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }; +} + +/** + * Extract the best-effort client IP from a Next.js Request. + * Falls back to `"unknown"` when the IP cannot be determined. + */ +export function getClientIp(req: Request): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + req.headers.get("x-real-ip") || + "unknown" + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 27de3d1a..4cb46d3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -183,6 +183,8 @@ export type SiteSettings = { emailFromName: SettingEntry; localPostAnalytics: SettingEntry; showSiteNameWithLogo: SettingEntry; + codeInjectionHead: SettingEntry; + codeInjectionFoot: SettingEntry; [key: string]: SettingEntry; }; export type ResendWebhookEvent = {