No description
  • Go 69%
  • HTML 22.8%
  • CSS 7.8%
  • Makefile 0.3%
Find a file
grm 4a6f987bf7 Merge the Layout and Design tabs into one form with a single Save
The look and the layout of a blog were split over two tabs that cross-linked
each other, and the Layout tab saved every toggle, move and add straight
away through a dozen tiny forms. Now one Design tab holds it all, organised
by part of the blog (colours & fonts, header, menu, content, side columns,
footer, logo & icon) behind a sticky side index, and one Save stores the
theme, every module and the menu atomically (BlogStore.SaveDesign). Module
settings are edited inline; Discard changes throws the edits away; a
validation error shows the form again exactly as it was sent.

The image pickers no longer render the whole library four times over: a
select of the newest images, and a script-driven panel that fetches the
library page by page (GET /files as JSON) with lazy thumbnails.

New options: posts per page, site title size, footer alignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
2026-09-16 19:10:13 +03:00
cmd/blogspace Add an HTML mode to posts, page intros and announcements 2026-09-16 18:37:20 +03:00
internal Merge the Layout and Design tabs into one form with a single Save 2026-09-16 19:10:13 +03:00
.dockerignore Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
.env.example Turn the image library into a file library, with a per-blog upload limit 2026-09-14 23:51:55 +03:00
.gitignore Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
AGENTS.md Merge the Layout and Design tabs into one form with a single Save 2026-09-16 19:10:13 +03:00
compose.dev.yml Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
compose.yml Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
Dockerfile Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
go.mod Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
go.sum Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
Makefile Initial multi-tenant blog host 2026-09-12 11:24:17 +03:00
README.md Merge the Layout and Design tabs into one form with a single Save 2026-09-16 19:10:13 +03:00

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 <name>.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… — 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.
  • Design tab: one form, one Save, organised by part of the blog — colours & fonts (schemes, background colour/image, text and link colours, fonts), 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 menu mixes blog pages and outside links. Posts per page, the date format, the site title size and the footer alignment live here too.
  • 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_<name>), 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).

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:

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 <name>.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)

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:

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_<name> (dashes become underscores: my-blogblog_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.

# 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:

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/<sub>/…, /admin/) plus the root blog's public pages on every other path. Host == <sub>.BASE_DOMAIN → that blog's public pages (/, /<page>, /<page>/<post>, /feed.xml, /media/<id>), 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.