Add deployment setup and request rate limiting
This commit is contained in:
parent
11a7d6d659
commit
7aac1d5e51
28
.dockerignore
Normal file
28
.dockerignore
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Keep the build context lean and secrets out of the image. Next.js would
|
||||
# happily read a baked-in .env at runtime — configuration must come from
|
||||
# --env-file / -e instead.
|
||||
.env*
|
||||
!.env.example
|
||||
env
|
||||
|
||||
node_modules
|
||||
.next
|
||||
uploads
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.claude
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker
|
||||
deploy
|
||||
README.md
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,6 +23,7 @@
|
|||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
|||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Production image for the blog. Build and run:
|
||||
#
|
||||
# docker build -t yap-blog .
|
||||
# docker run -d --name yap-blog -p 3000:3000 --env-file .env \
|
||||
# -v yap-blog-uploads:/app/uploads yap-blog
|
||||
#
|
||||
# Migrations and seeding are NOT part of the runtime image (they need dev
|
||||
# dependencies); run them from the `tools` stage against the same database:
|
||||
#
|
||||
# docker build --target tools -t yap-blog-tools .
|
||||
# docker run --rm --env-file .env yap-blog-tools npm run db:migrate
|
||||
# docker run --rm --env-file .env yap-blog-tools npm run db:seed
|
||||
#
|
||||
# In .env, DATABASE_URL must be reachable FROM INSIDE the container —
|
||||
# `localhost` there means the container itself, not the host.
|
||||
|
||||
# Node 24 to match development; its npm 11 is also what wrote
|
||||
# package-lock.json (npm 10's `npm ci` rejects npm 11 lockfile layouts).
|
||||
FROM node:24-alpine AS base
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Full source + dev dependencies: the build environment, also reused as the
|
||||
# `tools` stage for drizzle-kit migrations and the seed script.
|
||||
FROM deps AS tools
|
||||
COPY . .
|
||||
|
||||
FROM tools AS builder
|
||||
# The build needs no database — every route renders dynamically at request
|
||||
# time — but importing src/db fail-fasts when DATABASE_URL is unset, so give
|
||||
# it a placeholder. The pool connects lazily; nothing dials this address.
|
||||
RUN NEXT_OUTPUT=standalone \
|
||||
DATABASE_URL=postgresql://build:build@localhost:5432/placeholder \
|
||||
npm run build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
# Pre-create the uploads dir writable so a fresh named volume inherits
|
||||
# ownership that the non-root server can write to.
|
||||
RUN mkdir uploads && chown nextjs:nodejs uploads
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
VOLUME /app/uploads
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD wget -qO /dev/null http://127.0.0.1:3000/ || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
34
README.md
34
README.md
|
|
@ -1,12 +1,12 @@
|
|||
# Yap Blog — a small self-hosted blogging platform
|
||||
|
||||
A single-admin blogging platform built with **Next.js (App Router) + React + TypeScript**, backed by **PostgreSQL** via **Drizzle ORM**. Ships with fifteen switchable themes and twelve selectable body fonts, all configured from the admin area. Posts and static pages are written in Markdown, organized with tags, and managed through a session-authenticated admin area.
|
||||
A self-hosted blogging platform built with **Next.js (App Router) + React + TypeScript**, backed by **PostgreSQL** via **Drizzle ORM**. One admin plus optional author accounts with granular permissions, a WYSIWYG editor with Markdown paste and typing shortcuts, moderated threaded comments, an RSS feed + sitemap + SEO metadata, JSON backup import/export, fifteen switchable themes, and twelve selectable body fonts — all managed from a session-authenticated admin area.
|
||||
|
||||
  
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: **Node.js ≥ 20**, **Docker** with the compose plugin, and free local ports **5434** (Postgres) and **3000** (dev server) — both configurable.
|
||||
Requirements: **Node.js ≥ 20** with **npm 11** (`package-lock.json` is written by npm 11, and npm 10's `npm ci` rejects its layout — Node 24 bundles the right npm), **Docker** with the compose plugin, and free local ports **5434** (Postgres) and **3000** (dev server) — both configurable.
|
||||
|
||||
```bash
|
||||
# 1. Configuration (set ADMIN_PASSWORD to taste)
|
||||
|
|
@ -59,11 +59,15 @@ The `blog_test` and `blog_e2e` databases are created automatically the first tim
|
|||
| `/posts/[slug]` | One published post |
|
||||
| `/tags/[slug]` | Published posts with that tag, paginated |
|
||||
| `/pages/[slug]` | One published static page |
|
||||
| `/feed.xml`, `/sitemap.xml`, `/robots.txt` | RSS feed and crawler metadata |
|
||||
| `/admin` | Dashboard (auth required) |
|
||||
| `/admin/login` | Sign in |
|
||||
| `/admin/posts`, `/admin/posts/new`, `/admin/posts/[id]/edit`, `/admin/posts/[id]/preview` | Post management |
|
||||
| `/admin/pages`, … | Static-page management (same shape as posts) |
|
||||
| `/admin/settings` | Site title, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits |
|
||||
| `/admin/comments` | Comment moderation queue |
|
||||
| `/admin/users`, `/admin/users/new`, `/admin/users/[id]/edit` | Author accounts, per-tag posting rights, permissions (admin only) |
|
||||
| `/admin/account` | Change your own password |
|
||||
| `/admin/settings` | Site title & URL, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits, import/export |
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -89,7 +93,7 @@ src/
|
|||
|
||||
### Key decisions
|
||||
|
||||
- **Server components + server actions, no API layer.** Public pages are React Server Components that call the service layer directly; admin mutations are server actions. There are no JSON route handlers because nothing consumes them — one less surface to validate and keep in sync. Route handlers can be added later for an RSS feed or a public API without touching the services.
|
||||
- **Server components + server actions, almost no API layer.** Public pages are React Server Components that call the service layer directly; admin mutations are server actions. The only route handlers are the ones a browser actually consumes as URLs — the RSS feed, sitemap/robots, image upload + serving, and the backup export download — and they sit on the same service layer as everything else.
|
||||
- **A service layer owns all SQL.** Files under `src/lib/services/` are the only place queries live. Pages and actions stay thin, and the integration tests exercise the exact code paths production uses.
|
||||
- **Everything renders dynamically** (`force-dynamic` in the root layout). All content is admin-editable at runtime, so pages read the DB per request — plenty fast for an MVP and never stale. The obvious next optimization is tag-based caching (`revalidateTag`) around settings/posts.
|
||||
- **Auth: opaque session tokens, scrypt passwords.** Login verifies against a scrypt hash (Node's built-in crypto; parameters encoded per-hash so they can be raised later). Sessions are 32-byte random tokens in an `httpOnly` `SameSite=Lax` cookie; the database stores only the SHA-256 of the token, so a leaked DB dump cannot forge cookies. Expired sessions are treated as absent and purged on login. A failed login costs one scrypt derivation whether or not the username exists, avoiding a user-enumeration timing signal.
|
||||
|
|
@ -116,8 +120,8 @@ Public and admin groups have scoped `not-found.tsx`; `error.tsx` shows a generic
|
|||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`tests/unit/`): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, `javascript:` URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation.
|
||||
- **Integration** (`tests/integration/`, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish `publishedAt` semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle.
|
||||
- **Unit** (`tests/unit/`): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, `javascript:` URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation, rate limiting (window rollover, per-key isolation, memory bound).
|
||||
- **Integration** (`tests/integration/`, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish `publishedAt` semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle, comment threading/moderation, author permissions, import/export round-trips.
|
||||
- **E2E** (`tests/e2e/`, Playwright against a production build): admin routes redirect anonymously; bad credentials rejected; a full editorial flow — login → compose in the rich editor (heading + bold via toolbar) → publish → public listing/post/tag pages → draft 404s → logout locks the admin again; an editor-capabilities flow — markdown paste conversion, `<script>` stripped from pasted content, inline image upload through the toolbar, the uploaded file actually served, and the upload endpoint returning 401 anonymously; and an appearance flow (themes + fonts asserted via `data-*` attributes and computed styles).
|
||||
|
||||
```bash
|
||||
|
|
@ -125,13 +129,27 @@ npm test # unit + integration (~7s)
|
|||
npm run test:e2e # build + 3 E2E scenarios (~1 min)
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
The app is a standard Next.js server (`npm run build` + `npm start`) plus PostgreSQL — a small VPS runs both. Checklist for going live:
|
||||
|
||||
1. **Postgres.** Point `DATABASE_URL` at a production database. The `blog`/`blog` credentials in `docker-compose.yml` are for local development — if you reuse the compose file on a server, change the password (and don't publish the port beyond localhost).
|
||||
2. **Seed.** Set a strong `ADMIN_PASSWORD` in `.env`, then `npm run db:migrate && npm run db:seed`. Passwords can be changed later from **Admin → Account**.
|
||||
3. **Run.** Two ready-made options:
|
||||
- **systemd** — `deploy/yap-blog.service` runs `npm start` as a dedicated locked-down user; setup commands are in the unit file's header comment.
|
||||
- **Docker** — the `Dockerfile` builds a self-contained standalone image (non-root, uploads on a named volume, healthcheck). Build/run/migrate commands are in its header comment; migrations run from the `tools` build stage, since the runtime image has no dev dependencies.
|
||||
4. **Reverse proxy + HTTPS.** Serve behind nginx/Caddy/Traefik with TLS — the session cookie is `Secure` in production, so plain HTTP logins will not stick. Make sure the proxy sets `X-Forwarded-For`; the login and comment rate limits key on it.
|
||||
5. **Site URL.** Set **Admin → Settings → Site URL** (or the `SITE_URL` env var) so canonical URLs, the RSS feed, and the sitemap carry your real domain instead of localhost.
|
||||
6. **Backups.** Back up Postgres and the `./uploads/` directory (inline images live there). The JSON export on the settings page covers content and settings, but not uploaded files.
|
||||
|
||||
## Out of scope (by design)
|
||||
|
||||
Public registration, roles, comments, search, RSS, analytics, email, and deployment config are intentionally omitted.
|
||||
Public registration, search, analytics, and email are intentionally omitted.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- One administrator; credentials rotate via `.env` + re-seed.
|
||||
- Accounts are admin-created; there is no self-service password reset (the admin resets author passwords, and the admin password itself rotates via `.env` + re-seed).
|
||||
- Login and comment rate limits are in-memory and per-IP: they assume a single app instance and a reverse proxy that sets `X-Forwarded-For` (exposed directly, all traffic shares one bucket).
|
||||
- Every request hits the database (no caching layer yet — see the caching note above).
|
||||
- Images are unoptimized `<img>` tags by design (see Images); uploads live on local disk, so a multi-instance deployment needs the object-storage swap described above.
|
||||
- Upload validation trusts the declared MIME type (plus a strict extension map and SVG exclusion); magic-byte sniffing would be the next hardening step.
|
||||
|
|
|
|||
51
deploy/yap-blog.service
Normal file
51
deploy/yap-blog.service
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# systemd unit for running the blog directly on a server (no Docker).
|
||||
#
|
||||
# Setup, assuming the checkout lives at /opt/yap-blog:
|
||||
#
|
||||
# sudo useradd --system --home-dir /opt/yap-blog --shell /usr/sbin/nologin yap-blog
|
||||
# cd /opt/yap-blog
|
||||
# npm ci && npm run build # .env must hold the production DATABASE_URL
|
||||
# npm run db:migrate && npm run db:seed
|
||||
# sudo chown -R yap-blog:yap-blog /opt/yap-blog
|
||||
# sudo cp deploy/yap-blog.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now yap-blog
|
||||
#
|
||||
# After deploying new code: npm ci && npm run build && npm run db:migrate,
|
||||
# then `sudo systemctl restart yap-blog`. Adjust the npm path in ExecStart
|
||||
# if `which npm` says something else (e.g. a nodesource or nvm install).
|
||||
|
||||
[Unit]
|
||||
Description=Yap Blog (Next.js)
|
||||
Wants=network-online.target
|
||||
After=network-online.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=yap-blog
|
||||
Group=yap-blog
|
||||
WorkingDirectory=/opt/yap-blog
|
||||
# `next start` runs in production mode and reads .env from the working
|
||||
# directory. To keep secrets outside the checkout instead, delete .env and
|
||||
# uncomment:
|
||||
# EnvironmentFile=/etc/yap-blog/env
|
||||
ExecStart=/usr/bin/npm start
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
# The filesystem is read-only to the service except where it writes:
|
||||
# uploaded images, and .next (Next.js keeps runtime caches/traces there).
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/opt/yap-blog/uploads /opt/yap-blog/.next
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -4,6 +4,10 @@ const nextConfig: NextConfig = {
|
|||
// Pin the workspace root so stray lockfiles in parent directories
|
||||
// don't confuse Turbopack's project detection.
|
||||
turbopack: { root: __dirname },
|
||||
// The Docker image runs the self-contained .next/standalone server.
|
||||
// Gated behind an env var because `next start` (systemd/local) refuses
|
||||
// to run a build produced with output: "standalone".
|
||||
...(process.env.NEXT_OUTPUT === "standalone" ? { output: "standalone" as const } : {}),
|
||||
experimental: {
|
||||
// Site-import uploads carry a whole backup in one action request;
|
||||
// the default 1 MB cap is far too small. Imports themselves are
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
|
|
@ -13,10 +14,16 @@ import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
|||
import { createSession, deleteExpiredSessions, deleteSession } from "@/lib/auth/session";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
|
||||
import { loginFormSchema } from "@/lib/validation";
|
||||
|
||||
const GENERIC_LOGIN_ERROR = "Invalid username or password.";
|
||||
|
||||
// Slows credential stuffing and caps how much scrypt work an attacker can
|
||||
// demand. Successful logins reset the counter, so legitimate re-logins
|
||||
// (and repeated dev/E2E runs) never trip it.
|
||||
const loginLimiter = createRateLimiter({ limit: 10, windowMs: 15 * 60 * 1000 });
|
||||
|
||||
// Verified against when the username doesn't exist, so both failure paths
|
||||
// cost one scrypt derivation (no username-probing timing signal).
|
||||
let dummyHashPromise: Promise<string> | null = null;
|
||||
|
|
@ -32,6 +39,11 @@ export async function loginAction(_prev: FormState, formData: FormData): Promise
|
|||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
const clientKey = clientKeyFrom(await headers());
|
||||
if (!loginLimiter.allow(clientKey)) {
|
||||
return { formError: "Too many sign-in attempts. Please wait a few minutes and try again." };
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
const [user] = await db
|
||||
|
|
@ -45,6 +57,7 @@ export async function loginAction(_prev: FormState, formData: FormData): Promise
|
|||
ok = passwordOk && user !== undefined;
|
||||
|
||||
if (ok && user) {
|
||||
loginLimiter.reset(clientKey);
|
||||
await deleteExpiredSessions();
|
||||
const { token, expiresAt } = await createSession(user.id);
|
||||
await setSessionCookie(token, expiresAt);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
|
|
@ -13,6 +15,10 @@ import {
|
|||
} from "@/lib/services/comments";
|
||||
import { commentFormSchema } from "@/lib/validation";
|
||||
|
||||
// The honeypot below catches naive bots; this caps what the ones that skip
|
||||
// it can insert. Generous enough for an enthusiastic human in a thread.
|
||||
const commentLimiter = createRateLimiter({ limit: 5, windowMs: 10 * 60 * 1000 });
|
||||
|
||||
/**
|
||||
* The one unauthenticated mutation in the app. Safe because the result is
|
||||
* always a pending comment — nothing shows publicly until an admin approves
|
||||
|
|
@ -30,6 +36,12 @@ export async function submitCommentAction(
|
|||
return { status: "success" };
|
||||
}
|
||||
|
||||
if (!commentLimiter.allow(clientKeyFrom(await headers()))) {
|
||||
return {
|
||||
formError: "You are commenting too quickly. Please wait a few minutes and try again.",
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = commentFormSchema.safeParse({
|
||||
postId: formData.get("postId"),
|
||||
parentId: formData.get("parentId"),
|
||||
|
|
|
|||
72
src/lib/rate-limit.ts
Normal file
72
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Fixed-window, in-memory rate limiting for the handful of endpoints that
|
||||
* accept anonymous or pre-auth traffic (login, public comment submission).
|
||||
* In-memory is sound here because the app is single-instance by design
|
||||
* (uploads already live on local disk); a multi-instance deployment would
|
||||
* move this to Redis the same way it moves uploads to object storage.
|
||||
*
|
||||
* Deliberately free of next/* imports so it can be exercised directly by
|
||||
* unit tests; reading request headers happens in the calling action.
|
||||
*/
|
||||
|
||||
type WindowEntry = { count: number; windowStart: number };
|
||||
|
||||
export type RateLimiter = {
|
||||
/** Records an attempt and returns false when the key is over the limit. */
|
||||
allow(key: string, now?: number): boolean;
|
||||
/** Forgets a key, e.g. after a successful login. */
|
||||
reset(key: string): void;
|
||||
};
|
||||
|
||||
export function createRateLimiter(options: {
|
||||
/** Attempts allowed per window. */
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
/**
|
||||
* Memory bound: expired entries are swept once the map holds this many
|
||||
* keys. Spoofed X-Forwarded-For values can mint unlimited keys, so the
|
||||
* bound matters; 10k entries is ~1 MB and a sweep clears a whole window.
|
||||
*/
|
||||
maxKeys?: number;
|
||||
}): RateLimiter {
|
||||
const { limit, windowMs, maxKeys = 10_000 } = options;
|
||||
const entries = new Map<string, WindowEntry>();
|
||||
|
||||
function sweepExpired(now: number): void {
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.windowStart >= windowMs) entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allow(key: string, now: number = Date.now()): boolean {
|
||||
const entry = entries.get(key);
|
||||
if (!entry || now - entry.windowStart >= windowMs) {
|
||||
if (!entry && entries.size >= maxKeys) sweepExpired(now);
|
||||
entries.set(key, { count: 1, windowStart: now });
|
||||
return true;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count <= limit;
|
||||
},
|
||||
reset(key: string): void {
|
||||
entries.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort client key from request headers. Behind the reverse proxy a
|
||||
* production deployment should run anyway (see README → Deploying),
|
||||
* X-Forwarded-For's first hop is the real client. Exposed directly, the
|
||||
* header is absent and all traffic shares the "unknown" bucket — coarse,
|
||||
* but the protected actions stay usable and the limiter still bounds cost.
|
||||
*/
|
||||
export function clientKeyFrom(headers: Headers): string {
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
const first = forwarded?.split(",")[0]?.trim();
|
||||
if (first) return first.slice(0, 100);
|
||||
const realIp = headers.get("x-real-ip")?.trim();
|
||||
if (realIp) return realIp.slice(0, 100);
|
||||
return "unknown";
|
||||
}
|
||||
71
tests/unit/rate-limit.test.ts
Normal file
71
tests/unit/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
|
||||
|
||||
describe("createRateLimiter", () => {
|
||||
it("allows up to the limit within a window, then refuses", () => {
|
||||
const limiter = createRateLimiter({ limit: 3, windowMs: 1000 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("a", 1)).toBe(true);
|
||||
expect(limiter.allow("a", 2)).toBe(true);
|
||||
expect(limiter.allow("a", 3)).toBe(false);
|
||||
expect(limiter.allow("a", 999)).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a fresh window once the previous one expires", () => {
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1000 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("a", 500)).toBe(false);
|
||||
expect(limiter.allow("a", 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks keys independently", () => {
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1000 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("a", 1)).toBe(false);
|
||||
expect(limiter.allow("b", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("reset forgets a key's attempts", () => {
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1000 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("a", 1)).toBe(false);
|
||||
limiter.reset("a");
|
||||
expect(limiter.allow("a", 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("sweeps expired entries instead of growing past maxKeys", () => {
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1000, maxKeys: 3 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("b", 0)).toBe(true);
|
||||
expect(limiter.allow("c", 0)).toBe(true);
|
||||
// All three are expired at t=1000; the sweep makes room, and the new
|
||||
// key is not throttled by leftover state.
|
||||
expect(limiter.allow("d", 1000)).toBe(true);
|
||||
expect(limiter.allow("d", 1001)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps live windows intact across a sweep", () => {
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1000, maxKeys: 2 });
|
||||
expect(limiter.allow("a", 0)).toBe(true);
|
||||
expect(limiter.allow("b", 500)).toBe(true);
|
||||
expect(limiter.allow("c", 600)).toBe(true); // sweep drops nothing live
|
||||
expect(limiter.allow("b", 700)).toBe(false); // b's window survived
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientKeyFrom", () => {
|
||||
it("uses the first hop of x-forwarded-for", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": "203.0.113.7, 10.0.0.1" });
|
||||
expect(clientKeyFrom(headers)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip, then to a shared bucket", () => {
|
||||
expect(clientKeyFrom(new Headers({ "x-real-ip": "203.0.113.9" }))).toBe("203.0.113.9");
|
||||
expect(clientKeyFrom(new Headers())).toBe("unknown");
|
||||
});
|
||||
|
||||
it("caps the key length so junk headers cannot bloat the store", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": "x".repeat(500) });
|
||||
expect(clientKeyFrom(headers)).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue