diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2417187 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 0ebfa94..028abf8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +.idea/ # debug npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8017bd7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index f682fbc..0ff69cc 100644 --- a/README.md +++ b/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. ![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, `