didericis 944086539e Clarify local binary name vs Dockerfile binary name
go build ./... names the binary after the module (gitea-heatmap-sidecar)
while the Dockerfile uses -o /heatmap. Fix .gitignore to match the local
name and add notes in both files explaining the discrepancy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 15:29:28 -04:00
2026-05-05 15:24:55 -04:00

gitea-heatmap-sidecar

A tiny HTTP service that exposes daily contribution counts for an allowlisted Gitea user — including private repo activity — for rendering a GitHub-style heatmap on a public profile page.

Stock Gitea (and Forgejo) intentionally do not expose private contribution counts to anonymous viewers. This is a sidecar workaround: read directly from Gitea's action table with a read-only DB user, return per-day counts as JSON, and let a custom profile template render the squares client-side.

What it shows / what it doesn't

  • Daily counts (the green squares)
  • Hover tooltips with the date, count, and repo names
  • Commit messages, branches, file content — none of that ever leaves the database.

Architecture

[anonymous visitor] → GET /didericis (Gitea)
                        ↓ profile.tmpl override loads
                     <script> fetches https://heatmap.dideric.is/heatmap/didericis.json
                        ↓
                     [this service] → SELECT FROM action WHERE act_user_id=? → JSON

Setup

1. Create a read-only Postgres user

Run db/setup.sql against the Gitea database as a superuser. Edit the password first.

psql -U postgres -d gitea -f db/setup.sql

Only SELECT on "user" and "action" is granted. If Gitea ever renames either table in a migration, the service will break loudly — that's the goal.

2. Build and run the sidecar

Edit docker-compose.example.yml, then:

docker compose -f docker-compose.example.yml up -d --build

Make sure the networks block matches your existing Gitea Docker network so the sidecar can reach gitea-db by hostname.

Required env vars:

Var Description
DATABASE_URL postgres://heatmap_ro:...@host:5432/gitea?sslmode=...
ALLOWED_USERS Comma-separated lowercase usernames (e.g. didericis)
ALLOWED_ORIGIN CORS origin — must match Gitea's URL
OP_TYPES Optional. Comma-separated op_type ints. See below.
LISTEN Optional. Default :8080.

3. Reverse proxy

Expose the service at a hostname Gitea's frontend can reach over HTTPS — e.g. heatmap.dideric.isheatmap:8080. Use the same TLS setup as Gitea itself (Caddy/Traefik/nginx).

4. Install the profile template override

Copy templates/user/profile.tmpl from the Gitea source matching your running version into $GITEA_CUSTOM/templates/user/profile.tmpl, then merge in the snippet from templates/profile-snippet.tmpl near the existing heatmap block.

Replace HEATMAP_BASE_URL in the snippet with your sidecar's public URL (e.g. https://heatmap.dideric.is) and didericis with the username you're exposing.

Restart Gitea, hit the profile page in incognito, and you should see the heatmap populate.

Op type reference

Gitea's action.op_type is an integer enum. Defaults are commits-only (5,9,18), which is the closest analog to what GitHub counts as a "contribution" in their heatmap. Common values:

Value Meaning
1 Create repo
5 Push commits
6 Create issue
7 Create pull request
9 Push tag
11 Merge pull request
18 Mirror sync push
24 Publish release

Set OP_TYPES=5,6,7,9,11,18,24 for a more inclusive count.

Endpoints

  • GET /heatmap/{username}.json — JSON [{"date":"YYYY-MM-DD","count":N,"repos":["name", ...]}, ...] for the past ~53 weeks. 1-hour cache header.
  • GET /healthz — 200 if the DB is reachable, 503 otherwise.

Maintenance

  • Gitea schema changes. The query reads from "action" and "user". These tables have been stable for many Gitea versions. After each Gitea upgrade, hit /healthz and the JSON endpoint to confirm.
  • Template drift. The harder part. Gitea's profile.tmpl does change between versions — diff the new upstream against your override on each upgrade and re-merge the snippet.
  • Cache. 1-hour Cache-Control header keeps load trivial. Drop to 5 minutes for snappier updates if needed; the underlying query is cheap thanks to the existing index on (user_id, act_user_id, created_unix) added in Gitea 1.24.

Local dev

go run . # requires DATABASE_URL and ALLOWED_USERS in env

go build ./... produces a binary named gitea-heatmap-sidecar (derived from the module name). The Dockerfile overrides this with -o /heatmap; the two names differ but refer to the same program.

On NixOS:

nix-shell -p go_1_23 postgresql

Limitations

  • Postgres only. MySQL support would mean swapping to_timestamp(...) for FROM_UNIXTIME(...) and adjusting identifier quoting — straightforward but not done here.
  • Single-tenant by allowlist. The service is meant for a personal Gitea instance with a small known set of users opting in. Don't expose it to arbitrary usernames; that would leak private activity counts for users who haven't consented.
  • No auth on the JSON endpoint by design — the data is intentionally public.

License

MIT

S
Description
No description provided
Readme 71 KiB
Languages
Go 68.5%
Go Template 28.3%
Dockerfile 3.2%