# 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), in an editor with a formatting toolbar (bold, italic, headings, links, lists, quotes, code…), keyboard shortcuts and a rendered preview. Files of any kind — images, PDFs, archives, audio… — 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; each can be hidden without deleting. - Every blog has **pages** (Home, About, News, …); each page holds posts. Posts can carry **tags**, shown under the date; each tag has its own page listing every post with it. - **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, tags (with counts), tag cloud, 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. - **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.