From a224d31c47c6504f4e93e12611d20d2fd04752aa Mon Sep 17 00:00:00 2001 From: Simon Martens Date: Wed, 26 Mar 2025 18:53:27 +0100 Subject: [PATCH] GitHub Webhooks integration --- config.dev.json | 3 +- controllers/routes.go | 13 +++++++- controllers/webhook.go | 68 ++++++++++++++++++++++++++++++++++++++++ git/git.go | 54 +++++++++++++++++++++++-------- lenz.go | 4 ++- server/server.go | 4 ++- views/routes/body.gohtml | 31 ++++++++++++++++++ xmlmodels/meta.go | 18 ++++++++--- 8 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 controllers/webhook.go diff --git a/config.dev.json b/config.dev.json index e17b1ac..db5ae87 100644 --- a/config.dev.json +++ b/config.dev.json @@ -1,5 +1,6 @@ { "debug": true, "watch": true, - "git_url": "git@github.com:Theodor-Springmann-Stiftung/lenz-briefe.git" + "git_url": "git@github.com:Theodor-Springmann-Stiftung/lenz-briefe.git", + "webhook_secret": "test_secret" } diff --git a/controllers/routes.go b/controllers/routes.go index bc92a76..6c1b152 100644 --- a/controllers/routes.go +++ b/controllers/routes.go @@ -1,6 +1,7 @@ package controllers import ( + "github.com/Theodor-Springmann-Stiftung/lenz-web/config" "github.com/Theodor-Springmann-Stiftung/lenz-web/helpers/middleware" "github.com/Theodor-Springmann-Stiftung/lenz-web/server" "github.com/Theodor-Springmann-Stiftung/lenz-web/views" @@ -9,11 +10,21 @@ import ( ) const ASSETS_URL = "/assets" +const WBHOOK_URL = "/webhook" -func Register(server server.Server) { +func Register(server server.Server, cfg config.Config) { server.Server.Use(ASSETS_URL, compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) server.Server.Use(ASSETS_URL, middleware.StaticHandler(&views.StaticFS)) server.Server.Get("/", GetIndex) + + if cfg.WebHookSecret != "" { + whurl := WBHOOK_URL + if cfg.WebHookEndpoint != "" { + whurl = cfg.WebHookEndpoint + } + server.Server.Post(whurl, PostWebhook(cfg)) + } + } diff --git a/controllers/webhook.go b/controllers/webhook.go new file mode 100644 index 0000000..10f0b3e --- /dev/null +++ b/controllers/webhook.go @@ -0,0 +1,68 @@ +package controllers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "path/filepath" + "strings" + + "github.com/Theodor-Springmann-Stiftung/lenz-web/config" + gitprovider "github.com/Theodor-Springmann-Stiftung/lenz-web/git" + "github.com/Theodor-Springmann-Stiftung/lenz-web/xmlmodels" + "github.com/gofiber/fiber/v2" +) + +const SIGNATURE_PREFIX = "sha256=" + +func PostWebhook(cfg config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + body := c.Body() + if !verifySignature256([]byte(cfg.WebHookSecret), body, c.Get("X-Hub-Signature-256")) { + return c.SendStatus(fiber.StatusUnauthorized) + } + + if c.Get("X-GitHub-Event") == "" { + return c.SendStatus(fiber.StatusBadRequest) + } + + dir := filepath.Join(cfg.BaseDIR, cfg.GITPath) + + commit, err := gitprovider.Pull(dir, cfg.GitURL, cfg.GitBranch) + if err != nil { + panic(err) + return c.SendStatus(fiber.StatusInternalServerError) + } + + _, err = xmlmodels.Parse(dir, commit.Hash) + if err != nil { + panic(err) + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.SendStatus(fiber.StatusOK) + } +} + +func sign256(secret, body []byte) []byte { + computed := hmac.New(sha256.New, secret) + computed.Write(body) + return []byte(computed.Sum(nil)) +} + +func verifySignature256(secret, payload []byte, header string) bool { + if !strings.HasPrefix(header, SIGNATURE_PREFIX) { + return false + } + + sig, err := hex.DecodeString(header[len(SIGNATURE_PREFIX):]) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + expected := mac.Sum(nil) + + return hmac.Equal(expected, sig) +} diff --git a/git/git.go b/git/git.go index cb4fb56..5ccae24 100644 --- a/git/git.go +++ b/git/git.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "sync" "time" "github.com/go-git/go-git/v5" @@ -16,6 +17,9 @@ var NoURLProvidedError = errors.New("Missing URL.") var NoPathProvidedError = errors.New("Missing path.") var NoBranchProvidedError = errors.New("Missing branch name.") +var mu sync.Mutex +var repo *git.Repository + type Commit struct { Path string URL string @@ -29,7 +33,11 @@ func IsValidRepository(path, url, branch string) *Commit { return commit } +// WARNING: Only OpenOrClone() and Pull() should be used externally. func OpenOrClone(path, url, branch string) (*Commit, error) { + mu.Lock() + defer mu.Unlock() + commit := IsValidRepository(path, url, branch) if commit == nil { if _, err := os.Stat(path); err == nil { @@ -50,6 +58,9 @@ func OpenOrClone(path, url, branch string) (*Commit, error) { } func Pull(path, url, branch string) (*Commit, error) { + mu.Lock() + defer mu.Unlock() + if url == "" { return nil, NoURLProvidedError } @@ -63,12 +74,19 @@ func Pull(path, url, branch string) (*Commit, error) { } br := plumbing.NewBranchReferenceName(branch) - repo, err := git.PlainOpen(path) - if err != nil { - return nil, err + + var r *git.Repository + if repo == nil { + rep, err := git.PlainOpen(path) + if err != nil { + return nil, err + } + r = rep + } else { + r = repo } - wt, err := repo.Worktree() + wt, err := r.Worktree() if err != nil { return nil, err } @@ -82,6 +100,8 @@ func Pull(path, url, branch string) (*Commit, error) { } defer wt.Clean(&git.CleanOptions{Dir: true}) + repo = r + return latestCommit(repo, path, branch, url) } @@ -98,14 +118,20 @@ func Read(path, branch, url string) (*Commit, error) { return nil, NoURLProvidedError } - repo, err := git.PlainOpen(path) - if err != nil { - return nil, err + var r *git.Repository + if repo == nil { + rep, err := git.PlainOpen(path) + if err != nil { + return nil, err + } + r = rep + } else { + r = repo } - if err := ValidateBranch(repo, branch); err != nil { + if err := ValidateBranch(r, branch); err != nil { br := plumbing.NewBranchReferenceName(branch) - wt, err := repo.Worktree() + wt, err := r.Worktree() if err != nil { return nil, err } @@ -123,7 +149,8 @@ func Read(path, branch, url string) (*Commit, error) { } } - return latestCommit(repo, path, branch, url) + repo = r + return latestCommit(r, path, branch, url) } func Clone(path, url, branch string) (*Commit, error) { @@ -141,7 +168,7 @@ func Clone(path, url, branch string) (*Commit, error) { br := plumbing.NewBranchReferenceName(branch) - repo, err := git.PlainClone(path, false, &git.CloneOptions{ + r, err := git.PlainClone(path, false, &git.CloneOptions{ URL: url, Progress: os.Stdout, }) @@ -150,7 +177,7 @@ func Clone(path, url, branch string) (*Commit, error) { return nil, err } - wt, err := repo.Worktree() + wt, err := r.Worktree() if err != nil { return nil, err } @@ -163,7 +190,8 @@ func Clone(path, url, branch string) (*Commit, error) { return nil, err } - return latestCommit(repo, path, branch, url) + repo = r + return latestCommit(r, path, branch, url) } func (g Commit) String() string { diff --git a/lenz.go b/lenz.go index ef2a694..18199a0 100644 --- a/lenz.go +++ b/lenz.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log/slog" "path/filepath" "time" @@ -37,6 +38,7 @@ func main() { dir := filepath.Join(cfg.BaseDIR, cfg.GITPath) + fmt.Printf("Starting Lenz with config: %v", cfg) commit, err := gitprovider.OpenOrClone(dir, cfg.GitURL, cfg.GitBranch) if err != nil { panic(err) @@ -60,7 +62,7 @@ func main() { } server := server.New(engine, storage, cfg.Debug) - controllers.Register(server) + controllers.Register(server, cfg) server.Start(cfg.Address + ":" + cfg.Port) } diff --git a/server/server.go b/server/server.go index 48b5471..825b735 100644 --- a/server/server.go +++ b/server/server.go @@ -49,7 +49,9 @@ func New(engine *templating.Engine, storage fiber.Storage, debug bool) Server { server.Use(logger.New()) } - server.Use(recover.New()) + if !debug { + server.Use(recover.New()) + } if debug { server.Use(cache.New(cache.Config{ diff --git a/views/routes/body.gohtml b/views/routes/body.gohtml index 9f110b2..9e79850 100644 --- a/views/routes/body.gohtml +++ b/views/routes/body.gohtml @@ -9,6 +9,37 @@ {{ range $l := $letters -}}
{{ $l.Letter }}
{{ $l.Earliest.Text -}}
+ {{- range $sr := $l.SendReceivedPairs -}} +
+
+ {{- range $i, $p := $sr.Sent.Persons -}} +
+ {{- if $i -}} + , + {{ end -}} + {{- $person := Person $p.Reference -}} + {{- $person.Name -}} +
+ {{- end -}} +
+
an
+ {{- if $sr.Received -}} +
+ {{- range $i, $p := $sr.Received.Persons -}} +
+ {{- if $i -}} + , + {{ end -}} + {{- $person := Person $p.Reference -}} + {{- $person.Name -}} +
+ {{- end -}} +
+ {{- else -}} +
Unbekannt
+ {{- end -}} +
+ {{- end -}} {{- end -}} {{- end -}} diff --git a/xmlmodels/meta.go b/xmlmodels/meta.go index b658fa8..2c5cb0c 100644 --- a/xmlmodels/meta.go +++ b/xmlmodels/meta.go @@ -4,6 +4,7 @@ import ( "encoding/json" "encoding/xml" "iter" + "slices" xmlparsing "github.com/Theodor-Springmann-Stiftung/lenz-web/xml" ) @@ -15,7 +16,7 @@ type Meta struct { IsProofread xmlparsing.OptionalBool `xml:"isProofread"` IsDraft xmlparsing.OptionalBool `xml:"isDraft"` Sent []Action `xml:"sent"` - Recieved []Action `xml:"recieved"` + Recieved []Action `xml:"received"` } func (m *Meta) Earliest() *Date { @@ -56,14 +57,23 @@ func (m Meta) String() string { return string(json) } -func (m Meta) SendRecieved() iter.Seq2[*Action, *Action] { - return func(yield func(*Action, *Action) bool) { +type SendRecievedPair struct { + Sent *Action + Received *Action +} + +func (m Meta) SendReceivedPairs() []SendRecievedPair { + return slices.Collect(m.SendRecieved()) +} + +func (m Meta) SendRecieved() iter.Seq[SendRecievedPair] { + return func(yield func(SendRecievedPair) bool) { for i, sent := range m.Sent { var rec *Action if i < len(m.Recieved) { rec = &m.Recieved[i] } - if !yield(&sent, rec) { + if !yield(SendRecievedPair{Sent: &sent, Received: rec}) { return } }