From 078e9b4cc257c14bdd956f62aa7e98b4f5c2a171 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 15 Nov 2025 18:47:01 -0300 Subject: [PATCH] khatru: grasp server. --- go.mod | 8 +- go.sum | 6 +- khatru/examples/grasp/main.go | 33 ++ khatru/grasp/server.go | 764 ++++++++++++++++++++++++++++++++++ nip34/repository.go | 12 +- 5 files changed, 815 insertions(+), 8 deletions(-) create mode 100644 khatru/examples/grasp/main.go create mode 100644 khatru/grasp/server.go diff --git a/go.mod b/go.mod index ac7e771..aba6e89 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -require github.com/dgraph-io/ristretto/v2 v2.3.0 +require ( + github.com/dgraph-io/ristretto/v2 v2.3.0 + github.com/go-git/go-git/v5 v5.16.3 +) require ( github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect @@ -68,7 +71,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -79,7 +81,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index bbffb96..847afc6 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= +github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -214,8 +216,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= diff --git a/khatru/examples/grasp/main.go b/khatru/examples/grasp/main.go new file mode 100644 index 0000000..ad3139c --- /dev/null +++ b/khatru/examples/grasp/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "fiatjaf.com/nostr/eventstore/lmdb" + "fiatjaf.com/nostr/khatru" + "fiatjaf.com/nostr/khatru/grasp" +) + +func main() { + relay := khatru.NewRelay() + + db := &lmdb.LMDBBackend{Path: "/tmp/khatru-grasp-lmdb-tmp"} + os.MkdirAll(db.Path, 0o755) + if err := db.Init(); err != nil { + panic(err) + } + + relay.UseEventstore(db, 400) + + // create repository directory + repoDir := "/tmp/khatru-grasp-repos" + os.MkdirAll(repoDir, 0o755) + + // set up grasp server + grasp.New(relay, repoDir) + + fmt.Println("running grasp example on :3334") + http.ListenAndServe(":3334", relay) +} diff --git a/khatru/grasp/server.go b/khatru/grasp/server.go new file mode 100644 index 0000000..ee00f2a --- /dev/null +++ b/khatru/grasp/server.go @@ -0,0 +1,764 @@ +package grasp + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "syscall" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip19" + "fiatjaf.com/nostr/nip34" + + "fiatjaf.com/nostr/khatru" + "github.com/go-git/go-git/v5/plumbing/format/pktline" +) + +const zeroRef = "0000000000000000000000000000000000000000" + +var asciiPattern = regexp.MustCompile(`^[\w-.]+$`) + +type GraspServer struct { + ServiceURL string + RepositoryDir string + + Relay *khatru.Relay +} + +// New creates a new GraspServer and registers its handlers on the relay's router +func New(rl *khatru.Relay, repositoryDir string) *GraspServer { + gs := &GraspServer{ + Relay: rl, + RepositoryDir: repositoryDir, + } + + base := rl.Router() + mux := http.NewServeMux() + + // use specific route patterns for git endpoints + mux.HandleFunc("GET /{npub}/{repo}/info/refs", func(w http.ResponseWriter, r *http.Request) { + gs.handleGitRequest(w, r, base, func(w http.ResponseWriter, r *http.Request, pubkey nostr.PubKey, repoName string) { + gs.handleInfoRefs(w, r, pubkey, repoName) + }) + }) + + mux.HandleFunc("POST /{npub}/{repo}/git-upload-pack", func(w http.ResponseWriter, r *http.Request) { + gs.handleGitRequest(w, r, base, func(w http.ResponseWriter, r *http.Request, pubkey nostr.PubKey, repoName string) { + gs.handleGitUploadPack(w, r, pubkey, repoName) + }) + }) + + mux.HandleFunc("POST /{npub}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) { + gs.handleGitRequest(w, r, base, func(w http.ResponseWriter, r *http.Request, pubkey nostr.PubKey, repoName string) { + gs.handleGitReceivePack(w, r, pubkey, repoName) + }) + }) + + mux.HandleFunc("GET /{npub}/{repo}", func(w http.ResponseWriter, r *http.Request) { + gs.handleGitRequest(w, r, base, func(w http.ResponseWriter, r *http.Request, pubkey nostr.PubKey, repoName string) { + if r.URL.RawQuery == "" { + if gs.repoExists(pubkey, repoName) { + gs.serveRepoPage(w, r, r.PathValue("npub"), repoName) + } else { + http.NotFound(w, r) + } + } else { + base.ServeHTTP(w, r) + } + }) + }) + + // fallback handler for all other paths + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + base.ServeHTTP(w, r) + }) + + rl.SetRouter(mux) + + return gs +} + +// handleGitRequest validates .git suffix and decodes npub, then calls the handler +func (gs *GraspServer) handleGitRequest( + w http.ResponseWriter, + r *http.Request, + base http.Handler, + handler func(http.ResponseWriter, + *http.Request, + nostr.PubKey, + string, + ), +) { + npub := r.PathValue("npub") + repoWithGit := r.PathValue("repo") + + // validate .git suffix + if !strings.HasSuffix(repoWithGit, ".git") { + base.ServeHTTP(w, r) + return + } + + repoName := strings.TrimSuffix(repoWithGit, ".git") + + // validate repo name + if !asciiPattern.MatchString(repoName) { + http.Error(w, "invalid repository name", http.StatusBadRequest) + return + } + + // decode npub to pubkey + _, value, err := nip19.Decode(npub) + if err != nil { + http.Error(w, "invalid npub", http.StatusBadRequest) + return + } + pk, ok := value.(nostr.PubKey) + if !ok { + http.Error(w, "invalid npub", http.StatusBadRequest) + return + } + + handler(w, r, pk, repoName) +} + +// handleInfoRefs handles the git info/refs endpoint +func (gs *GraspServer) handleInfoRefs( + w http.ResponseWriter, + r *http.Request, + pubkey nostr.PubKey, + repoName string, +) { + serviceName := r.URL.Query().Get("service") + + switch serviceName { + case "git-upload-pack": + if !gs.repoExists(pubkey, repoName) { + gs.gitError(w, "repository not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") + w.Header().Set("Connection", "Keep-Alive") + w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + w.WriteHeader(http.StatusOK) + + repoPath := filepath.Join(gs.RepositoryDir, repoName) + if err := gs.runInfoRefs(w, r, repoPath); err != nil { + fmt.Printf("runInfoRefs error: %s\n", err) + return + } + case "git-receive-pack": + // for receive-pack on non-existent repos, send fake advertisement to allow initial push + if !gs.repoExists(pubkey, repoName) { + w.Header().Set("content-type", "text/plain; charset=UTF-8") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, "couldn't find the specified repository '%s' for '%s', you must publish its NIP-34 events here first\n", repoName, pubkey.Hex()) + return + } + w.Header().Set("content-type", "application/x-git-receive-pack-advertisement") + v, _ := base64.StdEncoding.DecodeString("MDAxZiMgc2VydmljZT1naXQtcmVjZWl2ZS1wYWNrCjAwMDAwMGIxMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCBjYXBhYmlsaXRpZXNee30AcmVwb3J0LXN0YXR1cyByZXBvcnQtc3RhdHVzLXYyIGRlbGV0ZS1yZWZzIHNpZGUtYmFuZC02NGsgcXVpZXQgYXRvbWljIG9mcy1kZWx0YSBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDMuMAowMDAw") + w.Write(v) + default: + gs.gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) + } +} + +func (gs *GraspServer) handleGitUploadPack( + w http.ResponseWriter, + r *http.Request, + pubkey nostr.PubKey, + repoName string, +) { + repoPath := filepath.Join(gs.RepositoryDir, repoName) + + // for upload-pack (pull), check if repository exists + if !gs.repoExists(pubkey, repoName) { + gs.gitError(w, "repository not found", http.StatusNotFound) + return + } + + const expectedContentType = "application/x-git-upload-pack-request" + contentType := r.Header.Get("Content-Type") + if contentType != expectedContentType { + gs.gitError(w, fmt.Sprintf("expected Content-Type: '%s', but received '%s'", expectedContentType, contentType), http.StatusUnsupportedMediaType) + return + } + + var bodyReader io.ReadCloser = r.Body + if r.Header.Get("Content-Encoding") == "gzip" { + gzipReader, err := gzip.NewReader(r.Body) + if err != nil { + gs.gitError(w, err.Error(), http.StatusInternalServerError) + fmt.Printf("git: failed to create gzip reader, handler: UploadPack, error: %v\n", err) + return + } + defer gzipReader.Close() + bodyReader = gzipReader + } + + w.Header().Set("Content-Type", "application/x-git-upload-pack-result") + w.Header().Set("Connection", "Keep-Alive") + w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + w.WriteHeader(http.StatusOK) + + fmt.Printf("git: executing git-upload-pack, handler: UploadPack, repo: %s\n", repoPath) + + if err := gs.runUploadPack(w, r, repoPath, bodyReader); err != nil { + fmt.Printf("git: failed to execute git-upload-pack, handler: UploadPack, error: %v\n", err) + return + } +} + +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 !gs.validatePush(r.Context(), pubkey, repoName, body.Bytes()) { + gs.gitError(w, "unauthorized push", http.StatusForbidden) + return + } + + repoPath := filepath.Join(gs.RepositoryDir, repoName) + + // ensure repository directory exists + if err := os.MkdirAll(repoPath, 0755); err != nil { + gs.gitError(w, fmt.Sprintf("failed to create repository: %s", err), http.StatusInternalServerError) + return + } + + // initialize git repo if .git doesn't exist + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = repoPath + if output, err := cmd.CombinedOutput(); err != nil { + gs.gitError(w, fmt.Sprintf("failed to initialize repository: %s, output: %s", err, string(output)), http.StatusInternalServerError) + return + } + fmt.Printf("initialized new git repository at %s\n", repoPath) + } + + 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") + w.WriteHeader(http.StatusOK) + + if err := gs.runReceivePack(w, r, repoPath, io.NopCloser(bytes.NewReader(body.Bytes()))); err != nil { + fmt.Printf("runReceivePack error: %s\n", err) + return + } + + // update HEAD per state announcement + if err := gs.updateHEAD(r.Context(), pubkey, repoName, repoPath); err != nil { + fmt.Printf("failed to update HEAD: %s\n", err) + // don't fail the push, just log + } + + // cleanup merged patches + go gs.cleanupMergedPatches(r.Context(), pubkey, repoName, repoPath) +} + +// validatePush checks if a push is authorized via NIP-34 repository state events +func (gs *GraspServer) validatePush( + ctx context.Context, + pubkey nostr.PubKey, + repoName string, + bodyBytes []byte, +) bool { + // query for repository state events (kind 30618) + if gs.Relay.QueryStored == nil { + fmt.Printf("relay has no QueryStored function\n") + return false + } + + // check state + 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, + }) { + state = nip34.ParseRepositoryState(evt) + } + if state.Event.ID == nostr.ZeroID { + return false + } + + // get repository announcement to check maintainers + var announcement nip34.Repository + for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement}, + Authors: []nostr.PubKey{pubkey}, + Tags: nostr.TagMap{"d": []string{repoName}}, + Limit: 1, + }) { + announcement = nip34.ParseRepository(evt) + } + if announcement.Event.ID == nostr.ZeroID { + return false + } + + // ensure pusher is authorized (owner or maintainer) + if pubkey != announcement.PubKey && !slices.Contains(announcement.Maintainers, pubkey) { + return false + } + + // parse pktline to extract and validate all push refs + pkt := pktline.NewScanner(bytes.NewReader(bodyBytes)) + for pkt.Scan() { + if err := pkt.Err(); err != nil { + fmt.Printf("invalid pkt: %v\n", err) + return false + } + line := string(pkt.Bytes()) + if len(line) < 40 { + continue + } + + spl := strings.Split(line, " ") + from := spl[0] + to := spl[1] + ref := strings.TrimRight(spl[2], "\x00") + + // handle refs/nostr/ pushes + if strings.HasPrefix(ref, "refs/nostr/") { + // query for the event + eventId := ref[11:] + id, err := nostr.IDFromHex(eventId) + if err != nil { + fmt.Printf("push rejected: invalid event id %s\n", eventId) + return false + } + var foundEvent bool + for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{ + IDs: []nostr.ID{id}, + }) { + // check if event has a "c" tag matching the commit + hasMatchingCommit := false + for _, tag := range evt.Tags { + if tag[0] == "c" && len(tag) > 1 && tag[1] == to { + hasMatchingCommit = true + break + } + } + if !hasMatchingCommit { + fmt.Printf("push rejected: event %s has different tip (expected %s)\n", eventId, to) + return false + } + foundEvent = true + break + } + if !foundEvent { + fmt.Printf("push rejected: event %s not found\n", eventId) + return false + } + continue + } + + // validate branch pushes + if strings.HasPrefix(ref, "refs/heads/") { + branchName := ref[11:] + // pushing a branch + if commitId, exists := state.Branches[branchName]; exists && to == commitId { + fmt.Printf("push accepted: %s %s->%s\n", ref, from, to) + continue + } + // deleting a branch + if _, exists := state.Branches[branchName]; to == zeroRef && !exists { + fmt.Printf("delete accepted: %s\n", ref) + continue + } + fmt.Printf("push unauthorized: ref %s %s->%s does not match state\n", ref, from, to) + return false + } + + // validate tag pushes + if strings.HasPrefix(ref, "refs/tags/") { + tagName := ref[10:] + // pushing a tag + if commitId, exists := state.Tags[tagName]; exists && to == commitId { + fmt.Printf("push accepted: %s %s->%s\n", ref, from, to) + continue + } + // deleting a tag + if _, exists := state.Tags[tagName]; to == zeroRef && !exists { + fmt.Printf("delete accepted: %s\n", ref) + continue + } + fmt.Printf("push unauthorized: ref %s %s->%s does not match state\n", ref, from, to) + return false + } + } + + return true +} + +// repoExists checks if a repository has an announcement event (kind 30617) +func (gs *GraspServer) repoExists(pubkey nostr.PubKey, repoName string) bool { + for range gs.Relay.QueryStored(context.Background(), nostr.Filter{ + Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement}, + Authors: []nostr.PubKey{pubkey}, + Tags: nostr.TagMap{"d": []string{repoName}}, + }) { + return true + } + return false +} + +// runInfoRefs executes git-upload-pack with --http-backend-info-refs +func (gs *GraspServer) runInfoRefs(w http.ResponseWriter, r *http.Request, repoPath string) error { + cmd := exec.Command("git", + "-c", "uploadpack.allowReachableSHA1InWant=true", + "-c", "uploadpack.allowTipSHA1InWant=true", + "upload-pack", "--stateless-rpc", "--http-backend-info-refs", ".") + 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) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start git-upload-pack: %w", err) + } + + // write pack line header only if not git protocol v2 + if !strings.Contains(r.Header.Get("Git-Protocol"), "version=2") { + if err := gs.packLine(w, "# service=git-upload-pack\n"); err != nil { + return fmt.Errorf("failed to write pack line: %w", err) + } + if err := gs.packFlush(w); err != nil { + return fmt.Errorf("failed to flush pack: %w", err) + } + } + + io.Copy(gs.newWriteFlusher(w), stdoutPipe) + stdoutPipe.Close() + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String()) + } + + return nil +} + +// runUploadPack executes git-upload-pack for pull operations +func (gs *GraspServer) runUploadPack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error { + cmd := exec.Command("git", + "-c", "uploadpack.allowFilter=true", + "-c", "uploadpack.allowReachableSHA1InWant=true", + "-c", "uploadpack.allowTipSHA1InWant=true", + "upload-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-upload-pack: %w", err) + } + + // copy input to stdin + go func() { + defer stdinPipe.Close() + io.Copy(stdinPipe, bodyReader) + }() + + // copy output to response + io.Copy(gs.newWriteFlusher(w), stdoutPipe) + stdoutPipe.Close() + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String()) + } + + return nil +} + +// 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) + } + + // copy input to stdin + go func() { + defer stdinPipe.Close() + io.Copy(stdinPipe, bodyReader) + }() + + // copy output to response + io.Copy(gs.newWriteFlusher(w), stdoutPipe) + stdoutPipe.Close() + + 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)) + } + + fmt.Printf("updated HEAD to %s for repo %s\n", latestState.HEAD, repoName) + 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 { + fmt.Printf("failed to delete ref %s: %s\n", ref, err) + } else { + fmt.Printf("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 { + fmt.Printf("failed to delete ref %s: %s\n", ref, err) + } else { + fmt.Printf("deleted ref %s (merged into %s)\n", ref, branchName) + } + break + } + } + } +} + +// serveRepoPage serves a webpage for the repository +func (gs *GraspServer) serveRepoPage(w http.ResponseWriter, r *http.Request, npub, repoName string) { + w.Header().Set("Content-Type", "text/html") + html := fmt.Sprintf(` + + + %s/%s - NIP-34 Git Repository + + + +

Repository: %s/%s

+
+

This is a NIP-34 git repository served over Nostr.

+
+

Clone this repository

+

Use a git-nostr client to clone:

+
git clone %s/%s/%s.git
+

Browse

+

Use a git-nostr web client or Nostr client to browse this repository.

+ +`, npub, repoName, npub, repoName, r.Host, npub, repoName) + fmt.Fprint(w, html) +} + +// gitError writes a git error response +func (gs *GraspServer) gitError(w http.ResponseWriter, msg string, status int) { + w.Header().Set("content-type", "text/plain; charset=UTF-8") + w.WriteHeader(status) + fmt.Fprintf(w, "%s\n", msg) +} + +// packLine writes a pktline formatted line +func (gs *GraspServer) packLine(w io.Writer, s string) error { + _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) + return err +} + +// packFlush writes a pktline flush +func (gs *GraspServer) packFlush(w io.Writer) error { + _, err := fmt.Fprint(w, "0000") + return err +} + +// newWriteFlusher creates a write flusher for streaming responses +func (gs *GraspServer) newWriteFlusher(w http.ResponseWriter) io.Writer { + return writeFlusher{w.(interface { + io.Writer + http.Flusher + })} +} + +type writeFlusher struct { + wf interface { + io.Writer + http.Flusher + } +} + +func (w writeFlusher) Write(p []byte) (int, error) { + defer w.wf.Flush() + return w.wf.Write(p) +} diff --git a/nip34/repository.go b/nip34/repository.go index e53fade..d524d06 100644 --- a/nip34/repository.go +++ b/nip34/repository.go @@ -14,7 +14,7 @@ type Repository struct { Clone []string Relays []string EarliestUniqueCommitID string - Maintainers []string + Maintainers []nostr.PubKey } func ParseRepository(event nostr.Event) Repository { @@ -42,7 +42,11 @@ func ParseRepository(event nostr.Event) Repository { case "r": repo.EarliestUniqueCommitID = tag[1] case "maintainers": - repo.Maintainers = append(repo.Maintainers, tag[1:]...) + for _, pkh := range tag[1:] { + if pk, err := nostr.PubKeyFromHex(pkh); err == nil { + repo.Maintainers = append(repo.Maintainers, pk) + } + } } } @@ -66,7 +70,9 @@ func (r Repository) ToEvent() nostr.Event { if len(r.Maintainers) > 0 { tag := make(nostr.Tag, 1, 1+len(r.Maintainers)) tag[0] = "maintainers" - tag = append(tag, r.Maintainers...) + for _, pk := range r.Maintainers { + tag = append(tag, pk.Hex()) + } tags = append(tags, tag) } if len(r.Web) > 0 {