feat: photography features & image optimizations
- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component - Full-bleed single albums: subpages with 1 album skip grid, render photos directly - EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover) - Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml - Per-subpage grid overrides: each subpage can override global grid config - True masonry: per-image aspect ratios from EXIF dimensions - next/image migration: homepage hero, album covers, grid thumbnails, about portrait - ThumbHash blur placeholders and dominant color backgrounds - Custom next/image loader via image proxy with width/quality params - Lightbox fade-in transition with dominant color background - Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
59
lib/rate-limit.ts
Normal file
59
lib/rate-limit.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* In-memory sliding-window rate limiter.
|
||||
* Tracks request counts per IP per minute bucket.
|
||||
* Auto-evicts expired entries to prevent memory leaks.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Evict expired entries periodically (every 60s)
|
||||
let lastCleanup = Date.now();
|
||||
const CLEANUP_INTERVAL = 60_000;
|
||||
|
||||
function cleanup() {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
for (const [key, entry] of store) {
|
||||
if (now > entry.expiresAt) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be allowed.
|
||||
* @param ip - Client IP address
|
||||
* @param maxRpm - Maximum requests per minute
|
||||
* @returns { success, remaining, resetAt (epoch ms) }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
maxRpm: number,
|
||||
): { success: boolean; remaining: number; resetAt: number } {
|
||||
cleanup();
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const key = `rl:${ip}`;
|
||||
|
||||
const entry = store.get(key);
|
||||
|
||||
// New window or expired
|
||||
if (!entry || now > entry.expiresAt) {
|
||||
const resetAt = now + windowMs;
|
||||
store.set(key, { count: 1, expiresAt: resetAt });
|
||||
return { success: true, remaining: maxRpm - 1, resetAt };
|
||||
}
|
||||
|
||||
// Within window
|
||||
entry.count++;
|
||||
if (entry.count > maxRpm) {
|
||||
return { success: false, remaining: 0, resetAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
|
||||
}
|
||||
Reference in New Issue
Block a user