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
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.idea/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
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
|
# 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
|
## 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
|
```bash
|
||||||
# 1. Configuration (set ADMIN_PASSWORD to taste)
|
# 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 |
|
| `/posts/[slug]` | One published post |
|
||||||
| `/tags/[slug]` | Published posts with that tag, paginated |
|
| `/tags/[slug]` | Published posts with that tag, paginated |
|
||||||
| `/pages/[slug]` | One published static page |
|
| `/pages/[slug]` | One published static page |
|
||||||
|
| `/feed.xml`, `/sitemap.xml`, `/robots.txt` | RSS feed and crawler metadata |
|
||||||
| `/admin` | Dashboard (auth required) |
|
| `/admin` | Dashboard (auth required) |
|
||||||
| `/admin/login` | Sign in |
|
| `/admin/login` | Sign in |
|
||||||
| `/admin/posts`, `/admin/posts/new`, `/admin/posts/[id]/edit`, `/admin/posts/[id]/preview` | Post management |
|
| `/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/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
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -89,7 +93,7 @@ src/
|
||||||
|
|
||||||
### Key decisions
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
## 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.
|
- **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.
|
- **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).
|
- **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
|
```bash
|
||||||
|
|
@ -125,13 +129,27 @@ npm test # unit + integration (~7s)
|
||||||
npm run test:e2e # build + 3 E2E scenarios (~1 min)
|
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)
|
## 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
|
## 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).
|
- 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.
|
- 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.
|
- 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
|
// Pin the workspace root so stray lockfiles in parent directories
|
||||||
// don't confuse Turbopack's project detection.
|
// don't confuse Turbopack's project detection.
|
||||||
turbopack: { root: __dirname },
|
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: {
|
experimental: {
|
||||||
// Site-import uploads carry a whole backup in one action request;
|
// Site-import uploads carry a whole backup in one action request;
|
||||||
// the default 1 MB cap is far too small. Imports themselves are
|
// the default 1 MB cap is far too small. Imports themselves are
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { users } from "@/db/schema";
|
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 { createSession, deleteExpiredSessions, deleteSession } from "@/lib/auth/session";
|
||||||
import type { FormState } from "@/lib/forms";
|
import type { FormState } from "@/lib/forms";
|
||||||
import { zodErrorToFormState } from "@/lib/forms";
|
import { zodErrorToFormState } from "@/lib/forms";
|
||||||
|
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
|
||||||
import { loginFormSchema } from "@/lib/validation";
|
import { loginFormSchema } from "@/lib/validation";
|
||||||
|
|
||||||
const GENERIC_LOGIN_ERROR = "Invalid username or password.";
|
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
|
// Verified against when the username doesn't exist, so both failure paths
|
||||||
// cost one scrypt derivation (no username-probing timing signal).
|
// cost one scrypt derivation (no username-probing timing signal).
|
||||||
let dummyHashPromise: Promise<string> | null = null;
|
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);
|
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;
|
let ok = false;
|
||||||
try {
|
try {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
|
|
@ -45,6 +57,7 @@ export async function loginAction(_prev: FormState, formData: FormData): Promise
|
||||||
ok = passwordOk && user !== undefined;
|
ok = passwordOk && user !== undefined;
|
||||||
|
|
||||||
if (ok && user) {
|
if (ok && user) {
|
||||||
|
loginLimiter.reset(clientKey);
|
||||||
await deleteExpiredSessions();
|
await deleteExpiredSessions();
|
||||||
const { token, expiresAt } = await createSession(user.id);
|
const { token, expiresAt } = await createSession(user.id);
|
||||||
await setSessionCookie(token, expiresAt);
|
await setSessionCookie(token, expiresAt);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||||
import type { FormState } from "@/lib/forms";
|
import type { FormState } from "@/lib/forms";
|
||||||
import { zodErrorToFormState } from "@/lib/forms";
|
import { zodErrorToFormState } from "@/lib/forms";
|
||||||
|
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
|
@ -13,6 +15,10 @@ import {
|
||||||
} from "@/lib/services/comments";
|
} from "@/lib/services/comments";
|
||||||
import { commentFormSchema } from "@/lib/validation";
|
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
|
* The one unauthenticated mutation in the app. Safe because the result is
|
||||||
* always a pending comment — nothing shows publicly until an admin approves
|
* always a pending comment — nothing shows publicly until an admin approves
|
||||||
|
|
@ -30,6 +36,12 @@ export async function submitCommentAction(
|
||||||
return { status: "success" };
|
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({
|
const parsed = commentFormSchema.safeParse({
|
||||||
postId: formData.get("postId"),
|
postId: formData.get("postId"),
|
||||||
parentId: formData.get("parentId"),
|
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