package webhook import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "log" "net/http" "os" "time" "github.com/go-git/go-git/v5" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/gorilla/mux" "golang.org/x/time/rate" "varde/internal/middleware" "varde/internal/ntfy" ) type Config struct { Enabled bool ContentDir string RepoURL string RepoToken string WebhookSecret string } type Module struct { cfg Config ntfyCfg ntfy.Config repo *git.Repository limiter *rate.Limiter } func New(cfg Config, ntfyCfg ntfy.Config) *Module { return &Module{ cfg: cfg, ntfyCfg: ntfyCfg, limiter: rate.NewLimiter(rate.Every(10*time.Second), 1), } } func (m *Module) Name() string { return "webhook" } func (m *Module) RegisterRoutes(r *mux.Router) { r.Handle("/webhook", middleware.RateLimit(m.limiter)(http.HandlerFunc(m.handleWebhook))).Methods("POST") } // Start clones or opens the content repo. Returns an error instead of // calling log.Fatalf directly — a failing webhook module should not // necessarily bring down other, unrelated modules in the same process. func (m *Module) Start(ctx context.Context) error { return m.cloneOrOpen() } func (m *Module) Stop(ctx context.Context) error { return nil } func (m *Module) cloneOrOpen() error { auth := &githttp.BasicAuth{ Username: "x-token", Password: m.cfg.RepoToken, } if _, err := os.Stat(m.cfg.ContentDir + "/.git"); os.IsNotExist(err) { log.Println("webhook: cloning repo...") r, err := git.PlainClone(m.cfg.ContentDir, false, &git.CloneOptions{ URL: m.cfg.RepoURL, Auth: auth, }) if err != nil { ntfy.Notify(m.ntfyCfg, "webhook clone failed", err) return err } m.repo = r return nil } log.Println("webhook: opening existing repo...") r, err := git.PlainOpen(m.cfg.ContentDir) if err != nil { ntfy.Notify(m.ntfyCfg, "webhook open failed", err) return err } m.repo = r return nil } func (m *Module) pull() error { w, err := m.repo.Worktree() if err != nil { return err } err = w.Pull(&git.PullOptions{ Auth: &githttp.BasicAuth{ Username: "x-token", Password: m.cfg.RepoToken, }, }) if err == git.NoErrAlreadyUpToDate { return nil } return err } func validateSignature(secret, signature string, body []byte) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } func (m *Module) handleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } sig := r.Header.Get("X-Gitea-Signature") if !validateSignature(m.cfg.WebhookSecret, sig, body) { log.Println("webhook: invalid signature") w.WriteHeader(http.StatusUnauthorized) return } if err := m.pull(); err != nil { ntfy.Notify(m.ntfyCfg, "webhook pull failed", err) log.Printf("webhook: pull failed: %v", err) w.WriteHeader(http.StatusInternalServerError) return } log.Println("webhook: pulled new content") w.WriteHeader(http.StatusOK) }