From a3eca4f639b0b720f8aa84630bbba6d0636a89b7 Mon Sep 17 00:00:00 2001 From: Catherine Date: Sun, 14 Sep 2025 19:53:58 +0000 Subject: [PATCH] Add Forgejo webhook compatible POST endpoint. Co-authored-by: bin --- README.md | 3 +- fetch.go | 80 ++++++++++++++++++--------- serve.go | 160 +++++++++++++++++++++++++++++++++++++++--------------- 3 files changed, 172 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a49188d..1427841 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ This is a simple Go service implemented as a strawman proposal of how https://co Features -------- -* In response to a `PUT` request, performs a shallow in-memory clone of a git repository, checks out a tree to the filesystem, and atomically updates the version of content being served. +* In response to a `PUT` or `POST` request, performs a shallow in-memory clone of a git repository, checks out a tree to the filesystem, and atomically updates the version of content being served. + - `PUT` method is a custom REST endpoint, `POST` method is a Forgejo webhook endpoint. * In response to a `GET` request, selects an appropriate tree and serves files from it. Supported URL patterns: - `https://domain.tld/project/` (routed to project-specific tree) - `https://domain.tld/` (routed to domain-specific tree by exclusion) diff --git a/fetch.go b/fetch.go index 8c287a0..bbed199 100644 --- a/fetch.go +++ b/fetch.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/go-git/go-billy/v6/osfs" "github.com/go-git/go-git/v6" @@ -14,15 +15,22 @@ import ( "github.com/go-git/go-git/v6/storage/memory" ) -type FetchResult int +type FetchOutcome int const ( - FetchError FetchResult = iota + FetchError FetchOutcome = iota + FetchTimeout FetchCreated FetchUpdated FetchNoChange ) +type FetchResult struct { + outcome FetchOutcome + head string + err error +} + func splitHash(hash plumbing.Hash) string { head := hash.String() return filepath.Join(head[:2], head[2:]) @@ -33,7 +41,7 @@ func fetch( webRoot string, repoURL string, branch string, -) (*plumbing.Hash, FetchResult, error) { +) FetchResult { storer := memory.NewStorage() repo, err := git.Clone(storer, nil, &git.CloneOptions{ @@ -44,12 +52,12 @@ func fetch( Tags: git.NoTags, }) if err != nil { - return nil, 0, fmt.Errorf("git clone: %s", err) + return FetchResult{err: fmt.Errorf("git clone: %s", err)} } ref, err := repo.Head() if err != nil { - return nil, 0, fmt.Errorf("git head: %s", err) + return FetchResult{err: fmt.Errorf("git head: %s", err)} } head := ref.Hash() @@ -58,33 +66,33 @@ func fetch( // check out to a temporary directory to avoid TOCTTOU race on destDir tempDir, err := os.MkdirTemp(dataDir, ".tree") if err != nil { - return nil, 0, fmt.Errorf("mkdir temp: %s", err) + return FetchResult{err: fmt.Errorf("mkdir temp: %s", err)} } defer os.RemoveAll(tempDir) repo, err = git.Open(storer, osfs.New(tempDir, osfs.WithBoundOS())) if err != nil { - return nil, 0, fmt.Errorf("git open: %s", err) + return FetchResult{err: fmt.Errorf("git open: %s", err)} } worktree, err := repo.Worktree() if err != nil { - return nil, 0, fmt.Errorf("git worktree: %s", err) + return FetchResult{err: fmt.Errorf("git worktree: %s", err)} } if err := worktree.Checkout(&git.CheckoutOptions{ Hash: head, }); err != nil { - return nil, 0, fmt.Errorf("git checkout: %s", err) + return FetchResult{err: fmt.Errorf("git checkout: %s", err)} } if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { - return nil, 0, fmt.Errorf("mkdir parent dest: %s", err) + return FetchResult{err: fmt.Errorf("mkdir parent dest: %s", err)} } // commit atomically; assume another fetch has won the race if directory exists if err := os.Rename(tempDir, destDir); err != nil && !errors.Is(err, os.ErrExist) { - return nil, 0, fmt.Errorf("rename dest: %s", err) + return FetchResult{err: fmt.Errorf("rename dest: %s", err)} } } @@ -94,32 +102,32 @@ func fetch( tempLink := filepath.Join(dataDir, fmt.Sprintf(".link.%s.%s", strings.ReplaceAll(webRoot, "/", ".."), head.String())) if err := os.Symlink(destDirRel, tempLink); err != nil { - return nil, 0, fmt.Errorf("symlink temp: %s", err) + return FetchResult{err: fmt.Errorf("symlink temp: %s", err)} } defer os.Remove(tempLink) if err := os.MkdirAll(filepath.Dir(webLink), 0o755); err != nil { - return nil, 0, fmt.Errorf("mkdir parent web: %s", err) + return FetchResult{err: fmt.Errorf("mkdir parent web: %s", err)} } // this status is advisory only (is subject to race conditions); it's used only // to return the correct HTTP status per the spec - fetchResult := FetchCreated + outcome := FetchCreated if existingLink, err := os.Readlink(webLink); err == nil { if existingLink != destDirRel { - fetchResult = FetchUpdated + outcome = FetchUpdated } else { - fetchResult = FetchNoChange + outcome = FetchNoChange } } // commit atomically; assume another fetch has won the race if symlink exists // FIXME: might not have the same target if err := os.Rename(tempLink, webLink); err != nil && !errors.Is(err, os.ErrExist) { - return nil, 0, fmt.Errorf("rename web: %s", err) + return FetchResult{err: fmt.Errorf("rename web: %s", err)} } - return &head, fetchResult, nil + return FetchResult{outcome: outcome, head: head.String(), err: nil} } func Fetch( @@ -127,12 +135,12 @@ func Fetch( webRoot string, repoURL string, branch string, -) (string, FetchResult, error) { +) FetchResult { log.Println("fetch:", webRoot, repoURL, branch) - head, result, err := fetch(dataDir, webRoot, repoURL, branch) - if err == nil { + result := fetch(dataDir, webRoot, repoURL, branch) + if result.err == nil { status := "" - switch result { + switch result.outcome { case FetchCreated: status = "created" case FetchUpdated: @@ -140,10 +148,30 @@ func Fetch( case FetchNoChange: status = "unchanged" } - log.Println("fetch ok:", webRoot, head, status) - return head.String(), result, err + log.Println("fetch ok:", webRoot, result.head, status) } else { - log.Println("fetch err:", fmt.Errorf("%s: %s", webRoot, err)) - return "", FetchError, err + log.Println("fetch err:", fmt.Errorf("%s: %s", webRoot, result.err)) + } + return result +} + +func FetchWithTimeout( + dataDir string, + webRoot string, + repoURL string, + branch string, + timeout time.Duration, +) FetchResult { + // fetch the updated content with a timeout + c := make(chan FetchResult, 1) + go func() { + result := Fetch(dataDir, webRoot, repoURL, branch) + c <- result + }() + select { + case result := <-c: + return result + case <-time.After(timeout): + return FetchResult{outcome: FetchTimeout, err: fmt.Errorf("fetch timeout")} } } diff --git a/serve.go b/serve.go index 7a9f033..cf72141 100644 --- a/serve.go +++ b/serve.go @@ -2,6 +2,10 @@ package main import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -93,30 +97,32 @@ func getPage(dataDir string, w http.ResponseWriter, r *http.Request) error { return err } -type putResult struct { - head string - result FetchResult - err error -} - -func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { - host := getHost(r) - +func getProjectName(w http.ResponseWriter, r *http.Request) (string, error) { // path must be either `/` or `/foo/` (`/foo` is accepted as an alias) path, _ := strings.CutPrefix(r.URL.Path, "/") path, _ = strings.CutSuffix(path, "/") if strings.HasPrefix(path, ".") { http.Error(w, "this directory name is reserved for system use", http.StatusBadRequest) - return fmt.Errorf("reserved name") + return "", fmt.Errorf("reserved name") } else if strings.Contains(path, "/") { http.Error(w, "only one level of nesting is allowed", http.StatusBadRequest) - return fmt.Errorf("nesting too deep") + return "", fmt.Errorf("nesting too deep") } - // path `/` corresponds to pseudo-project `.index` - projectName := ".index" - if path != "" { - projectName = path + if path == "" { + // path `/` corresponds to pseudo-project `.index` + return ".index", nil + } else { + return path, nil + } +} + +func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { + host := getHost(r) + + projectName, err := getProjectName(w, r) + if err != nil { + return err } requestBody, err := io.ReadAll(r.Body) @@ -133,36 +139,100 @@ func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error { branch = "pages" } - // fetch the updated content with a timeout - c := make(chan putResult, 1) - go func() { - head, result, err := Fetch(dataDir, webRoot, repoURL, branch) - c <- putResult{head, result, err} - }() - select { - case putResult := <-c: - if putResult.err == nil { - w.Header().Add("Content-Location", r.URL.String()) - } - switch putResult.result { - case FetchError: - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintln(w, putResult.err) - return putResult.err - // HTTP prescribes these response codes to be used - case FetchNoChange: - w.WriteHeader(http.StatusNoContent) - case FetchCreated: - w.WriteHeader(http.StatusCreated) - case FetchUpdated: - w.WriteHeader(http.StatusOK) - } - fmt.Fprintln(w, putResult.head) - return nil - case <-time.After(fetchTimeout): - w.WriteHeader(http.StatusGatewayTimeout) - return fmt.Errorf("fetch timeout") + result := FetchWithTimeout(dataDir, webRoot, repoURL, branch, fetchTimeout) + if result.err == nil { + w.Header().Add("Content-Location", r.URL.String()) } + switch result.outcome { + case FetchError: + w.WriteHeader(http.StatusServiceUnavailable) + case FetchTimeout: + w.WriteHeader(http.StatusGatewayTimeout) + // HTTP prescribes these response codes to be used + case FetchNoChange: + w.WriteHeader(http.StatusNoContent) + case FetchCreated: + w.WriteHeader(http.StatusCreated) + case FetchUpdated: + w.WriteHeader(http.StatusOK) + } + if result.err != nil { + fmt.Fprintln(w, result.err) + } else { + fmt.Fprintln(w, result.head) + } + return result.err +} + +const hmacSecret = "" + +func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error { + host := getHost(r) + + projectName, err := getProjectName(w, r) + if err != nil { + return err + } + + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "only JSON payload is allowed", http.StatusBadRequest) + return fmt.Errorf("invalid content type") + } + + if r.Header.Get("X-Forgejo-Event") != "push" { + http.Error(w, "only push events are allowed", http.StatusBadRequest) + return fmt.Errorf("invalid event") + } + + requestBody, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("body read: %s", err) + } + + if hmacSecret != "" { + signature, err := hex.DecodeString(r.Header.Get("X-Forgejo-Signature")) + if err != nil { + http.Error(w, "malformed signature", http.StatusBadRequest) + return fmt.Errorf("malformed signature") + } + + mac := hmac.New(sha256.New, []byte(hmacSecret)) + mac.Write(requestBody) + if !hmac.Equal(mac.Sum(nil), signature) { + http.Error(w, "invalid signature", http.StatusBadRequest) + return fmt.Errorf("invalid hmac") + } + } + + var event map[string]any + err = json.NewDecoder(bytes.NewReader(requestBody)).Decode(&event) + if err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %s", err), http.StatusBadRequest) + return err + } + + eventRef := event["ref"].(string) + if eventRef != "refs/heads/pages" { + w.WriteHeader(http.StatusOK) + return nil + } + + webRoot := fmt.Sprintf("%s/%s", host, projectName) + repoURL := event["repository"].(map[string]any)["clone_url"].(string) + + result := FetchWithTimeout(dataDir, webRoot, repoURL, "pages", fetchTimeout) + switch result.outcome { + case FetchError: + w.WriteHeader(http.StatusServiceUnavailable) + case FetchTimeout: + w.WriteHeader(http.StatusGatewayTimeout) + default: + w.WriteHeader(http.StatusOK) + } + if result.err != nil { + fmt.Fprintln(w, result.err) + } + return result.err } func Serve(dataDir string) func(http.ResponseWriter, *http.Request) { @@ -174,6 +244,8 @@ func Serve(dataDir string) func(http.ResponseWriter, *http.Request) { err = getPage(dataDir, w, r) case http.MethodPut: err = putPage(dataDir, w, r) + case http.MethodPost: + err = postPage(dataDir, w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) err = fmt.Errorf("method %s not allowed", r.Method)