From d7787fee76e807f1c03874eb75ae1494ccbf8478 Mon Sep 17 00:00:00 2001 From: luisdralves Date: Wed, 10 Jun 2026 13:21:56 +0100 Subject: [PATCH] Integrate with rate-my-shots --- .gitea/workflows/deploy.yaml | 4 +- Dockerfile | 5 +- bun.lock | 6 - docker-compose.yml | 8 +- package.json | 2 - src/app/api/photos/route.ts | 115 ----------------- src/app/photos/[...path]/route.ts | 90 ------------- src/app/sections/photography/index.tsx | 5 +- src/app/sections/photography/photo-tile.tsx | 44 +++---- .../photography/photography-client.tsx | 120 ++++++++++++------ src/app/sections/photography/types.ts | 12 +- src/lib/rate-limit.ts | 72 ----------- 12 files changed, 116 insertions(+), 367 deletions(-) delete mode 100644 src/app/api/photos/route.ts delete mode 100644 src/app/photos/[...path]/route.ts delete mode 100644 src/lib/rate-limit.ts diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index e5b40e3..d8c0301 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -31,9 +31,7 @@ jobs: run: | cat > .env << 'EOF' HOST_PORT=${{ vars.HOST_PORT }} - IMMICH_API_URL=${{ vars.IMMICH_API_URL }} - IMMICH_API_KEY=${{ secrets.IMMICH_API_KEY }} - IMMICH_ALBUM_ID=${{ vars.IMMICH_ALBUM_ID }} + RATE_MY_SHOTS_URL=${{ vars.RATE_MY_SHOTS_URL }} DATA_DIR=/opt/website/data EOF diff --git a/Dockerfile b/Dockerfile index 10645a0..122253a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,14 +7,13 @@ FROM oven/bun:1 AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG RATE_MY_SHOTS_URL +ENV RATE_MY_SHOTS_URL=$RATE_MY_SHOTS_URL ENV NEXT_TELEMETRY_DISABLED=1 RUN bun run build FROM node:24-slim AS runner WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends libimage-exiftool-perl \ - && rm -rf /var/lib/apt/lists/* ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 diff --git a/bun.lock b/bun.lock index a6ab021..8bd5699 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "better-sqlite3": "^12.6.2", - "ipaddr.js": "^2.3.0", - "mime": "^4.1.0", "motion": "^12.34.2", "next": "16.1.6", "react": "19.2.3", @@ -285,8 +283,6 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], - "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -329,8 +325,6 @@ "meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="], - "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/docker-compose.yml b/docker-compose.yml index 34bb08c..e1e4582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,14 @@ name: website services: app: - build: . + build: + context: . + args: + RATE_MY_SHOTS_URL: ${RATE_MY_SHOTS_URL} restart: unless-stopped ports: - "${HOST_PORT}:3000" environment: - IMMICH_API_URL: ${IMMICH_API_URL} - IMMICH_API_KEY: ${IMMICH_API_KEY} - IMMICH_ALBUM_ID: ${IMMICH_ALBUM_ID} DATA_DIR: /app/data volumes: - "${DATA_DIR:-./data}:/app/data" diff --git a/package.json b/package.json index 2220a49..4efc839 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "better-sqlite3": "^12.6.2", - "ipaddr.js": "^2.3.0", - "mime": "^4.1.0", "motion": "^12.34.2", "next": "16.1.6", "react": "19.2.3", diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts deleted file mode 100644 index 2863c5f..0000000 --- a/src/app/api/photos/route.ts +++ /dev/null @@ -1,115 +0,0 @@ -import mime from "mime"; -import { type NextRequest, NextResponse } from "next/server"; -import type { PhotoItem } from "@/app/sections/photography/types"; -import { rateLimit } from "@/lib/rate-limit"; - -const IMMICH_API_URL = process.env.IMMICH_API_URL ?? ""; -const IMMICH_API_KEY = process.env.IMMICH_API_KEY ?? ""; -const IMMICH_ALBUM_ID = process.env.IMMICH_ALBUM_ID ?? ""; -const PAGE_SIZE = 48; - -type ImmichAsset = { - id: string; - width: number; - height: number; - originalMimeType: string; - exifInfo: { - rating: number | null; - fNumber: number | null; - exposureTime: string | null; - iso: number | null; - focalLength: number | null; - model: string | null; - lensModel: string | null; - }; -}; - -type ImmichAlbumResponse = { - assets: ImmichAsset[]; -}; - -function seededRandom(seed: string) { - let hash = 0; - for (let i = 0; i < seed.length; i++) { - const char = seed.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - - return () => { - hash = Math.imul(hash ^ (hash >>> 15), hash | 1); - hash ^= hash + Math.imul(hash ^ (hash >>> 7), hash | 61); - return ((hash ^ (hash >>> 14)) >>> 0) / 4294967296; - }; -} - -function sortByRating(items: PhotoItem[], seed: string): PhotoItem[] { - const random = seededRandom(seed); - return [...items].sort((a, b) => { - const diff = b.rating - a.rating; - if (diff !== 0) return diff; - return random() - 0.5; - }); -} - -async function fetchAlbum(): Promise { - if (!IMMICH_API_URL || !IMMICH_API_KEY || !IMMICH_ALBUM_ID) { - console.error("[photos] Missing Immich configuration:", { - hasUrl: !!IMMICH_API_URL, - hasKey: !!IMMICH_API_KEY, - hasAlbumId: !!IMMICH_ALBUM_ID, - }); - return []; - } - - const response = await fetch(`${IMMICH_API_URL}/albums/${IMMICH_ALBUM_ID}`, { - headers: { - "x-api-key": IMMICH_API_KEY, - }, - next: { revalidate: 3600 }, - }); - - if (!response.ok) { - console.error("[photos] Immich API error:", response.status, response.statusText); - return []; - } - - const data = (await response.json()) as ImmichAlbumResponse; - - return data.assets.map((asset) => ({ - id: asset.id, - width: asset.width, - height: asset.height, - ext: mime.getExtension(asset.originalMimeType) ?? "jpg", - rating: asset.exifInfo.rating ?? 0, - exif: { - fNumber: asset.exifInfo.fNumber, - exposureTime: asset.exifInfo.exposureTime, - iso: asset.exifInfo.iso, - focalLength: asset.exifInfo.focalLength, - model: asset.exifInfo.model, - lensModel: asset.exifInfo.lensModel, - }, - })); -} - -export async function GET(request: NextRequest) { - const { allowed } = rateLimit(request, "photos", 60, 60_000); - if (!allowed) { - return new NextResponse("Too many requests", { status: 429 }); - } - - const { searchParams } = request.nextUrl; - const seed = searchParams.get("seed") ?? "default"; - const page = Number.parseInt(searchParams.get("page") ?? "1", 10); - - const allPhotos = await fetchAlbum(); - const sorted = sortByRating(allPhotos, seed); - - const startIndex = (page - 1) * PAGE_SIZE; - const endIndex = startIndex + PAGE_SIZE; - const photos = sorted.slice(startIndex, endIndex); - const hasMore = endIndex < sorted.length; - - return NextResponse.json({ photos, hasMore }); -} diff --git a/src/app/photos/[...path]/route.ts b/src/app/photos/[...path]/route.ts deleted file mode 100644 index 5dc6527..0000000 --- a/src/app/photos/[...path]/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { exec } from "node:child_process"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import mime from "mime"; -import { type NextRequest, NextResponse } from "next/server"; -import { rateLimit } from "@/lib/rate-limit"; - -const execAsync = promisify(exec); - -const IMMICH_API_URL = process.env.IMMICH_API_URL ?? ""; -const IMMICH_API_KEY = process.env.IMMICH_API_KEY ?? ""; - -async function stripMetadata(input: Buffer, id: string): Promise { - const dir = join(tmpdir(), "photo-proxy"); - await mkdir(dir, { recursive: true }); - const tempFile = join(dir, `${id}.tmp`); - - try { - await writeFile(tempFile, input); - await execAsync( - `exiftool -all= -tagsfromfile @ -Orientation -ICC_Profile -overwrite_original "${tempFile}"`, - ); - return await readFile(tempFile); - } finally { - await rm(tempFile, { force: true }); - } -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ path: string[] }> }, -) { - const { allowed } = rateLimit(request, "photo-asset", 120, 60_000); - if (!allowed) { - return new NextResponse("Too many requests", { status: 429 }); - } - - const { path } = await params; - const fullPath = path.join("/"); - - const match = fullPath.match( - /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(\w+)$/i, - ); - if (!match) { - return new NextResponse("Invalid path format", { status: 400 }); - } - - const [, id, ext] = match; - const contentType = mime.getType(ext) ?? "application/octet-stream"; - - if (!IMMICH_API_URL || !IMMICH_API_KEY) { - console.warn("[photos] Missing Immich configuration for asset proxy"); - return new NextResponse("Server configuration error", { status: 500 }); - } - - const immichUrl = `${IMMICH_API_URL}/assets/${id}/original`; - - const response = await fetch(immichUrl, { - headers: { - "x-api-key": IMMICH_API_KEY, - }, - }); - - if (!response.ok) { - return new NextResponse("Image not found", { status: response.status }); - } - - const originalBuffer = Buffer.from(await response.arrayBuffer()); - - let strippedBuffer: Buffer; - try { - strippedBuffer = await stripMetadata(originalBuffer, id); - } catch (err) { - console.warn("[photos] Metadata stripping failed:", err); - return new NextResponse("Failed to process image", { status: 500 }); - } - - const etag = `"${id}"`; - - return new NextResponse(strippedBuffer as BodyInit, { - status: 200, - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=31536000, immutable", - ETag: etag, - }, - }); -} diff --git a/src/app/sections/photography/index.tsx b/src/app/sections/photography/index.tsx index 3db10ba..d255e50 100644 --- a/src/app/sections/photography/index.tsx +++ b/src/app/sections/photography/index.tsx @@ -1,6 +1,9 @@ import { PhotographyClient } from "./photography-client"; export const Photography = () => { + const apiUrl = process.env.RATE_MY_SHOTS_URL; + if (!apiUrl) throw new Error("Missing required env var: RATE_MY_SHOTS_URL"); + return (
@@ -12,7 +15,7 @@ export const Photography = () => {
- +
); diff --git a/src/app/sections/photography/photo-tile.tsx b/src/app/sections/photography/photo-tile.tsx index 6f10c1c..a4ce747 100644 --- a/src/app/sections/photography/photo-tile.tsx +++ b/src/app/sections/photography/photo-tile.tsx @@ -1,13 +1,19 @@ "use client"; import { m, useReducedMotion } from "motion/react"; -import Image from "next/image"; import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover"; import { springGentle } from "@/lib/motion"; import type { PhotoItem } from "./types"; type PhotoTileProps = { photo: PhotoItem; + apiUrl: string; +}; + +const formatExposureTime = (s: number): string => { + if (s >= 1) return `${s}s`; + const denom = Math.round(1 / s); + return `1/${denom}s`; }; const formatExifLine = (photo: PhotoItem): string | null => { @@ -16,23 +22,13 @@ const formatExifLine = (photo: PhotoItem): string | null => { if (exif.focalLength) parts.push(`${exif.focalLength}mm`); if (exif.fNumber) parts.push(`ƒ/${exif.fNumber}`); - if (exif.exposureTime) parts.push(`${exif.exposureTime}s`); + if (exif.exposureTime) parts.push(formatExposureTime(exif.exposureTime)); if (exif.iso) parts.push(`${exif.iso / 100}\u00A0hISO`); return parts.length > 0 ? parts.join(" · ") : null; }; -const formatGearLine = (photo: PhotoItem): string | null => { - const { exif } = photo; - const parts: string[] = []; - - if (exif.model) parts.push(exif.model); - if (exif.lensModel) parts.push(exif.lensModel); - - return parts.length > 0 ? parts.join(" · ") : null; -}; - -export const PhotoTile = ({ photo }: PhotoTileProps) => { +export const PhotoTile = ({ photo, apiUrl }: PhotoTileProps) => { const shouldReduceMotion = useReducedMotion(); const hover = useMagneticSpringHover({ magnetStrength: 0.15, @@ -41,8 +37,8 @@ export const PhotoTile = ({ photo }: PhotoTileProps) => { }); const exifLine = formatExifLine(photo); - const gearLine = formatGearLine(photo); - const hasOverlay = exifLine || gearLine; + const lensName = photo.exif.lensDisplayName; + const hasOverlay = exifLine || lensName; return ( { {...hover.handlers} > - Photograph without description (sorry) {hasOverlay && ( -
- {exifLine &&

{exifLine}

} - {gearLine &&

{gearLine}

} +
+ {lensName &&

{lensName}

} + {exifLine &&

{exifLine}

}
)}
diff --git a/src/app/sections/photography/photography-client.tsx b/src/app/sections/photography/photography-client.tsx index f0c6eeb..815c5cb 100644 --- a/src/app/sections/photography/photography-client.tsx +++ b/src/app/sections/photography/photography-client.tsx @@ -1,10 +1,31 @@ "use client"; +import { m } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import useSWRInfinite from "swr/infinite"; +import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover"; import { PhotoTile } from "./photo-tile"; import type { PhotoItem } from "./types"; +type PhotographyClientProps = { + apiUrl: string; +}; + +const LG_BREAKPOINT = 1024; +const GAP_DESKTOP = 16; +const GAP_MOBILE = 8; +const MAX_COLUMN_WIDTH = 400; +const MAX_PAGES = 2; + +const getGap = (width: number) => (width >= LG_BREAKPOINT ? GAP_DESKTOP : GAP_MOBILE); + +const MIN_COLUMNS = 3; + +function computeColumnCount(containerWidth: number, gap: number): number { + if (containerWidth <= 0) return MIN_COLUMNS; + return Math.max(MIN_COLUMNS, Math.ceil((containerWidth + gap) / (MAX_COLUMN_WIDTH + gap))); +} + type PhotosResponse = { photos: PhotoItem[]; hasMore: boolean; @@ -19,15 +40,6 @@ const fetcher = async (url: string): Promise => { return res.json(); }; -const GAP = 16; - -const getColumnCount = (width: number) => { - if (width >= 1024) return 4; - if (width >= 768) return 3; - if (width >= 640) return 2; - return 1; -}; - type Column = { photos: PhotoItem[]; height: number; @@ -43,6 +55,7 @@ function assignPhotosToColumns( photos: PhotoItem[], columnCount: number, columnWidth: number, + gap: number, ): ColumnCache { const needsReset = !cache || cache.columns.length !== columnCount; @@ -57,11 +70,11 @@ function assignPhotosToColumns( if (photo.width <= 0 || photo.height <= 0) continue; const shortest = columns.reduce((min, col) => (col.height < min.height ? col : min)); shortest.photos.push(photo); - shortest.height += (photo.height / photo.width) * columnWidth + GAP; + shortest.height += (photo.height / photo.width) * columnWidth + gap; } } - return { columns, photoCount: photos.length }; + return { columns, photoCount: columnWidth > 0 ? photos.length : 0 }; } if (photos.length > cache.photoCount) { @@ -71,7 +84,7 @@ function assignPhotosToColumns( if (photo.width <= 0 || photo.height <= 0) continue; const shortest = columns.reduce((min, col) => (col.height < min.height ? col : min)); shortest.photos.push(photo); - shortest.height += (photo.height / photo.width) * columnWidth + GAP; + shortest.height += (photo.height / photo.width) * columnWidth + gap; } return { columns, photoCount: photos.length }; @@ -80,42 +93,53 @@ function assignPhotosToColumns( return cache; } -export const PhotographyClient = () => { +export const PhotographyClient = ({ apiUrl }: PhotographyClientProps) => { const gridRef = useRef(null); const loadMoreRef = useRef(null); - const [columnCount, setColumnCount] = useState(4); + const [columnCount, setColumnCount] = useState(MIN_COLUMNS); const [columnWidth, setColumnWidth] = useState(0); + const [gap, setGap] = useState(GAP_DESKTOP); const cacheRef = useRef(null); + const ctaHover = useMagneticSpringHover({ + magnetStrength: 0.2, + scaleAmount: 1.04, + shadowElevation: 16, + }); + const seed = useMemo(() => Math.random().toString(36).slice(2), []); const { data, size, setSize, isValidating } = useSWRInfinite( (pageIndex, previousPageData) => { if (previousPageData && !previousPageData.hasMore) return null; - return `/api/photos?seed=${seed}&page=${pageIndex + 1}`; + return `${apiUrl}/api/export?seed=${seed}&page=${pageIndex}`; }, fetcher, - { revalidateOnFocus: false, revalidateOnReconnect: false }, + { revalidateFirstPage: false, revalidateOnFocus: false, revalidateOnReconnect: false }, ); const photos = data?.flatMap((page) => page.photos) ?? []; - const hasMore = data?.[data.length - 1]?.hasMore ?? true; + const upstreamHasMore = data?.[data.length - 1]?.hasMore ?? true; + const reachedCap = (data?.length ?? 0) >= MAX_PAGES; + const autoLoad = upstreamHasMore && !reachedCap; + const columns = useMemo(() => { - const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth); + const result = assignPhotosToColumns(cacheRef.current, photos, columnCount, columnWidth, gap); cacheRef.current = result; return result.columns; - }, [photos, columnCount, columnWidth]); + }, [photos, columnCount, columnWidth, gap]); useEffect(() => { if (!gridRef.current) return; const measure = () => { if (!gridRef.current) return; - const count = getColumnCount(window.innerWidth); - setColumnCount(count); const gridWidth = gridRef.current.offsetWidth; - const width = (gridWidth - (count - 1) * GAP) / count; - setColumnWidth(width); + const g = getGap(window.innerWidth); + const count = computeColumnCount(gridWidth, g); + setGap(g); + setColumnCount(count); + setColumnWidth((gridWidth - (count - 1) * g) / count); }; const observer = new ResizeObserver(measure); @@ -124,11 +148,11 @@ export const PhotographyClient = () => { }, []); useEffect(() => { - if (!loadMoreRef.current) return; + if (!loadMoreRef.current || !autoLoad) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMore && !isValidating) { + if (entries[0].isIntersecting && !isValidating) { setSize(size + 1); } }, @@ -137,23 +161,39 @@ export const PhotographyClient = () => { observer.observe(loadMoreRef.current); return () => observer.disconnect(); - }, [hasMore, isValidating, size, setSize]); + }, [autoLoad, isValidating, size, setSize]); return ( -
- {columns.map((column, colIdx) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: columns are positional by nature -
- {column.photos.map((photo) => ( - - ))} + <> +
+ {columns.map((column, colIdx) => ( +
+ {column.photos.map((photo) => ( + + ))} +
+ ))} + {autoLoad &&
} +
+ {reachedCap && upstreamHasMore && ( +
+ + See the rest of the portfolio +
- ))} -
-
+ )} + ); }; diff --git a/src/app/sections/photography/types.ts b/src/app/sections/photography/types.ts index 8eac472..f37b703 100644 --- a/src/app/sections/photography/types.ts +++ b/src/app/sections/photography/types.ts @@ -2,14 +2,12 @@ export type PhotoItem = { id: string; width: number; height: number; - ext: string; - rating: number; + eloVerified: number; exif: { - fNumber: number | null; - exposureTime: string | null; - iso: number | null; + lensDisplayName: string | null; focalLength: number | null; - model: string | null; - lensModel: string | null; + fNumber: number | null; + exposureTime: number | null; + iso: number | null; }; }; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts deleted file mode 100644 index e579851..0000000 --- a/src/lib/rate-limit.ts +++ /dev/null @@ -1,72 +0,0 @@ -import ipaddr from "ipaddr.js"; - -type RateLimitEntry = { - count: number; - resetAt: number; -}; - -const store = new Map(); - -const CLEANUP_INTERVAL = 60_000; -let lastCleanup = Date.now(); - -function cleanup() { - const now = Date.now(); - if (now - lastCleanup < CLEANUP_INTERVAL) return; - lastCleanup = now; - - for (const [key, entry] of store) { - if (entry.resetAt < now) { - store.delete(key); - } - } -} - -function isPrivateIp(ip: string): boolean { - try { - const addr = ipaddr.process(ip); - const range = addr.range(); - return range === "private" || range === "loopback" || range === "linkLocal"; - } catch { - return false; - } -} - -function getClientIp(request: Request): string { - const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0].trim(); - } - return "unknown"; -} - -export function rateLimit( - request: Request, - key: string, - limit: number, - windowMs: number, -): { allowed: boolean; remaining: number } { - const ip = getClientIp(request); - - if (isPrivateIp(ip)) { - return { allowed: true, remaining: limit }; - } - - cleanup(); - - const fullKey = `${key}:${ip}`; - const now = Date.now(); - const entry = store.get(fullKey); - - if (!entry || entry.resetAt < now) { - store.set(fullKey, { count: 1, resetAt: now + windowMs }); - return { allowed: true, remaining: limit - 1 }; - } - - if (entry.count >= limit) { - return { allowed: false, remaining: 0 }; - } - - entry.count++; - return { allowed: true, remaining: limit - entry.count }; -}