# 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, page intros and announcements are written in **Markdown** (sanitized on save), in an editor with a formatting toolbar (bold, italic, headings, links, lists, quotes, code…), keyboard shortcuts and a rendered preview — or, with the editor's Format switch, in **raw HTML** that goes on the blog exactly as written (embeds, scripts, inline styles; the blogger's own responsibility, like the custom HTML module). Files of any kind — images, PDFs, archives, audio, fonts… — can be inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript off the file is appended on save): images are shown, everything else becomes a download link. The **Files** tab lists them by kind with search, rename and delete. Uploads are 10 MB per file by default; the superadmin can set a different limit per blog. - **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 — highlighted, as a warning, plain, or as framed "Gizmo" boxes that sit side by side; each can be hidden without deleting. - Every blog has **pages** (Home, About, News, …); each page holds posts, with an optional text before them (an intro) and after them (a site map, a widget). Posts can carry **tags**, shown under the date; each tag has its own page listing every post with it. A post can have a **featured image**: a thumbnail beside it on listings, and the picture above or below the text on its own page. - **Design** tab: one form, one Save, organised by part of the blog — colours & fonts (schemes, background colour/image, text and link colours, fonts — built-in stacks, Fira Code, or your own uploaded WOFF2/WOFF/TTF/OTF files), header, menu, content (dates, summaries, a rule under titles — line, dots, double dots or stripes — and tags shown as #tag), side columns, footer, logo & site icon. 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, tags (with counts), tag cloud, a search box (finds posts by title and text, any case; several words match in order with anything in between), custom HTML, footer text, RSS link, site map. Column widths are percentages of the page, which is the whole screen or a set number of pixels, centred or at the left; on phones the columns stack under the posts; the header and footer can follow the main column's edges for a centred, single-column look, and a rule (line, dots or stripes) can fill the row beside the title or logo. The **menu** mixes blog pages and outside links; its links can be bold, uppercase, underlined always, on hover or never, small or large, tight or wide apart, in any combination; the current page's link is underlined, bold, boxed or left alone, and the header menu sits left, centred, right or spread across the bar. A live preview of the header shows the menu as set, on a wide screen or a phone. Posts per page, the date format, the site title size and the footer alignment live here too, as does whether listings show whole posts or **summaries** with a "Read more" link (a post's summary ends at a `` line, or after its first paragraphs). - Changes are live once saved: save, then refresh the blog tab. Nothing is saved until you press Save, and Discard changes forgets the edits. - **Languages**: English or Greek, chosen per blog on the Settings tab. It switches the whole dashboard and the blog's fixed text — dates, archive months, "RSS feed", the pager, the 404 page. What the blogger writes is never translated. - 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. - **Each blog is its own Postgres database** (`blog_`), uploaded files included, so one `pg_dump` is a complete backup of a blog and one `psql` restores it. A small control database holds the users and the list of blogs. ## 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 | Connection string of the **control** database; blog databases are created next to it by the same role | | `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` | `10` | Per-file upload limit; the superadmin can override it per blog in `/admin/` | | `HTTPS` | `false` | The proxy terminates TLS: generated links are `https://`, the session cookie is `Secure`, HSTS is sent. **Set it in production.** | | `TRUST_PROXY` | `false` | Take the client address from the last `X-Forwarded-For` entry (the one your proxy wrote) for rate limiting and the log. Set it when the app is only reachable through your proxy. | | `DEV` | `false` | Hot-reload templates, allow missing secrets | Migrations run automatically at startup, for the control database and for every blog database. ## 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. Keep `HTTPS=true` and `TRUST_PROXY=true` in `.env` for that setup (the sample has them). DNS needs two records: `A example.com` and `A *.example.com` (or CNAMEs) pointing at the proxy. nginx example: ```nginx limit_req_zone $binary_remote_addr zone=blogspace:10m rate=10r/s; # per client; the app throttles login and search itself server { listen 443 ssl; server_name example.com *.example.com; # wildcard certificate client_max_body_size 101m; # the Files page sends up to 10 files per request: >= 10 x the largest blog limit + 1 MB limit_req zone=blogspace burst=20 nodelay; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; # exactly one address: TRUST_PROXY reads the last one proxy_set_header X-Forwarded-Proto $scheme; } } ``` The app never sees TLS itself, so tell it: `HTTPS=true` (https links, `Secure` cookie, HSTS for the whole domain) and `TRUST_PROXY=true` (client addresses from `X-Forwarded-For`). Flood control for the site as a whole belongs to the proxy (`limit_req` above); the app throttles the two anonymous endpoints worth abusing on its own: login attempts (10, then 10 a minute, per address and per account) and blog search (30, then 30 a minute, per address). Caddy: `example.com, *.example.com { reverse_proxy 127.0.0.1:8080 }` (wildcard certificates need the DNS challenge). ### Backups and restores Every blog lives in its own database, `blog_` (dashes become underscores: `my-blog` → `blog_my_blog`). The control database (`blogspace`) holds the users and the blog registry. Uploaded files are in the blog database, so one dump is the whole blog. ```sh # one blog docker compose exec db pg_dump -U blogspace blog_alice > alice.sql # users and the blog list docker compose exec db pg_dump -U blogspace blogspace > control.sql # everything at once docker compose exec db pg_dumpall -U blogspace > all.sql ``` Restoring a blog, with the app running: ```sh docker compose exec db dropdb -U blogspace --force blog_alice docker compose exec db createdb -U blogspace blog_alice docker compose exec -T db psql -U blogspace -q blog_alice < alice.sql ``` The registry row must exist: on a fresh install first create the user with that subdomain in `/admin/` (which makes an empty `blog_alice`), then overwrite it as above. A dump taken with an older version of Blogspace is upgraded at the next start (or with `blogspace migrate`). Deleting a user in `/admin/` drops their blog database — take a dump first if you may want it back. Upgrading from a single database (installs older than the per-blog split): run the release that contains the split once (it moves each blog into its own database at start-up), then clear the old migration history so later migrations apply: `docker compose exec db psql -U blogspace -c "DELETE FROM goose_db_version WHERE version_id > 1"`. Current releases no longer carry the split code, and the first control migration after the split (the per-blog upload limit) is refused as "missing" until that history is cleared. Blog pools are small (4 connections each, closed when idle); with many blogs busy at once, raise `max_connections` on the `db` service. ## Layout ``` cmd/blogspace/ main (serve | seed | migrate) internal/config/ environment → Config internal/db/ control + per-blog pools, goose migrations (control/ and blog/) internal/store/ Store (users, blog registry) and BlogStore (one blog's content) 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`, `SameSite=Lax` cookie (`Secure` with `HTTPS=true`). 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, and a POST a modern browser marks as coming from another origin (`Sec-Fetch-Site`) is refused outright — a blog on a subdomain counts as another origin. Failed logins and throttled requests are logged with the client address (fail2ban-friendly). Management pages send `X-Frame-Options: DENY` and a CSP that keeps their forms on this origin; every response says `X-Content-Type-Options: nosniff`. Content bloggers write in HTML mode and the custom HTML module are published unsanitised on the blog's own origin, by design; the dashboard origin never renders them (HTML previews run in a sandboxed frame).