grasp: spread stuff into multiple files.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
package grasp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip34"
|
||||
)
|
||||
|
||||
func (gs *GraspServer) handleGitReceivePack(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
pubkey nostr.PubKey,
|
||||
repoName string,
|
||||
) {
|
||||
// for receive-pack (push), validate authorization via NIP-34 events
|
||||
body := &bytes.Buffer{}
|
||||
io.Copy(body, r.Body)
|
||||
|
||||
if err := gs.validatePush(r.Context(), pubkey, repoName, body.Bytes()); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "unauthorized push: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if gs.OnWrite != nil {
|
||||
reject, msg := gs.OnWrite(r.Context(), pubkey, repoName)
|
||||
if reject {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "%s\n", msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
repoPath := filepath.Join(gs.RepositoryDir, repoName)
|
||||
|
||||
// initialize git repo if it doesn't exist
|
||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(repoPath, 0755); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprintf(w, "failed to create repository: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "init", "--bare")
|
||||
cmd.Dir = repoPath
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprintf(w, "failed to initialize repository: %s, output: %s\n", err, string(output))
|
||||
return
|
||||
}
|
||||
|
||||
// disable denyNonFastForwards and denyCurrentBranch to allow force pushes
|
||||
for _, config := range []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"receive.denyNonFastForwards", "false"},
|
||||
{"receive.denyCurrentBranch", "updateInstead"},
|
||||
{"uploadpack.allowReachableSHA1InWant", "true"},
|
||||
{"uploadpack.allowTipSHA1InWant", "true"},
|
||||
} {
|
||||
cmd = exec.Command("git", "config", config.key, config.value)
|
||||
cmd.Dir = repoPath
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprintf(w, "failed to configure repository with %s=%s: %s, output: %s\n",
|
||||
config.key, config.value, err, string(output))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
|
||||
w.Header().Set("Connection", "Keep-Alive")
|
||||
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
||||
|
||||
if err := gs.runReceivePack(w, r, repoPath, io.NopCloser(bytes.NewReader(body.Bytes()))); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "runReceivePack: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// update HEAD per state announcement
|
||||
if err := gs.updateHEAD(r.Context(), pubkey, repoName, repoPath); err != nil {
|
||||
w.Header().Set("content-type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(403)
|
||||
fmt.Fprintf(w, "failed to update HEAD: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// cleanup merged patches
|
||||
go gs.cleanupMergedPatches(r.Context(), pubkey, repoName, repoPath)
|
||||
}
|
||||
|
||||
// runReceivePack executes git-receive-pack for push operations
|
||||
func (gs *GraspServer) runReceivePack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error {
|
||||
cmd := exec.Command("git", "receive-pack", "--stateless-rpc", ".")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Dir = repoPath
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start git-receive-pack: %w", err)
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
// copy input to stdin
|
||||
wg.Go(func() {
|
||||
defer stdinPipe.Close()
|
||||
if _, err := io.Copy(stdinPipe, bodyReader); err != nil {
|
||||
gs.Log("failed to copy to stdin pipe: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
// copy output to response
|
||||
wg.Go(func() {
|
||||
defer stdoutPipe.Close()
|
||||
if _, err := io.Copy(gs.newWriteFlusher(w), stdoutPipe); err != nil {
|
||||
gs.Log("failed to copy to write flusher: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("git-receive-pack failed: %w, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateHEAD updates the repository HEAD based on the latest state announcement
|
||||
func (gs *GraspServer) updateHEAD(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) error {
|
||||
if gs.Relay.QueryStored == nil {
|
||||
return fmt.Errorf("no QueryStored function")
|
||||
}
|
||||
|
||||
// query for the latest state event
|
||||
var latestState *nip34.RepositoryState
|
||||
for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindRepositoryState},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Tags: nostr.TagMap{"d": []string{repoName}},
|
||||
Limit: 1,
|
||||
}) {
|
||||
state := nip34.ParseRepositoryState(evt)
|
||||
latestState = &state
|
||||
break
|
||||
}
|
||||
|
||||
if latestState == nil || latestState.HEAD == "" {
|
||||
// no state or no HEAD specified
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify the HEAD branch exists in the state
|
||||
if _, exists := latestState.Branches[latestState.HEAD]; !exists {
|
||||
return fmt.Errorf("HEAD branch %s not found in state", latestState.HEAD)
|
||||
}
|
||||
|
||||
// update HEAD using git symbolic-ref
|
||||
cmd := exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/"+latestState.HEAD)
|
||||
cmd.Dir = repoPath
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to update HEAD: %w, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupMergedPatches removes refs/nostr/<event-id> refs that have been merged into branches
|
||||
func (gs *GraspServer) cleanupMergedPatches(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) {
|
||||
// use background context since request context will be cancelled
|
||||
ctx = context.Background()
|
||||
|
||||
// wait 20 minutes before cleanup to allow events to propagate
|
||||
time.Sleep(20 * time.Minute)
|
||||
|
||||
if gs.Relay.QueryStored == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// get current state to know which branches exist
|
||||
var state *nip34.RepositoryState
|
||||
for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindRepositoryState},
|
||||
Authors: []nostr.PubKey{pubkey},
|
||||
Tags: nostr.TagMap{"d": []string{repoName}},
|
||||
Limit: 1,
|
||||
}) {
|
||||
parsed := nip34.ParseRepositoryState(evt)
|
||||
state = &parsed
|
||||
break
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// list all refs/nostr/* refs
|
||||
cmd := exec.Command("git", "for-each-ref", "--format=%(refname)", "refs/nostr")
|
||||
cmd.Dir = repoPath
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// no refs/nostr refs, nothing to clean up
|
||||
return
|
||||
}
|
||||
|
||||
refs := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, ref := range refs {
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
eventId := strings.TrimPrefix(ref, "refs/nostr/")
|
||||
id, err := nostr.IDFromHex(eventId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// check if there's still a valid patch event with a "c" tag referencing this commit
|
||||
hasValidEvent := false
|
||||
for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
|
||||
IDs: []nostr.ID{id},
|
||||
}) {
|
||||
// check if event has a "c" tag
|
||||
for _, tag := range evt.Tags {
|
||||
if tag[0] == "c" && len(tag) > 1 {
|
||||
hasValidEvent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !hasValidEvent {
|
||||
// no valid event, delete the ref
|
||||
cmd := exec.Command("git", "update-ref", "-d", ref)
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
gs.Log("failed to delete ref %s: %s\n", ref, err)
|
||||
} else {
|
||||
gs.Log("deleted ref %s (no corresponding event)\n", ref)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// check if the commit is merged into any branch
|
||||
for branchName, commitId := range state.Branches {
|
||||
// get the commit ID for this ref
|
||||
cmd := exec.Command("git", "rev-parse", ref)
|
||||
cmd.Dir = repoPath
|
||||
refCommit, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if ref commit is ancestor of branch head
|
||||
cmd = exec.Command("git", "merge-base", "--is-ancestor", strings.TrimSpace(string(refCommit)), commitId)
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err == nil {
|
||||
// it's merged! delete the ref
|
||||
cmd := exec.Command("git", "update-ref", "-d", ref)
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
gs.Log("failed to delete ref %s: %s\n", ref, err)
|
||||
} else {
|
||||
gs.Log("deleted ref %s (merged into %s)\n", ref, branchName)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user