penguin/flatgit

flatgit showcase

internal/webhook/webhook.go

raw ยท 2767 bytes

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strings"

	"github.com/tgckpg/flatgit/internal/config"
)

type Enqueuer interface {
	Enqueue(string) bool
}

type Handler struct {
	Config  *config.Config
	Queue   Enqueuer
	Logger  *slog.Logger
	MaxBody int64
}

type payload struct {
	Ref        string `json:"ref"`
	Repository struct {
		Name     string `json:"name"`
		FullName string `json:"full_name"`
		CloneURL string `json:"clone_url"`
		HTMLURL  string `json:"html_url"`
	} `json:"repository"`
}

func (h *Handler) Register(mux *http.ServeMux) {
	mux.HandleFunc("/webhook/gitea", h.ServeHTTP)
	mux.HandleFunc("/webhook/github", h.ServeHTTP)
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if h.MaxBody <= 0 {
		h.MaxBody = 1 << 20
	}
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, h.MaxBody))
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	if !verifySignature(h.Config.Webhook.Secret, body, r) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}

	var p payload
	if err := json.Unmarshal(body, &p); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	fullName := firstNonEmpty(p.Repository.FullName, p.Repository.Name)
	if fullName == "" {
		http.Error(w, "missing repository name", http.StatusBadRequest)
		return
	}
	repo, ok := h.Config.RepoByWebhookName(fullName)
	if !ok {
		http.Error(w, fmt.Sprintf("repo not configured: %s", fullName), http.StatusNotFound)
		return
	}
	queued := h.Queue.Enqueue(repo.Name)
	if h.Logger != nil {
		h.Logger.Info("webhook received", "repo", repo.FullName(), "ref", p.Ref, "queued", queued)
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusAccepted)
	_, _ = fmt.Fprintf(w, `{"ok":true,"queued":%t,"repo":%q}`+"\n", queued, repo.FullName())
}

func verifySignature(secret string, body []byte, r *http.Request) bool {
	if secret == "" {
		return true
	}
	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write(body)
	expected := mac.Sum(nil)

	github := r.Header.Get("X-Hub-Signature-256")
	if strings.HasPrefix(github, "sha256=") {
		got, err := hex.DecodeString(strings.TrimPrefix(github, "sha256="))
		return err == nil && hmac.Equal(got, expected)
	}

	gitea := r.Header.Get("X-Gitea-Signature")
	if gitea != "" {
		got, err := hex.DecodeString(gitea)
		return err == nil && hmac.Equal(got, expected)
	}

	return false
}

func firstNonEmpty(v ...string) string {
	for _, s := range v {
		if strings.TrimSpace(s) != "" {
			return strings.TrimSpace(s)
		}
	}
	return ""
}