package grasp import ( "bytes" "context" "fmt" "io" "net/http" "os" "os/exec" "strings" "sync" "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 := gs.getRepositoryPath(pubkey, 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.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/ 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 } } } }