mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-21 14:41:34 +00:00
Add Forgejo webhook compatible POST endpoint.
Co-authored-by: bin <flumf@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
80
fetch.go
80
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")}
|
||||
}
|
||||
}
|
||||
|
||||
160
serve.go
160
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)
|
||||
|
||||
Reference in New Issue
Block a user