Initial commit
This commit is contained in:
23
templates/package.json
Normal file
23
templates/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "vercel-kv-project",
|
||||
"version": "1.0.0",
|
||||
"description": "Vercel KV (Redis) key-value storage",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"next": "^15.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
320
templates/session-management.ts
Normal file
320
templates/session-management.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// Complete Session Management with Vercel KV
|
||||
// Secure session handling for Next.js applications
|
||||
|
||||
import { kv } from '@vercel/kv';
|
||||
import { cookies } from 'next/headers';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// Session data interface
|
||||
interface SessionData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
createdAt: number;
|
||||
lastActivityAt: number;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
// Session configuration
|
||||
const SESSION_CONFIG = {
|
||||
cookieName: 'session',
|
||||
ttl: 7 * 24 * 3600, // 7 days in seconds
|
||||
renewalWindow: 24 * 3600, // Renew if less than 1 day remaining
|
||||
absoluteTimeout: 30 * 24 * 3600, // 30 days maximum
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CREATE SESSION
|
||||
// ============================================================================
|
||||
|
||||
export async function createSession(
|
||||
userId: string,
|
||||
email: string,
|
||||
options?: {
|
||||
role?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
): Promise<string> {
|
||||
// Generate secure session ID
|
||||
const sessionId = randomBytes(32).toString('base64url');
|
||||
|
||||
// Create session data
|
||||
const sessionData: SessionData = {
|
||||
userId,
|
||||
email,
|
||||
role: options?.role,
|
||||
createdAt: Date.now(),
|
||||
lastActivityAt: Date.now(),
|
||||
ipAddress: options?.ipAddress,
|
||||
userAgent: options?.userAgent,
|
||||
};
|
||||
|
||||
// Store in KV with TTL
|
||||
await kv.setex(
|
||||
`session:${sessionId}`,
|
||||
SESSION_CONFIG.ttl,
|
||||
JSON.stringify(sessionData)
|
||||
);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
cookies().set(SESSION_CONFIG.cookieName, sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_CONFIG.ttl,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET SESSION
|
||||
// ============================================================================
|
||||
|
||||
export async function getSession(): Promise<SessionData | null> {
|
||||
const sessionId = cookies().get(SESSION_CONFIG.cookieName)?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get session from KV
|
||||
const sessionJson = await kv.get<string>(`session:${sessionId}`);
|
||||
|
||||
if (!sessionJson) {
|
||||
// Session expired or doesn't exist
|
||||
await destroySession();
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionData: SessionData = JSON.parse(sessionJson);
|
||||
|
||||
// Check absolute timeout (prevent indefinite renewal)
|
||||
const sessionAge = Date.now() - sessionData.createdAt;
|
||||
if (sessionAge > SESSION_CONFIG.absoluteTimeout * 1000) {
|
||||
await destroySession();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auto-renew if close to expiration
|
||||
const timeSinceLastActivity = Date.now() - sessionData.lastActivityAt;
|
||||
if (timeSinceLastActivity > SESSION_CONFIG.renewalWindow * 1000) {
|
||||
await refreshSession(sessionId, sessionData);
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REFRESH SESSION
|
||||
// ============================================================================
|
||||
|
||||
async function refreshSession(sessionId: string, sessionData: SessionData): Promise<void> {
|
||||
// Update last activity
|
||||
sessionData.lastActivityAt = Date.now();
|
||||
|
||||
// Extend TTL
|
||||
await kv.setex(
|
||||
`session:${sessionId}`,
|
||||
SESSION_CONFIG.ttl,
|
||||
JSON.stringify(sessionData)
|
||||
);
|
||||
|
||||
// Extend cookie
|
||||
cookies().set(SESSION_CONFIG.cookieName, sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: SESSION_CONFIG.ttl,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE SESSION DATA
|
||||
// ============================================================================
|
||||
|
||||
export async function updateSession(updates: Partial<Omit<SessionData, 'userId' | 'createdAt'>>): Promise<boolean> {
|
||||
const sessionId = cookies().get(SESSION_CONFIG.cookieName)?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionJson = await kv.get<string>(`session:${sessionId}`);
|
||||
|
||||
if (!sessionJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionData: SessionData = JSON.parse(sessionJson);
|
||||
|
||||
// Merge updates
|
||||
Object.assign(sessionData, updates);
|
||||
sessionData.lastActivityAt = Date.now();
|
||||
|
||||
// Save updated session
|
||||
await kv.setex(
|
||||
`session:${sessionId}`,
|
||||
SESSION_CONFIG.ttl,
|
||||
JSON.stringify(sessionData)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DESTROY SESSION
|
||||
// ============================================================================
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const sessionId = cookies().get(SESSION_CONFIG.cookieName)?.value;
|
||||
|
||||
if (sessionId) {
|
||||
// Delete from KV
|
||||
await kv.del(`session:${sessionId}`);
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
cookies().delete(SESSION_CONFIG.cookieName);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET ALL USER SESSIONS (for multi-device support)
|
||||
// ============================================================================
|
||||
|
||||
export async function getUserSessions(userId: string): Promise<Array<{
|
||||
sessionId: string;
|
||||
data: SessionData;
|
||||
}>> {
|
||||
// Note: This requires maintaining a user -> sessions index
|
||||
// Store session IDs in a set for each user
|
||||
const sessionIds = await kv.smembers(`user:${userId}:sessions`);
|
||||
|
||||
const sessions = await Promise.all(
|
||||
sessionIds.map(async (sessionId) => {
|
||||
const sessionJson = await kv.get<string>(`session:${sessionId}`);
|
||||
if (!sessionJson) return null;
|
||||
|
||||
return {
|
||||
sessionId: sessionId as string,
|
||||
data: JSON.parse(sessionJson) as SessionData,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return sessions.filter((s) => s !== null) as Array<{
|
||||
sessionId: string;
|
||||
data: SessionData;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DESTROY ALL USER SESSIONS (for logout from all devices)
|
||||
// ============================================================================
|
||||
|
||||
export async function destroyAllUserSessions(userId: string): Promise<void> {
|
||||
const sessionIds = await kv.smembers(`user:${userId}:sessions`);
|
||||
|
||||
// Delete all session keys
|
||||
if (sessionIds.length > 0) {
|
||||
await kv.del(...sessionIds.map(id => `session:${id}`));
|
||||
}
|
||||
|
||||
// Clear sessions set
|
||||
await kv.del(`user:${userId}:sessions`);
|
||||
|
||||
// Clear current session cookie
|
||||
cookies().delete(SESSION_CONFIG.cookieName);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USAGE EXAMPLES
|
||||
// ============================================================================
|
||||
|
||||
// Example: Login Server Action
|
||||
/*
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
// Validate credentials (example - use proper auth)
|
||||
const user = await validateCredentials(email, password);
|
||||
|
||||
if (!user) {
|
||||
return { error: 'Invalid credentials' };
|
||||
}
|
||||
|
||||
// Create session
|
||||
await createSession(user.id, user.email, {
|
||||
role: user.role,
|
||||
ipAddress: headers().get('x-forwarded-for') || undefined,
|
||||
userAgent: headers().get('user-agent') || undefined,
|
||||
});
|
||||
|
||||
redirect('/dashboard');
|
||||
}
|
||||
*/
|
||||
|
||||
// Example: Logout Server Action
|
||||
/*
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function logout() {
|
||||
await destroySession();
|
||||
redirect('/login');
|
||||
}
|
||||
*/
|
||||
|
||||
// Example: Protected API Route
|
||||
/*
|
||||
import { getSession } from './session-management';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// User is authenticated
|
||||
return NextResponse.json({ userId: session.userId, email: session.email });
|
||||
}
|
||||
*/
|
||||
|
||||
// Example: Middleware for Protected Routes
|
||||
/*
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { kv } from '@vercel/kv';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const sessionId = request.cookies.get('session')?.value;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
const sessionJson = await kv.get<string>(`session:${sessionId}`);
|
||||
|
||||
if (!sessionJson) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/profile/:path*'],
|
||||
};
|
||||
*/
|
||||
339
templates/simple-rate-limiting.ts
Normal file
339
templates/simple-rate-limiting.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Simple Rate Limiting with Vercel KV
|
||||
// Protect API routes from abuse with sliding window and fixed window patterns
|
||||
|
||||
import { kv } from '@vercel/kv';
|
||||
|
||||
// ============================================================================
|
||||
// FIXED WINDOW RATE LIMITING (Simple, Good Enough for Most Cases)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fixed window rate limiter
|
||||
* Allows N requests per window (e.g., 10 requests per minute)
|
||||
*
|
||||
* @param identifier - Unique identifier (IP, user ID, API key)
|
||||
* @param limit - Maximum requests per window
|
||||
* @param windowSeconds - Window duration in seconds
|
||||
* @returns Object with allowed status and remaining count
|
||||
*/
|
||||
export async function fixedWindowRateLimit(
|
||||
identifier: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}> {
|
||||
const key = `ratelimit:${identifier}`;
|
||||
|
||||
// Increment counter
|
||||
const current = await kv.incr(key);
|
||||
|
||||
// If first request in window, set TTL
|
||||
if (current === 1) {
|
||||
await kv.expire(key, windowSeconds);
|
||||
}
|
||||
|
||||
// Get TTL to calculate reset time
|
||||
const ttl = await kv.ttl(key);
|
||||
const resetAt = new Date(Date.now() + (ttl > 0 ? ttl * 1000 : windowSeconds * 1000));
|
||||
|
||||
return {
|
||||
allowed: current <= limit,
|
||||
remaining: Math.max(0, limit - current),
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLIDING WINDOW RATE LIMITING (More Accurate, Prevents Bursts)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sliding window rate limiter using sorted set
|
||||
* More accurate than fixed window, prevents burst at window boundaries
|
||||
*
|
||||
* @param identifier - Unique identifier (IP, user ID, API key)
|
||||
* @param limit - Maximum requests per window
|
||||
* @param windowSeconds - Window duration in seconds
|
||||
*/
|
||||
export async function slidingWindowRateLimit(
|
||||
identifier: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}> {
|
||||
const key = `ratelimit:sliding:${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowStart = now - (windowSeconds * 1000);
|
||||
|
||||
// Remove old entries outside the window
|
||||
await kv.zremrangebyscore(key, 0, windowStart);
|
||||
|
||||
// Count requests in current window
|
||||
const count = await kv.zcard(key);
|
||||
|
||||
if (count < limit) {
|
||||
// Add current request with timestamp as score
|
||||
await kv.zadd(key, { score: now, member: `${now}-${Math.random()}` });
|
||||
|
||||
// Set expiration for cleanup
|
||||
await kv.expire(key, windowSeconds * 2);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: limit - count - 1,
|
||||
resetAt: new Date(now + (windowSeconds * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
// Get oldest entry to calculate when it expires
|
||||
const oldest = await kv.zrange(key, 0, 0, { withScores: true });
|
||||
const resetAt = oldest.length > 0
|
||||
? new Date(oldest[0].score + (windowSeconds * 1000))
|
||||
: new Date(now + (windowSeconds * 1000));
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RATE LIMIT MIDDLEWARE (Next.js API Routes)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Middleware function for Next.js API routes
|
||||
* Use with standard Next.js route handlers
|
||||
*/
|
||||
export async function withRateLimit<T>(
|
||||
request: Request,
|
||||
handler: () => Promise<T>,
|
||||
options: {
|
||||
identifier?: string; // Defaults to IP from headers
|
||||
limit?: number; // Default: 10
|
||||
windowSeconds?: number; // Default: 60 (1 minute)
|
||||
algorithm?: 'fixed' | 'sliding'; // Default: 'fixed'
|
||||
} = {}
|
||||
): Promise<Response | T> {
|
||||
const {
|
||||
limit = 10,
|
||||
windowSeconds = 60,
|
||||
algorithm = 'fixed',
|
||||
} = options;
|
||||
|
||||
// Get identifier (IP address by default)
|
||||
const identifier = options.identifier ||
|
||||
request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Apply rate limit
|
||||
const result = algorithm === 'sliding'
|
||||
? await slidingWindowRateLimit(identifier, limit, windowSeconds)
|
||||
: await fixedWindowRateLimit(identifier, limit, windowSeconds);
|
||||
|
||||
if (!result.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
resetAt: result.resetAt.toISOString(),
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-RateLimit-Limit': limit.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
|
||||
'Retry-After': Math.ceil((result.resetAt.getTime() - Date.now()) / 1000).toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Call handler
|
||||
return handler();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VIEW COUNTER (Simple Incrementing Counter)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple view counter for pages, posts, etc.
|
||||
* Increments count and returns new value
|
||||
*/
|
||||
export async function incrementViewCount(resourceId: string): Promise<number> {
|
||||
const key = `views:${resourceId}`;
|
||||
const count = await kv.incr(key);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get view count without incrementing
|
||||
*/
|
||||
export async function getViewCount(resourceId: string): Promise<number> {
|
||||
const key = `views:${resourceId}`;
|
||||
const count = await kv.get<number>(key);
|
||||
return count || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get view counts for multiple resources
|
||||
*/
|
||||
export async function getViewCounts(resourceIds: string[]): Promise<Record<string, number>> {
|
||||
const keys = resourceIds.map(id => `views:${id}`);
|
||||
const counts = await kv.mget<number[]>(...keys);
|
||||
|
||||
return resourceIds.reduce((acc, id, index) => {
|
||||
acc[id] = counts[index] || 0;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USAGE EXAMPLES
|
||||
// ============================================================================
|
||||
|
||||
// Example 1: Next.js API Route with Fixed Window Rate Limit
|
||||
/*
|
||||
// app/api/search/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withRateLimit } from './simple-rate-limiting';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withRateLimit(
|
||||
request,
|
||||
async () => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
// Your search logic here
|
||||
const results = await searchDatabase(query);
|
||||
|
||||
return NextResponse.json({ results });
|
||||
},
|
||||
{
|
||||
limit: 10, // 10 requests
|
||||
windowSeconds: 60, // per minute
|
||||
}
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 2: Next.js API Route with Sliding Window
|
||||
/*
|
||||
// app/api/ai-generation/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { withRateLimit } from './simple-rate-limiting';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withRateLimit(
|
||||
request,
|
||||
async () => {
|
||||
const body = await request.json();
|
||||
|
||||
// Expensive AI operation
|
||||
const result = await generateWithAI(body.prompt);
|
||||
|
||||
return NextResponse.json({ result });
|
||||
},
|
||||
{
|
||||
algorithm: 'sliding', // More accurate for expensive operations
|
||||
limit: 5, // 5 requests
|
||||
windowSeconds: 3600, // per hour
|
||||
}
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 3: Rate Limit by User ID Instead of IP
|
||||
/*
|
||||
import { getSession } from './session-management';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
return withRateLimit(
|
||||
request,
|
||||
async () => {
|
||||
// Your logic
|
||||
return NextResponse.json({ success: true });
|
||||
},
|
||||
{
|
||||
identifier: `user:${session.userId}`, // Rate limit per user
|
||||
limit: 100,
|
||||
windowSeconds: 3600,
|
||||
}
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 4: View Counter in Server Action
|
||||
/*
|
||||
'use server';
|
||||
|
||||
import { incrementViewCount } from './simple-rate-limiting';
|
||||
|
||||
export async function recordPageView(pageSlug: string) {
|
||||
const views = await incrementViewCount(pageSlug);
|
||||
return views;
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 5: Display View Count in Component
|
||||
/*
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function ViewCounter({ postId }: { postId: string }) {
|
||||
const [views, setViews] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Increment view count
|
||||
fetch('/api/increment-view', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ postId }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => setViews(data.views));
|
||||
}, [postId]);
|
||||
|
||||
if (views === null) return null;
|
||||
|
||||
return (
|
||||
<span className="text-sm text-gray-500">
|
||||
{views.toLocaleString()} views
|
||||
</span>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 6: Multiple Rate Limits (Different Tiers)
|
||||
/*
|
||||
export async function tieredRateLimit(request: Request) {
|
||||
const session = await getSession();
|
||||
|
||||
const limits = session?.role === 'premium'
|
||||
? { limit: 1000, windowSeconds: 3600 } // Premium: 1000/hour
|
||||
: { limit: 100, windowSeconds: 3600 }; // Free: 100/hour
|
||||
|
||||
return withRateLimit(request, async () => {
|
||||
// Your logic
|
||||
return NextResponse.json({ success: true });
|
||||
}, {
|
||||
identifier: session ? `user:${session.userId}` : undefined,
|
||||
...limits,
|
||||
});
|
||||
}
|
||||
*/
|
||||
Reference in New Issue
Block a user