// Package main is a tiny HTTP service that exposes daily contribution counts // for a single allowlisted Gitea user, including activity from private repos. // // It reads directly from the Gitea database (the `action` table) using a // read-only DB user. Output is JSON: [{"date":"2026-05-04","count":3}, ...]. // // Pair with templates/profile-snippet.tmpl on the Gitea side to render a // GitHub-style heatmap with hover tooltips on a user profile page. package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" _ "github.com/lib/pq" ) // ---- config --------------------------------------------------------------- type config struct { DatabaseURL string AllowedUsers map[string]bool AllowedOrigin string Listen string OpTypes string // comma-separated list of integer op_type values WindowDays int } func loadConfig() (*config, error) { c := &config{ DatabaseURL: os.Getenv("DATABASE_URL"), AllowedOrigin: getEnv("ALLOWED_ORIGIN", "*"), Listen: getEnv("LISTEN", ":8080"), // Default: commit pushes (5), tag pushes (9), mirror sync push (18). // See README for the full Gitea op_type reference and why these are // the closest analog to "GitHub-style contributions". OpTypes: getEnv("OP_TYPES", "5,9,18"), WindowDays: 371, // 53 weeks * 7 days } if c.DatabaseURL == "" { return nil, errors.New("DATABASE_URL is required") } users := strings.TrimSpace(os.Getenv("ALLOWED_USERS")) if users == "" { return nil, errors.New("ALLOWED_USERS is required (comma-separated lowercase usernames)") } c.AllowedUsers = map[string]bool{} for _, u := range strings.Split(users, ",") { u = strings.TrimSpace(strings.ToLower(u)) if u != "" { c.AllowedUsers[u] = true } } // Validate OP_TYPES contains only digits, commas, and whitespace. This is // admin-controlled config but it gets interpolated into SQL, so be paranoid. for _, r := range c.OpTypes { if !((r >= '0' && r <= '9') || r == ',' || r == ' ') { return nil, fmt.Errorf("OP_TYPES may only contain digits and commas, got: %q", c.OpTypes) } } if strings.TrimSpace(strings.Trim(c.OpTypes, ", ")) == "" { return nil, errors.New("OP_TYPES must contain at least one value") } return c, nil } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // ---- handler -------------------------------------------------------------- type heatmapHandler struct { db *sql.DB cfg *config log *slog.Logger } type dayCount struct { Date string `json:"date"` Count int `json:"count"` } func (h *heatmapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Path: /heatmap/{username}.json (the .json suffix is optional) username := strings.TrimPrefix(r.URL.Path, "/heatmap/") username = strings.TrimSuffix(username, ".json") username = strings.ToLower(strings.TrimSpace(username)) if username == "" { http.Error(w, "username required", http.StatusBadRequest) return } if !h.cfg.AllowedUsers[username] { // 404, not 403, to avoid confirming whether a username exists. http.Error(w, "not found", http.StatusNotFound) return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() counts, err := h.queryHeatmap(ctx, username) if err != nil { h.log.Error("heatmap query failed", "user", username, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if counts == nil { counts = []dayCount{} } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Access-Control-Allow-Origin", h.cfg.AllowedOrigin) w.Header().Set("Vary", "Origin") if err := json.NewEncoder(w).Encode(counts); err != nil { h.log.Error("encode failed", "err", err) } } func (h *heatmapHandler) queryHeatmap(ctx context.Context, username string) ([]dayCount, error) { var userID int64 err := h.db.QueryRowContext(ctx, `SELECT id FROM "user" WHERE lower_name = $1`, username, ).Scan(&userID) if err != nil { return nil, fmt.Errorf("lookup user %q: %w", username, err) } cutoff := time.Now().UTC().AddDate(0, 0, -h.cfg.WindowDays).Unix() // OP_TYPES is validated at config load to contain only digits and commas, // so this fmt.Sprintf interpolation is safe. It's not a parameter because // PostgreSQL doesn't accept lists as a single placeholder. query := fmt.Sprintf(` SELECT to_char(to_timestamp(created_unix) AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, COUNT(*)::int FROM "action" WHERE act_user_id = $1 AND created_unix >= $2 AND op_type IN (%s) GROUP BY day ORDER BY day `, h.cfg.OpTypes) rows, err := h.db.QueryContext(ctx, query, userID, cutoff) if err != nil { return nil, fmt.Errorf("query: %w", err) } defer rows.Close() var out []dayCount for rows.Next() { var d dayCount if err := rows.Scan(&d.Date, &d.Count); err != nil { return nil, fmt.Errorf("scan: %w", err) } out = append(out, d) } return out, rows.Err() } // ---- main ----------------------------------------------------------------- func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) cfg, err := loadConfig() if err != nil { logger.Error("config error", "err", err) os.Exit(1) } db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { logger.Error("db open failed", "err", err) os.Exit(1) } defer db.Close() db.SetMaxOpenConns(4) db.SetMaxIdleConns(2) db.SetConnMaxLifetime(time.Hour) pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second) defer pingCancel() if err := db.PingContext(pingCtx); err != nil { logger.Error("db ping failed", "err", err) os.Exit(1) } h := &heatmapHandler{db: db, cfg: cfg, log: logger} mux := http.NewServeMux() mux.Handle("/heatmap/", h) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { http.Error(w, "db unhealthy", http.StatusServiceUnavailable) return } _, _ = w.Write([]byte("ok\n")) }) srv := &http.Server{ Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } go func() { logger.Info("listening", "addr", cfg.Listen, "users", len(cfg.AllowedUsers), "op_types", cfg.OpTypes, ) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server error", "err", err) os.Exit(1) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop logger.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Error("shutdown error", "err", err) } }