penguin/flatgit

flatgit showcase

internal/webhook/webhook.go

raw ยท 4429 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) {
	log := h.Logger

	if log != nil {
		log.Info("webhook request",
			"method", r.Method,
			"path", r.URL.Path,
			"remote", r.RemoteAddr,
			"event", r.Header.Get("X-Gitea-Event"),
			"content_type", r.Header.Get("Content-Type"),
			"delivery", r.Header.Get("X-Gitea-Delivery"),
			"signature", r.Header.Get("X-Gitea-Signature") != "",
			"signature_256", r.Header.Get("X-Gitea-Signature-256") != "",
		)
	}

	if r.Method != http.MethodPost {
		if log != nil {
			log.Warn("webhook rejected", "reason", "method not allowed", "method", r.Method)
		}
		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 {
		if log != nil {
			log.Warn("webhook rejected", "reason", "bad body", "error", err)
		}
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}

	if log != nil {
		log.Info("webhook body read", "bytes", len(body))
	}

	if !verifySignature(h.Config.Webhook.Secret, body, r) {
		if log != nil {
			log.Warn("webhook rejected",
				"reason", "bad signature",
				"secret_configured", h.Config.Webhook.Secret != "",
				"signature", r.Header.Get("X-Gitea-Signature"),
				"signature_256", r.Header.Get("X-Gitea-Signature-256"),
			)
		}
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}

	var p payload
	if err := json.Unmarshal(body, &p); err != nil {
		if log != nil {
			log.Warn("webhook rejected", "reason", "bad json", "error", err)
		}
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	fullName := firstNonEmpty(p.Repository.FullName, p.Repository.Name)

	if log != nil {
		log.Info("webhook payload",
			"ref", p.Ref,
			"repo_name", p.Repository.Name,
			"repo_full_name", p.Repository.FullName,
			"selected_name", fullName,
		)
	}

	if fullName == "" {
		if log != nil {
			log.Warn("webhook rejected", "reason", "missing repository name")
		}
		http.Error(w, "missing repository name", http.StatusBadRequest)
		return
	}

	repo, ok := h.Config.RepoByWebhookName(fullName)
	if !ok {
		if log != nil {
			var configured []string
			for _, r := range h.Config.Repos {
				configured = append(configured, r.FullName())
			}

			log.Warn("webhook rejected",
				"reason", "repo not configured",
				"payload_repo", fullName,
				"configured_repos", configured,
			)
		}

		http.Error(w, fmt.Sprintf("repo not configured: %s", fullName), http.StatusNotFound)
		return
	}

	queued := h.Queue.Enqueue(repo.FullName())

	if log != nil {
		h.Logger.Info("webhook accepted",
			"payload_repo", fullName,
			"repo", repo.FullName(),
			"queue_name", repo.Name,
			"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 ""
}