# Blogspace A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at `example.com` to manage their blog; each blog is served at `.example.com`. Server-rendered HTML, no JavaScript required, works on old browsers and phones. - Posts and page intros are written in **Markdown** (sanitized on save). Images can be inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript off the file is appended on save). - **Announcements**: blog-wide notices (next meeting, this month's book, a closure) shown at the top or bottom of the main content or of a side column; each can be hidden without deleting. - Every blog has **pages** (Home, About, News, …); each page holds posts. - **Layout** tab: the page is five areas — header, left column, main content, right column, footer — each holding modules in any order: blog title, logo, menu, archive (posts by year and month), recent posts, custom HTML, footer text, RSS link, site map. Column widths are percentages; on phones the columns stack under the posts. The **menu** mixes blog pages and outside links. - **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon). - Changes are live immediately: save, then refresh the blog tab. - A **superadmin** creates bloggers, resets passwords, disables or deletes accounts. - The **root domain is itself a blog**, owned by the superadmin and managed like any other. - Images are stored in Postgres; everything is in one database. ## Local development Requirements: Go 1.26+, Docker (for Postgres). ```sh make dev # starts Postgres in Docker, runs the app on :8080 with template hot-reload make seed # (another terminal) demo data: superadmin admin/admin, blogger alice/alicealice ``` Then open: - Root blog (the superadmin's): http://blogspace.localhost:8080 - Dashboard: http://blogspace.localhost:8080/webadmin (log in as `admin` or `alice`). `/webadmin` on any blog (e.g. http://alice.blogspace.localhost:8080/webadmin) redirects here too. - Alice's blog: http://alice.blogspace.localhost:8080 Chrome and Firefox resolve any `*.localhost` name to your machine, so no DNS or `/etc/hosts` changes are needed. If your browser does not, add lines like `127.0.0.1 blogspace.localhost alice.blogspace.localhost` to `/etc/hosts`, or set `BASE_DOMAIN=lvh.me` (a public name that resolves to 127.0.0.1). Port already taken? `make dev ADDR=:8090 PUBLIC_PORT=8090`. Other targets: `make test`, `make db-reset` (wipe dev data), `make build`. In dev mode (`DEV=1`) templates and CSS are read from disk on each request, so edits under `internal/web/templates` and `internal/web/static` show up on reload; Go changes need a restart. ## Configuration (environment) | Variable | Default | Meaning | |---|---|---| | `BASE_DOMAIN` | `blogspace.localhost` | Root domain; blogs are `.BASE_DOMAIN` | | `ADDR` | `:8080` | Listen address | | `PUBLIC_PORT` | — | Appended to generated blog links (dev only; unset behind a proxy on :80/:443) | | `DATABASE_URL` | local dev DSN | Postgres connection string | | `JWT_SECRET` | — | **Required** outside dev; long random string (`openssl rand -hex 32`) | | `SUPERADMIN_USERNAME` / `SUPERADMIN_PASSWORD` | `admin` / — | Created on first start if no superadmin exists | | `MAX_UPLOAD_MB` | `5` | Image upload limit | | `DEV` | `false` | Hot-reload templates, allow missing secrets | Migrations run automatically at startup. ## Deployment (Docker) ```sh cp .env.example .env # edit BASE_DOMAIN, JWT_SECRET, passwords docker compose up --build -d ``` The app listens on `127.0.0.1:8080` (see `APP_PORT`); put a reverse proxy in front that terminates TLS and forwards **both** the root domain and the wildcard with the original `Host` header. DNS needs two records: `A example.com` and `A *.example.com` (or CNAMEs) pointing at the proxy. nginx example: ```nginx server { listen 443 ssl; server_name example.com *.example.com; # wildcard certificate client_max_body_size 8m; # >= MAX_UPLOAD_MB location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Caddy: `example.com, *.example.com { reverse_proxy 127.0.0.1:8080 }` (wildcard certificates need the DNS challenge). Backups: dump the Postgres volume (`docker compose exec db pg_dump -U blogspace blogspace > backup.sql`). Images live in the database, so that one dump is everything. ## Layout ``` cmd/blogspace/ main (serve | seed | migrate) internal/config/ environment → Config internal/db/ pgx pool + goose migrations (embedded SQL) internal/store/ models and queries (users, blogs, pages, posts, images, sections) internal/auth/ bcrypt, JWT cookie sessions, CSRF tokens internal/markdown/ goldmark + bluemonday internal/slug/ title → slug internal/web/ host router, handlers, templates, static CSS, theme ``` ### How requests are routed `Host == BASE_DOMAIN` (or `www.`) → management site (`/webadmin`, `/dashboard`, `/b//…`, `/admin/`) **plus** the root blog's public pages on every other path. `Host == .BASE_DOMAIN` → that blog's public pages (`/`, `/`, `//`, `/feed.xml`, `/media/`), plus `/webadmin`, which redirects to the login page on the base domain. Anything else → 404. The root blog is a normal `blogs` row with subdomain `www`; it is created on first start for the first superadmin and managed at `/b/www/`. Page slugs that would be shadowed by management routes (`webadmin`, `admin`, `b`, …) are rejected for every blog. ### Auth notes Sessions are HS256 JWTs in an `HttpOnly` cookie. The token carries the user's `token_version`; changing a password or disabling a user bumps it, which logs out every existing session. Every POST carries a `_csrf` field derived from the same secret, so old browsers without `SameSite` support are protected too.