A pure-Go rewrite of Rables, the Rails personal blog system that runs https://versun.me.
Single static binary + a data/ directory. No Node.js, no frontend build step, no Redis/Postgres — SQLite (pure Go, no cgo) for everything.
The Rails app in the parent directory remains the behavioral source of truth. The full execution spec, task breakdown (T01–T30), and the decision log live in ../docs/plans/go-rewrite-plan.md.
All implementation tasks (T01–T29) are complete: core blog, admin, comments, subscriptions, newsletters, crossposting, Twitter sync/archive, import/export, Rails migration tool, vanilla JS frontend, and deployment artifacts. Remaining: T30 (production cutover), which must be executed against the live environment.
- Content: articles & pages with draft/publish/schedule/trash/shared states, dual editor modes (Lexxy rich text / raw HTML), tags, scheduled publishing with crosspost & newsletter snapshots
- Comments: threaded comments with math captcha + per-IP rate limiting, admin moderation/reply, social comment import (Mastodon/Bluesky/X)
- Newsletter: native SMTP or listmonk, tag-scoped subscriptions, double opt-in confirm/unsubscribe
- Crossposting: Mastodon, Bluesky (hand-written XRPC + facets), X (OAuth1.0a, chunked media upload, quote-tweet/GIF rules); Xiaohongshu is log-only
- Twitter: account sync (tweets archived as articles) + official archive ZIP import (streaming, memory-flat) with a public timeline page
- Transfer: full-site export (SQLite database + media files in one ZIP), import from a Rables export or bare database (upsert by id — a restore mechanism for fresh installs or the originating site, not a merge of two populated sites; users, including your own account, are overwritten), import from a Rails rables SQLite database (+ optional storage ZIP), RSS import (SSRF-hardened)
- Ops: background jobs (
job_runstable + in-process worker), cron scheduler, activity log, regex redirects, static file hosting
Requires Go ≥ 1.24 (developed on 1.26).
go build -o server ./cmd/server
HMAC_SECRET=change-me ./server
# open http://localhost:8080/setupConfiguration via environment variables:
| Var | Default | Notes |
|---|---|---|
ADDR |
:8080 |
Listen address |
DATA_DIR |
./data |
SQLite DB + uploaded files live here |
HMAC_SECRET |
(required) | Signs math-captcha tokens |
ARTICLE_ROUTE_PREFIX |
(empty) | Fallback prefix for article URLs (e.g. blog); the admin setting at /admin/setting/edit overrides it and applies without a restart |
LOG_LEVEL |
info |
debug/info/warn/error (JSON logs to stdout) |
TRUST_X_FORWARDED_FOR |
false |
1/true/yes: key rate limits off the rightmost X-Forwarded-For hop (the one the proxy appended) — enable only behind a reverse proxy that appends to the header |
SECURE_COOKIES |
false |
1/true/yes: add Secure to session/flash cookies (enable when serving HTTPS) |
# quality gate (run before handoff — must be all green)
gofmt -l . && go vet ./... && go test ./...
# regenerate sqlc code after editing queries/*.sql
go tool sqlc generate
# database migrations are embedded and applied automatically at startup
# (migrations/*.sql via goose)Conventions: timestamps are INTEGER unix seconds (UTC) everywhere; HTML forms are GET/POST only (Rails PATCH/DELETE actions map to POST path variants); each HTTP feature lives in internal/httpd/<feature>.go and exposes RegisterXxxRoutes, wired centrally in router.go.
See deploy/README.md. In short:
docker build -t rables .
docker run -e HMAC_SECRET=change-me -v rables-data:/data -p 8080:8080 rablesDockerfile— multi-stage build → distroless nonroot image (~48MB)deploy/rables.service— hardened systemd unitdeploy/backup.sh— online SQLite backup (.backup) + files tarball with retention
go build -o migrate-rails ./cmd/migrate-rails
./migrate-rails -old /path/to/rails/db/production.sqlite3 -data ./data --verify-filesThe tool is idempotent (safe to re-run to catch up before cutover), migrates all content and rewrites ActionText attachments to /files/<key> URLs, and prints a per-table report (old/inserted/skipped counts). It exits non-zero if row counts mismatch. Disk files are not copied — mount the Rails storage/ directory as DATA_DIR/files/ (the xx/yy/<key> layout is identical). Sessions are not migrated; everyone logs in again after cutover.
The same migration also runs from the admin UI (/admin/migrates, Import tab): upload the Rails SQLite database plus an optional ZIP of the storage/ directory.
cmd/server/ the one long-running process (HTTP + job worker + cron)
cmd/migrate-rails/ one-shot Rails → Go migration tool
internal/config/ env config + logger
internal/db/ goose-embedded migrations, connection, sqlc-generated queries
internal/domain/ pure functions (state machine, slug, excerpt, sanitize, contentbuilder)
internal/httpd/ chi router, middleware, all HTTP handlers
internal/jobs/ job_runs worker + cron scheduler
internal/service/ articles, comments, crosspost, media, newsletter, transfer,
twitterarchive, twittersync, railsmigrate, ...
internal/templates/ embedded html/template pages (+ `_`-prefixed partials)
internal/assets/ embedded app.js (vanilla, no build) / app.css / Lexxy editor
migrations/ full DDL (goose)
queries/ sqlc sources (pure ASCII only)
deploy/ Dockerfile companions: systemd unit, backup script, docs