yap-blog/README.md

159 lines
17 KiB
Markdown

# Yap Blog — a small self-hosted blogging platform
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** 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)
cp .env.example .env
# 2. Database
docker compose up -d
# 3. Dependencies
npm install
# 4. Schema + demo content (admin credentials come from .env)
npm run db:migrate
npm run db:seed
# 5. Go
npm run dev
```
Open <http://localhost:3000> for the public site and <http://localhost:3000/admin> for the admin area, signing in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from your `.env`.
> The seed is idempotent: the admin password hash is refreshed on every run (re-run it after changing `ADMIN_PASSWORD`), site settings are created only if missing, and demo content is only inserted into an empty database. It creates ~51 published posts (7 handwritten showcases plus generated topic filler so listings paginate to ~11 pages), 2 drafts, 9 tags, and 3 static pages.
## Commands
| Command | Purpose |
| --- | --- |
| `npm run dev` | Development server on :3000 |
| `npm run build` | Production build |
| `npm start` | Serve the production build |
| `npm run lint` | ESLint |
| `npm run typecheck` | TypeScript compiler, no emit |
| `npm test` | Unit + integration tests (Vitest, uses the `blog_test` DB) |
| `npm run test:watch` | Vitest in watch mode |
| `npm run test:e2e` | Production build, then Playwright E2E (uses the `blog_e2e` DB) |
| `npm run test:all` | Both suites |
| `npm run db:generate` | Generate a new migration from schema changes |
| `npm run db:migrate` | Apply migrations |
| `npm run db:seed` | Seed admin, settings, and demo content |
| `npm run db:studio` | Drizzle Studio DB browser |
The `blog_test` and `blog_e2e` databases are created automatically the first time the Postgres volume initializes (`docker/initdb/`). Both are **wiped and re-migrated on every test run** — never point them at data you care about. If you created the volume with an older setup, recreate it with `docker compose down -v && docker compose up -d`.
## Routes
| Route | Purpose |
| --- | --- |
| `/` | Configured home page: all posts, one tag's posts, or a static page |
| `/posts` | All published posts, newest first, paginated (`?page=N`) |
| `/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/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
```
src/
├── app/ # Routes only — thin, no business logic
│ ├── (public)/ # Public site, wrapped in header/sidebar/footer chrome
│ │ ├── (home)/ # / with its own loading skeleton
│ │ └── posts/(list)/ # /posts with its own loading skeleton
│ ├── admin/login/ # Login (outside the guarded group)
│ └── admin/(panel)/ # Guarded admin area (layout + every page re-check auth)
├── actions/ # Server actions: auth, posts, pages, settings, preview
├── components/ # public/ and admin/ UI + shared primitives (ui.tsx)
├── db/ # Drizzle schema + connection pool
├── lib/
│ ├── auth/ # scrypt hashing, session store, cookie, DAL guards
│ ├── services/ # All database access (posts, tags, pages, settings, home)
│ └── … # slug, markdown, excerpt, pagination, validation, forms
├── drizzle/ # Generated SQL migrations (committed)
├── scripts/seed.ts # Idempotent seeding (also reused by the E2E setup)
└── tests/ # unit/ · integration/ · e2e/
```
### Key decisions
- **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.
- **Protection is server-side at three layers**: the admin layout redirects, every admin page calls `requireAdmin()` (React-`cache()`d per request), and every mutating server action calls it again — an action invoked directly over HTTP without a valid session cookie redirects instead of mutating. There is deliberately no middleware-only check to rely on.
- **Rich text editing, HTML storage.** Posts and pages are written in a WordPress-style WYSIWYG editor (Tiptap/ProseMirror): headings, bold/italic/underline/strike, inline code, links, lists, blockquotes, code blocks, tables with row/column controls, horizontal rules, undo/redo — plus inline images uploaded straight from the editor (toolbar button, drag-drop, or paste). Pasting plain-text **Markdown converts automatically** through the same remark pipeline used for seeding; pasting rich HTML uses ProseMirror's native handling. Bodies are stored as HTML and pass through one shared `rehype-sanitize` allowlist (GitHub schema + `<u>`/table scaffolding) **both on save and on render** — scripts, event handlers, and `javascript:` URLs cannot survive either path, and the editor is WYSIWYG against the same `.markdown-body` styles the public site uses, so what you see is literally what publishes.
- **Slug policy.** Slugs are generated from titles (NFKD-normalized, diacritics stripped, hyphenated). A *generated* slug that collides is auto-suffixed (`-2`, `-3`, …); an *explicitly chosen* slug that collides is rejected with a field error — the author picked it, silently renaming it would surprise them. Uniqueness is also enforced by DB constraints, so a race between two requests ends in a caught constraint error, not a duplicate.
- **Deletion safety is in the schema.** `settings.home_tag_id` / `home_page_id` are `ON DELETE SET NULL` and nav items are `ON DELETE CASCADE` from their page, so deleting a tag or page featured on the home page or navigation degrades gracefully (home falls back to the post list; the nav item disappears). Unpublishing a page hides its nav items until it is republished. A `CHECK` constraint guarantees a nav item points at exactly one of URL/page.
- **HTTP 404 vs. streaming.** Slug routes (`/posts/[slug]`, `/tags/[slug]`, `/pages/[slug]`) have **no** loading boundary so unknown or draft content returns a real HTTP 404. The two listing routes have loading skeletons, which means an out-of-range `?page=` there streams a not-found UI with HTTP 200 plus a `noindex` meta tag (a "soft 404") — the deliberate trade-off for skeletons on the routes users actually wait on. Malformed pagination (`?page=abc`, `-1`, `1e9`) clamps to page 1.
- **Theming.** `globals.css` maps raw palette values to semantic tokens (`--background`, `--ink`, `--link`, …) and exposes those to Tailwind via `@theme inline`; components only ever use semantic utilities (`bg-surface`, `text-ink-strong`). The admin **Settings → Appearance** section switches the whole site (public + admin) between fifteen themes by stamping `data-theme` on `<html>`; each theme is nothing but a token override block. Dark: **Solarized Dark** (default), **Dracula**, **Nord**, **Gruvbox Dark**, **Catppuccin Mocha**, **Tokyo Night**, **One Dark**, **Rosé Pine**, **Everforest Dark**, **Monokai**, **White on Black**. Light: **Solarized Light**, **Catppuccin Latte**, **GitHub Light**, **Black & White**. The two mono themes are deliberately pure grayscale. Selection color, blockquote borders, badges, and the browser `theme-color` all derive from tokens, so new themes need no component work. Adding a theme = one CSS block, one enum value in `src/db/schema.ts` (+ generated migration), and one entry in the `src/lib/themes.ts` registry — the `Record<Theme, …>` type makes a missing entry a compile error.
- **Fonts.** The admin also picks a site-wide body font: sans — **Geist** (default), **Inter**, **Open Sans**, **Work Sans**, **Space Grotesk**, **Atkinson Hyperlegible**; serif — **Lora**, **Merriweather**, **Source Serif 4**, **EB Garamond**, **Playfair Display**; mono — **JetBrains Mono**. All are self-hosted via `next/font` (downloaded once at build time, no runtime Google requests). Only Geist is preloaded; the rest are declared `@font-face` rules the browser fetches solely when `data-font` on `<html>` makes one active. Tailwind's `font-sans` resolves through `--font-body`, which each `[data-font="…"]` block remaps. Code blocks always stay in Geist Mono. The settings form previews each option in its actual typeface.
- **Accessibility.** Semantic landmarks, labelled navs, a skip-to-content link, visible `:focus-visible` rings, `aria-invalid`/`aria-describedby` wiring on form errors, an `Escape`-closable mobile menu, and alt-text support (with an explicit "decorative" convention) on featured images. Destructive admin actions confirm before submitting.
### Images
**Inline images** are uploaded from the editor to `POST /api/admin/uploads` (auth-required, 8 MB cap, MIME allowlist: PNG/JPEG/WebP/GIF/AVIF — SVG deliberately excluded). Files land in `./uploads/` (gitignored) under random UUID names — client filenames never touch the filesystem — and are served by the `GET /uploads/[name]` route handler with immutable cache headers (Next only serves `public/` files that existed at build time, hence the route). The filename pattern is validated on read, ruling out path traversal. Alt text is editable per image via the toolbar's **Alt** button when an image is selected.
**Featured images** accept either a pasted URL or the same upload flow via the Upload button next to the field. Rendering uses a plain `<img loading="lazy">` with an error fallback ("image unavailable") instead of `next/image`, because arbitrary admin-supplied URLs would require a wildcard `remotePatterns`, which turns the image optimizer into an open proxy.
Moving to object storage later: point `saveUploadedImage` (src/lib/uploads.ts) at S3/R2/MinIO instead of the local directory and return the bucket URL — the endpoint, editor, and schema stay unchanged. Restricting `next/image` to that bucket's hostname would then restore image optimization.
### Error handling
Public and admin groups have scoped `not-found.tsx`; `error.tsx` shows a generic retry card (details stay in server logs, correlated by digest); `global-error.tsx` catches root-layout failures (e.g. DB down) with a self-contained page. Server actions return typed field/form errors — constraint violations and unexpected exceptions surface as friendly messages, never stack traces or connection strings. `generateMetadata` failures fall back to defaults rather than crashing the page.
## 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, 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
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, search, analytics, and email are intentionally omitted.
## Known limitations
- 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.
- Out-of-range pagination on listing routes is a soft 404 (real 404s everywhere else).
- `window.confirm` guards destructive actions and quick link/alt prompts; styled dialogs would be nicer.
- Bodies are stored as editor HTML. Content from databases seeded before this change (markdown source) renders as plain text — reseed demo databases rather than migrating them.