- Go 95.1%
- Shell 2.7%
- HTML 1.7%
- Dockerfile 0.3%
- Makefile 0.2%
- llm: the client never follows redirects, so a custom auth header and the prompt cannot be resent to another host; a 3xx fails through the error path - config: llm.base_url and grav.base_url must be https, http only on loopback - web: wrong Basic-Auth credentials log 'basic auth failed' with the client IP (remote address, or the last entry of server.client_ip_header behind a proxy); rate limiting is left to fail2ban on the host - README documents the https rule, client_ip_header and a fail2ban example |
||
|---|---|---|
| .claude | ||
| cmd/bumo-blogger | ||
| configs | ||
| docs | ||
| internal | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yml | ||
| CONTEXT.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
bumo-blogger
A self-hosted Go service for buchforst-mobil.de. Every day it reads local RSS feeds, picks the most relevant topics for Köln-Buchforst and Mülheim-Süd, and has an exchangeable LLM write up to three German blog post suggestions. In a small web UI (htmx, Alpine.js, Bulma) the editor reviews them, rejects them, or uploads them to Grav with one click as unpublished pages. Going live happens in Grav.
Project docs
CONTEXT.md: domain glossary. The German UI terms (Vorschlag, Freigeben, Quellen, …) are the canonical names.docs/adr/: architecture decision records.docs/PITFALLS.md: known traps and open gaps.docs/ROADMAP.md: phases in order with their issues.docs/agents/: conventions for coding agents (issue tracker, triage labels, domain docs).
Development
Requires Go 1.27 (GOTOOLCHAIN=auto fetches it). No C toolchain is needed: the build always runs with CGO_ENABLED=0.
make build # binary in bin/
make test # go test ./...
make lint # golangci-lint (pinned), including depguard architecture rules
make run # serve with configs/config.example.yaml
make docker # image bumo-blogger:dev
The code follows a flat clean architecture: cmd → internal/adapters → internal/app → internal/domain. See ADR-0001.
Issue tracking with git-bug
Issues live in this repository as git-bug entities under refs/bugs/, not on a hosting platform. They are local only; there is no bridge.
git bug bug status:open sort:creation # list open issues
git bug bug label:ready-for-agent # filter by label
git bug bug show <id> # details and comments
git bug bug new --non-interactive -t "<title>" -m "<body>"
git bug bug label new <id> needs-triage
git bug bug comment new <id> --non-interactive -m "<text>"
git bug bug status close <id>
git bug webui # browser UI
- Labels: triage state (
needs-triage,needs-info,ready-for-agent,ready-for-human,wontfix) plus the phase (phase-1…phase-5,backlog). - Dependencies: an issue states them on its first body line as
Blocked by: <id>, <id>. - Body text: pass it with
-m, not-F. With-F, git-bug uses the file's first line as the title and drops every line starting with#, which strips the Markdown headings. - The UIs hold the repository lock: while
git bug webuiorgit bug termuiruns, every other git-bug command, reads included, hangs without output until the UI exits. It does not fail or time out. - Pushing: a plain
git pushdoes not send issues. Once a remote exists, usegit bug pushandgit bug pull.
More detail: docs/agents/issue-tracker.md.
Operations
Environment variables
Secrets live only in the environment, never in the YAML file. The service refuses to start if one is missing:
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY |
API key of the LLM provider (the name can be changed with llm.api_key_env) |
GRAV_API_KEY |
Key of the Grav API user |
BASIC_AUTH_USER, BASIC_AUTH_PASS |
Login for the web UI |
Non-secret overrides: BUMO_CONFIG, BUMO_SERVER_ADDR, BUMO_DATABASE_PATH, BUMO_LOG_FORMAT, BUMO_LOG_LEVEL, BUMO_LLM_BASE_URL, BUMO_GRAV_BASE_URL, BUMO_SCHEDULE_DAILY_AT.
Configuration
Template: configs/config.example.yaml. Unknown keys make the start fail. Changes take effect only after a restart.
- Add a feed: add another entry under
sources(id,name,url). No code change is needed. Optional flags per feed:repair_links(broken Stadt Köln links),teaser_only(paywalled: title, teaser and link only),fix_berlin_offset(feed writes+0100all year). - Models:
llm.tasks.rank.modelandllm.tasks.write.model. - Switch provider: change only
llm.base_url,llm.auth_headerandllm.auth_scheme, plusllm.extra_bodyif needed.
# OpenAI
llm:
base_url: "https://api.openai.com/v1"
api_key_env: OPENAI_API_KEY
auth_header: Authorization
auth_scheme: Bearer
extra_body: {}
tasks:
rank: { model: "gpt-5-mini", max_tokens: 2000 }
write: { model: "gpt-5", max_tokens: 2500 }
# Azure OpenAI (v1 endpoint, model = deployment name)
llm:
base_url: "https://MY-RESOURCE.openai.azure.com/openai/v1"
api_key_env: AZURE_OPENAI_API_KEY
auth_header: api-key
auth_scheme: ""
extra_body: {}
Run locally
# Git Bash
export OPENROUTER_API_KEY=... GRAV_API_KEY=... BASIC_AUTH_USER=... BASIC_AUTH_PASS=...
make run
# PowerShell
$env:OPENROUTER_API_KEY="..."; $env:GRAV_API_KEY="..."
$env:BASIC_AUTH_USER="..."; $env:BASIC_AUTH_PASS="..."
make run
The UI is then at http://127.0.0.1:8080/.
Docker
make docker
docker run -d --name bumo-blogger --stop-timeout 45 -p 8080:8080 -v bumo-data:/data --env-file bumo.env bumo-blogger:dev
bumo.env: holds the four variables asNAME=value. Do not commit it.- User and mounts: the image runs as user
nonroot(UID 65532). With a bind mount instead of a named volume, that user must own the directory:chown 65532:65532 <dir>. - Own configuration:
-v ./config.yaml:/etc/bumo-blogger/config.yaml:ro. - Stopping: the service waits up to 40 s for a running Grav upload before it closes the database. Docker kills a container after 10 s by default, so keep
--stop-timeout 45(Compose:stop_grace_period: 45s).
Daily run and health check
- The run starts every day at 06:00 Europe/Berlin (
schedule.daily_at). - Catch-up: if the service starts after 06:00 and there has been no successful run today, it runs once to catch up. A further restart on the same day starts no second run.
- Manual run: „Lauf starten“ in the UI starts a run. Only one run happens at a time.
- Health check:
GET /healthzneeds no login and answers 200 as long as the database responds. The DockerHEALTHCHECKcallsbumo-blogger healthcheck.
Reverse proxy
Basic-Auth is only safe over HTTPS, so TLS must terminate at the reverse proxy. If the proxy rewrites the Host header, the public address must be listed in server.trusted_origins (for example https://blogger.example.org). Otherwise the cross-origin protection rejects the htmx POSTs with 403.
Set server.client_ip_header to the header the proxy writes the client IP into (for example X-Forwarded-For; the last entry is used). Without a proxy leave it empty: then the remote address counts and a client cannot fake its IP.
HTTPS for API endpoints
llm.base_url and grav.base_url must use https, because the API keys travel with every request. Plain http is accepted only for localhost, 127.0.0.0/8 and ::1. The LLM and Grav clients never follow redirects, so a key is never resent to another host.
Failed logins and fail2ban
The service does not limit login attempts itself. Every request with wrong Basic-Auth credentials logs one warning with the client IP; a request without credentials (the browser's first try) does not. Let fail2ban on the host ban repeat offenders, for example with the Docker journald log driver:
# /etc/fail2ban/filter.d/bumo-blogger.conf
[Definition]
failregex = msg="basic auth failed" client_ip=<HOST>$
"msg":"basic auth failed","client_ip":"<HOST>"
# /etc/fail2ban/jail.d/bumo-blogger.conf
[bumo-blogger]
enabled = true
backend = systemd
journalmatch = CONTAINER_NAME=bumo-blogger
filter = bumo-blogger
maxretry = 10
findtime = 10m
bantime = 1h
Behind a reverse proxy the ban must happen where the client connects (the proxy host), and server.client_ip_header must be set so the log names the real client.
Grav
The service needs its own Grav API user with the permissions api.access and api.pages.*. Generate the key with bin/plugin api keys:generate. Uploaded pages are unpublished and not routable, so the „In Grav öffnen“ link returns 404 publicly until the page is published in Grav.
Backup
Stop the container, then copy bumo.db* (the database plus its -wal and -shm files) out of the volume, for example with docker run --rm -v bumo-data:/data -v "$PWD":/backup alpine sh -c "cp /data/bumo.db* /backup/".