141 lines
15 KiB
Markdown
141 lines
15 KiB
Markdown
# 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.
|
|
|
|
  
|
|
|
|
## Quick start
|
|
|
|
Requirements: **Node.js ≥ 20**, **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 |
|
|
| `/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 |
|
|
|
|
## 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, 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.
|
|
- **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.
|
|
- **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.
|
|
- **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)
|
|
```
|
|
|
|
## Out of scope (by design)
|
|
|
|
Public registration, roles, comments, search, RSS, analytics, email, and deployment config are intentionally omitted.
|
|
|
|
## Known limitations
|
|
|
|
- One administrator; credentials rotate via `.env` + re-seed.
|
|
- 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.
|