- Go 69%
- HTML 22.8%
- CSS 7.8%
- Makefile 0.3%
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 |
||
|---|---|---|
| cmd/blogspace | ||
| internal | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| compose.dev.yml | ||
| compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
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 onepg_dumpis a complete backup of a blog and onepsqlrestores 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:
- Root blog (the superadmin's): http://blogspace.localhost:8080
- Dashboard: http://blogspace.localhost:8080/webadmin (log in as
adminoralice)./webadminon 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 <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-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.
# 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.