# 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 or your own uploaded WOFF2/WOFF/TTF/OTF files), header, menu, content, 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; 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, with bold, uppercase or plain underlined links. 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/` | | `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. 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 101m; # the Files page sends up to 10 files per request: >= 10 x the largest blog limit + 1 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 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` 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.