Integrate with rate-my-shots
Build and Deploy / build-deploy (push) Failing after 46s

This commit is contained in:
2026-06-10 13:21:56 +01:00
parent 8285e118fd
commit 154766dc81
12 changed files with 111 additions and 366 deletions
+1 -3
View File
@@ -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
-3
View File
@@ -12,9 +12,6 @@ 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
-6
View File
@@ -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=="],
+1 -3
View File
@@ -7,9 +7,7 @@ services:
ports:
- "${HOST_PORT}:3000"
environment:
IMMICH_API_URL: ${IMMICH_API_URL}
IMMICH_API_KEY: ${IMMICH_API_KEY}
IMMICH_ALBUM_ID: ${IMMICH_ALBUM_ID}
RATE_MY_SHOTS_URL: ${RATE_MY_SHOTS_URL}
DATA_DIR: /app/data
volumes:
- "${DATA_DIR:-./data}:/app/data"
-2
View File
@@ -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",
-115
View File
@@ -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<PhotoItem[]> {
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 });
}
-90
View File
@@ -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<Buffer> {
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,
},
});
}
+4 -1
View File
@@ -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 (
<section id="photography" className="min-h-screen px-8 py-16 md:px-16 lg:px-24">
<div className="mx-auto max-w-6xl space-y-16">
@@ -12,7 +15,7 @@ export const Photography = () => {
</div>
</div>
<div className="mx-auto mt-16 max-w-480">
<PhotographyClient />
<PhotographyClient apiUrl={apiUrl} />
</div>
</section>
);
+20 -24
View File
@@ -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<HTMLDivElement>({
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 (
<m.div
@@ -56,24 +52,24 @@ export const PhotoTile = ({ photo }: PhotoTileProps) => {
{...hover.handlers}
>
<a
href={`/photos/${photo.id}.${photo.ext}`}
href={`${apiUrl}/assets/${photo.id}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer"
>
<Image
src={`/photos/${photo.id}.${photo.ext}`}
alt="Photograph without description (sorry)"
{/* biome-ignore lint/performance/noImgElement: thumbnails are pre-optimized upstream */}
<img
src={`${apiUrl}/img/${photo.id}?size=preview`}
alt=""
width={photo.width}
height={photo.height}
sizes="(max-width: 639px) 100vw, (max-width: 767px) 50vw, (max-width: 1023px) 33vw, 480px"
className="w-full"
loading="lazy"
className="w-full"
/>
{hasOverlay && (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{exifLine && <p className="font-mono text-white/90 text-xs">{exifLine}</p>}
{gearLine && <p className="font-mono text-white/60 text-xs">{gearLine}</p>}
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end gap-1 bg-linear-to-t from-black/70 via-black/20 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{lensName && <p className="font-mono text-white/90 text-xs">{lensName}</p>}
{exifLine && <p className="font-mono text-white/60 text-xs">{exifLine}</p>}
</div>
)}
</a>
@@ -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<PhotosResponse> => {
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<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(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<ColumnCache | null>(null);
const ctaHover = useMagneticSpringHover<HTMLAnchorElement>({
magnetStrength: 0.2,
scaleAmount: 1.04,
shadowElevation: 16,
});
const seed = useMemo(() => Math.random().toString(36).slice(2), []);
const { data, size, setSize, isValidating } = useSWRInfinite<PhotosResponse>(
(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 (
<div
ref={gridRef}
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
style={{ gap: GAP }}
>
{columns.map((column, colIdx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: columns are positional by nature
<div key={colIdx} className="flex flex-col" style={{ gap: GAP }}>
{column.photos.map((photo) => (
<PhotoTile key={photo.id} photo={photo} />
))}
<>
<div
ref={gridRef}
className="grid"
style={{ gap, gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
>
{columns.map((column, colIdx) => (
<div key={`${colIdx}/${columnCount}`} className="flex flex-col" style={{ gap }}>
{column.photos.map((photo) => (
<PhotoTile key={photo.id} photo={photo} apiUrl={apiUrl} />
))}
</div>
))}
{autoLoad && <div ref={loadMoreRef} className="col-span-full h-1" />}
</div>
{reachedCap && upstreamHasMore && (
<div className="mt-8 flex justify-center">
<m.a
ref={ctaHover.ref}
style={ctaHover.style}
{...ctaHover.handlers}
href={`${apiUrl}/gallery`}
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded-lg border border-accent-cyan/40 px-6 py-3 font-body text-accent-cyan text-base transition-colors hover:border-accent-cyan/70 hover:bg-accent-cyan/10"
>
See the rest of the portfolio
</m.a>
</div>
))}
<div ref={loadMoreRef} className="col-span-full h-1" />
</div>
)}
</>
);
};
+5 -7
View File
@@ -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;
};
};
-72
View File
@@ -1,72 +0,0 @@
import ipaddr from "ipaddr.js";
type RateLimitEntry = {
count: number;
resetAt: number;
};
const store = new Map<string, RateLimitEntry>();
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 };
}