Monorepo for Tangled tangled.org

knotserver/xrpc: define handlers for all ops

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

anirudh.fi 6a968a8f 77474ceb

verified
-1
knotserver/handler.go
··· 139 139 // Create a new repository. 140 140 r.Route("/repo", func(r chi.Router) { 141 141 r.Use(h.VerifySignature) 142 - r.Put("/new", h.NewRepo) 143 142 r.Delete("/", h.RemoveRepo) 144 143 r.Route("/fork", func(r chi.Router) { 145 144 r.Post("/", h.RepoFork)
-62
knotserver/routes.go
··· 22 22 securejoin "github.com/cyphar/filepath-securejoin" 23 23 "github.com/gliderlabs/ssh" 24 24 "github.com/go-chi/chi/v5" 25 - gogit "github.com/go-git/go-git/v5" 26 25 "github.com/go-git/go-git/v5/plumbing" 27 26 "github.com/go-git/go-git/v5/plumbing/object" 28 27 "tangled.sh/tangled.sh/core/hook" ··· 646 645 w.WriteHeader(http.StatusNoContent) 647 646 return 648 647 } 649 - } 650 - 651 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 652 - l := h.l.With("handler", "NewRepo") 653 - 654 - data := struct { 655 - Did string `json:"did"` 656 - Name string `json:"name"` 657 - DefaultBranch string `json:"default_branch,omitempty"` 658 - }{} 659 - 660 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 661 - writeError(w, "invalid request body", http.StatusBadRequest) 662 - return 663 - } 664 - 665 - if data.DefaultBranch == "" { 666 - data.DefaultBranch = h.c.Repo.MainBranch 667 - } 668 - 669 - did := data.Did 670 - name := data.Name 671 - defaultBranch := data.DefaultBranch 672 - 673 - if err := validateRepoName(name); err != nil { 674 - l.Error("creating repo", "error", err.Error()) 675 - writeError(w, err.Error(), http.StatusBadRequest) 676 - return 677 - } 678 - 679 - relativeRepoPath := filepath.Join(did, name) 680 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 681 - err := git.InitBare(repoPath, defaultBranch) 682 - if err != nil { 683 - l.Error("initializing bare repo", "error", err.Error()) 684 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 685 - writeError(w, "That repo already exists!", http.StatusConflict) 686 - return 687 - } else { 688 - writeError(w, err.Error(), http.StatusInternalServerError) 689 - return 690 - } 691 - } 692 - 693 - // add perms for this user to access the repo 694 - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 695 - if err != nil { 696 - l.Error("adding repo permissions", "error", err.Error()) 697 - writeError(w, err.Error(), http.StatusInternalServerError) 698 - return 699 - } 700 - 701 - hook.SetupRepo( 702 - hook.Config( 703 - hook.WithScanPath(h.c.Repo.ScanPath), 704 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 705 - ), 706 - repoPath, 707 - ) 708 - 709 - w.WriteHeader(http.StatusNoContent) 710 648 } 711 649 712 650 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
+127
knotserver/xrpc/create_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + gogit "github.com/go-git/go-git/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/hook" 16 + "tangled.sh/tangled.sh/core/knotserver/git" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + ) 20 + 21 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 22 + l := h.Logger.With("handler", "NewRepo") 23 + fail := func(e xrpcerr.XrpcError) { 24 + l.Error("failed", "kind", e.Tag, "error", e.Message) 25 + writeError(w, e, http.StatusBadRequest) 26 + } 27 + 28 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 + if !ok { 30 + fail(xrpcerr.MissingActorDidError) 31 + return 32 + } 33 + 34 + isMember, err := h.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) 35 + if err != nil { 36 + fail(xrpcerr.GenericError(err)) 37 + return 38 + } 39 + if !isMember { 40 + fail(xrpcerr.AccessControlError(actorDid.String())) 41 + return 42 + } 43 + 44 + var data tangled.RepoCreate_Input 45 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 46 + fail(xrpcerr.GenericError(err)) 47 + return 48 + } 49 + 50 + defaultBranch := h.Config.Repo.MainBranch 51 + if data.Default_branch != nil && *data.Default_branch != "" { 52 + defaultBranch = *data.Default_branch 53 + } 54 + 55 + did := data.Did 56 + name := data.Name 57 + 58 + if err := validateRepoName(name); err != nil { 59 + l.Error("creating repo", "error", err.Error()) 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 66 + err = git.InitBare(repoPath, defaultBranch) 67 + if err != nil { 68 + l.Error("initializing bare repo", "error", err.Error()) 69 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 70 + fail(xrpcerr.RepoExistsError("repository already exists")) 71 + return 72 + } else { 73 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 74 + return 75 + } 76 + } 77 + 78 + // add perms for this user to access the repo 79 + err = h.Enforcer.AddRepo(did, rbac.ThisServer, relativeRepoPath) 80 + if err != nil { 81 + l.Error("adding repo permissions", "error", err.Error()) 82 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + hook.SetupRepo( 87 + hook.Config( 88 + hook.WithScanPath(h.Config.Repo.ScanPath), 89 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 90 + ), 91 + repoPath, 92 + ) 93 + 94 + w.WriteHeader(http.StatusOK) 95 + } 96 + 97 + func validateRepoName(name string) error { 98 + // check for path traversal attempts 99 + if name == "." || name == ".." || 100 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 101 + return fmt.Errorf("Repository name contains invalid path characters") 102 + } 103 + 104 + // check for sequences that could be used for traversal when normalized 105 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 106 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 107 + return fmt.Errorf("Repository name contains invalid path sequence") 108 + } 109 + 110 + // then continue with character validation 111 + for _, char := range name { 112 + if !((char >= 'a' && char <= 'z') || 113 + (char >= 'A' && char <= 'Z') || 114 + (char >= '0' && char <= '9') || 115 + char == '-' || char == '_' || char == '.') { 116 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 117 + } 118 + } 119 + 120 + // additional check to prevent multiple sequential dots 121 + if strings.Contains(name, "..") { 122 + return fmt.Errorf("Repository name cannot contain sequential dots") 123 + } 124 + 125 + // if all checks pass 126 + return nil 127 + }
+82
knotserver/xrpc/delete_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "DeleteRepo") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) 31 + if err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + if !isMember { 36 + fail(xrpcerr.AccessControlError(actorDid.String())) 37 + return 38 + } 39 + 40 + var data tangled.RepoDelete_Input 41 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 42 + fail(xrpcerr.GenericError(err)) 43 + return 44 + } 45 + 46 + did := data.Did 47 + name := data.Name 48 + 49 + if did == "" || name == "" { 50 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 51 + return 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 56 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 57 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 58 + return 59 + } 60 + 61 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 62 + if err != nil { 63 + fail(xrpcerr.GenericError(err)) 64 + return 65 + } 66 + 67 + err = os.RemoveAll(repoPath) 68 + if err != nil { 69 + l.Error("deleting repo", "error", err.Error()) 70 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 71 + return 72 + } 73 + 74 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 75 + if err != nil { 76 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 77 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 + return 79 + } 80 + 81 + w.WriteHeader(http.StatusOK) 82 + }
+93
knotserver/xrpc/fork_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/hook" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkRepo(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkRepo") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) 32 + if err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + if !isMember { 37 + fail(xrpcerr.AccessControlError(actorDid.String())) 38 + return 39 + } 40 + 41 + var data tangled.RepoFork_Input 42 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 43 + fail(xrpcerr.GenericError(err)) 44 + return 45 + } 46 + 47 + did := data.Did 48 + source := data.Source 49 + 50 + if did == "" || source == "" { 51 + fail(xrpcerr.GenericError(fmt.Errorf("did and source are required"))) 52 + return 53 + } 54 + 55 + var name string 56 + if data.Name != nil && *data.Name != "" { 57 + name = *data.Name 58 + } else { 59 + name = filepath.Base(source) 60 + } 61 + 62 + relativeRepoPath := filepath.Join(did, name) 63 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 64 + if err != nil { 65 + fail(xrpcerr.GenericError(err)) 66 + return 67 + } 68 + 69 + err = git.Fork(repoPath, source) 70 + if err != nil { 71 + l.Error("forking repo", "error", err.Error()) 72 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 73 + return 74 + } 75 + 76 + // add perms for this user to access the repo 77 + err = x.Enforcer.AddRepo(did, rbac.ThisServer, relativeRepoPath) 78 + if err != nil { 79 + l.Error("adding repo permissions", "error", err.Error()) 80 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 81 + return 82 + } 83 + 84 + hook.SetupRepo( 85 + hook.Config( 86 + hook.WithScanPath(x.Config.Repo.ScanPath), 87 + hook.WithInternalApi(x.Config.Server.InternalListenAddr), 88 + ), 89 + repoPath, 90 + ) 91 + 92 + w.WriteHeader(http.StatusOK) 93 + }
+111
knotserver/xrpc/fork_status.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" || branch == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name, and branch are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.PlainOpen(repoPath) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync(branch) 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+112
knotserver/xrpc/merge.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := &git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 + 86 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 87 + if err != nil { 88 + var mergeErr *git.ErrMerge 89 + if errors.As(err, &mergeErr) { 90 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 91 + for i, conflict := range mergeErr.Conflicts { 92 + conflicts[i] = types.ConflictInfo{ 93 + Filename: conflict.Filename, 94 + Reason: conflict.Reason, 95 + } 96 + } 97 + 98 + conflictErr := xrpcerr.NewXrpcError( 99 + xrpcerr.WithTag("MergeConflict"), 100 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 101 + ) 102 + writeError(w, conflictErr, http.StatusConflict) 103 + return 104 + } else { 105 + l.Error("failed to merge", "error", err.Error()) 106 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 107 + return 108 + } 109 + } 110 + 111 + w.WriteHeader(http.StatusOK) 112 + }
+105
knotserver/xrpc/merge_check.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "MergeCheck") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) 31 + if err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + if !isMember { 36 + fail(xrpcerr.AccessControlError(actorDid.String())) 37 + return 38 + } 39 + 40 + var data tangled.RepoMergeCheck_Input 41 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 42 + fail(xrpcerr.GenericError(err)) 43 + return 44 + } 45 + 46 + did := data.Did 47 + name := data.Name 48 + 49 + if did == "" || name == "" { 50 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 51 + return 52 + } 53 + 54 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 55 + if err != nil { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + gr, err := git.Open(repoPath, data.Branch) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 69 + return 70 + } 71 + 72 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 73 + 74 + response := tangled.RepoMergeCheck_Output{ 75 + Is_conflicted: false, 76 + } 77 + 78 + if err != nil { 79 + var mergeErr *git.ErrMerge 80 + if errors.As(err, &mergeErr) { 81 + response.Is_conflicted = true 82 + 83 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 84 + for i, conflict := range mergeErr.Conflicts { 85 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 86 + Filename: conflict.Filename, 87 + Reason: conflict.Reason, 88 + } 89 + } 90 + response.Conflicts = conflicts 91 + 92 + if mergeErr.Message != "" { 93 + response.Message = &mergeErr.Message 94 + } 95 + } else { 96 + response.Is_conflicted = true 97 + errMsg := err.Error() 98 + response.Error = &errMsg 99 + } 100 + } 101 + 102 + w.Header().Set("Content-Type", "application/json") 103 + w.WriteHeader(http.StatusOK) 104 + json.NewEncoder(w).Encode(response) 105 + }
+13 -4
knotserver/xrpc/router.go
··· 31 31 32 32 func (x *Xrpc) Router() http.Handler { 33 33 r := chi.NewRouter() 34 + r.Group(func(r chi.Router) { 35 + r.Use(x.ServiceAuth.VerifyServiceAuth) 36 + 37 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 38 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 39 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 40 + r.Post("/"+tangled.RepoForkNSID, x.ForkRepo) 41 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 34 43 35 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 44 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 36 45 46 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 48 + }) 37 49 return r 38 50 } 39 51 40 - // this is slightly different from http_util::write_error to follow the spec: 41 - // 42 - // the json object returned must include an "error" and a "message" 43 52 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 44 53 w.Header().Set("Content-Type", "application/json") 45 54 w.WriteHeader(status)