Add deployment setup and request rate limiting

This commit is contained in:
matt 2026-07-05 15:45:15 -04:00
parent 11a7d6d659
commit 7aac1d5e51
10 changed files with 332 additions and 8 deletions

28
.dockerignore Normal file
View 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
View file

@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea/
# debug
npm-debug.log*

54
Dockerfile Normal file
View 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"]

View file

@ -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.
![Stack](https://img.shields.io/badge/Next.js-16-blue) ![DB](https://img.shields.io/badge/PostgreSQL-17-blue) ![ORM](https://img.shields.io/badge/Drizzle-0.45-blue)
## 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
View 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

View file

@ -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

View file

@ -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);

View file

@ -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
View 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";
}

View 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);
});
});