From 6a968a8f18aba0c3cbe44294b724eacbf4dc880c Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 12 Aug 2025 17:46:35 +0300 Subject: [PATCH] knotserver/xrpc: define handlers for all ops Signed-off-by: Anirudh Oppiliappan --- knotserver/handler.go | 1 - knotserver/routes.go | 62 ---------------- knotserver/xrpc/create_repo.go | 127 +++++++++++++++++++++++++++++++++ knotserver/xrpc/delete_repo.go | 82 +++++++++++++++++++++ knotserver/xrpc/fork_repo.go | 93 ++++++++++++++++++++++++ knotserver/xrpc/fork_status.go | 111 ++++++++++++++++++++++++++++ knotserver/xrpc/fork_sync.go | 73 +++++++++++++++++++ knotserver/xrpc/hidden_ref.go | 104 +++++++++++++++++++++++++++ knotserver/xrpc/merge.go | 112 +++++++++++++++++++++++++++++ knotserver/xrpc/merge_check.go | 105 +++++++++++++++++++++++++++ knotserver/xrpc/router.go | 17 +++-- 11 files changed, 820 insertions(+), 67 deletions(-) create mode 100644 knotserver/xrpc/create_repo.go create mode 100644 knotserver/xrpc/delete_repo.go create mode 100644 knotserver/xrpc/fork_repo.go create mode 100644 knotserver/xrpc/fork_status.go create mode 100644 knotserver/xrpc/fork_sync.go create mode 100644 knotserver/xrpc/hidden_ref.go create mode 100644 knotserver/xrpc/merge.go create mode 100644 knotserver/xrpc/merge_check.go diff --git a/knotserver/handler.go b/knotserver/handler.go index c0810383..c2a8427c 100644 --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -139,7 +139,6 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j // Create a new repository. r.Route("/repo", func(r chi.Router) { r.Use(h.VerifySignature) - r.Put("/new", h.NewRepo) r.Delete("/", h.RemoveRepo) r.Route("/fork", func(r chi.Router) { r.Post("/", h.RepoFork) diff --git a/knotserver/routes.go b/knotserver/routes.go index 19ef2c2e..cda0a570 100644 --- a/knotserver/routes.go +++ b/knotserver/routes.go @@ -22,7 +22,6 @@ import ( securejoin "github.com/cyphar/filepath-securejoin" "github.com/gliderlabs/ssh" "github.com/go-chi/chi/v5" - gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "tangled.sh/tangled.sh/core/hook" @@ -648,67 +647,6 @@ func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { } } -func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "NewRepo") - - data := struct { - Did string `json:"did"` - Name string `json:"name"` - DefaultBranch string `json:"default_branch,omitempty"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - if data.DefaultBranch == "" { - data.DefaultBranch = h.c.Repo.MainBranch - } - - did := data.Did - name := data.Name - defaultBranch := data.DefaultBranch - - if err := validateRepoName(name); err != nil { - l.Error("creating repo", "error", err.Error()) - writeError(w, err.Error(), http.StatusBadRequest) - return - } - - relativeRepoPath := filepath.Join(did, name) - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) - err := git.InitBare(repoPath, defaultBranch) - if err != nil { - l.Error("initializing bare repo", "error", err.Error()) - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { - writeError(w, "That repo already exists!", http.StatusConflict) - return - } else { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - } - - // add perms for this user to access the repo - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) - if err != nil { - l.Error("adding repo permissions", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - hook.SetupRepo( - hook.Config( - hook.WithScanPath(h.c.Repo.ScanPath), - hook.WithInternalApi(h.c.Server.InternalListenAddr), - ), - repoPath, - ) - - w.WriteHeader(http.StatusNoContent) -} - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { l := h.l.With("handler", "RepoForkSync") diff --git a/knotserver/xrpc/create_repo.go b/knotserver/xrpc/create_repo.go new file mode 100644 index 00000000..bd62f4f0 --- /dev/null +++ b/knotserver/xrpc/create_repo.go @@ -0,0 +1,127 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + gogit "github.com/go-git/go-git/v5" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/hook" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { + l := h.Logger.With("handler", "NewRepo") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + isMember, err := h.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + if !isMember { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + var data tangled.RepoCreate_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + defaultBranch := h.Config.Repo.MainBranch + if data.Default_branch != nil && *data.Default_branch != "" { + defaultBranch = *data.Default_branch + } + + did := data.Did + name := data.Name + + if err := validateRepoName(name); err != nil { + l.Error("creating repo", "error", err.Error()) + fail(xrpcerr.GenericError(err)) + return + } + + relativeRepoPath := filepath.Join(did, name) + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) + err = git.InitBare(repoPath, defaultBranch) + if err != nil { + l.Error("initializing bare repo", "error", err.Error()) + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { + fail(xrpcerr.RepoExistsError("repository already exists")) + return + } else { + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + } + + // add perms for this user to access the repo + err = h.Enforcer.AddRepo(did, rbac.ThisServer, relativeRepoPath) + if err != nil { + l.Error("adding repo permissions", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + hook.SetupRepo( + hook.Config( + hook.WithScanPath(h.Config.Repo.ScanPath), + hook.WithInternalApi(h.Config.Server.InternalListenAddr), + ), + repoPath, + ) + + w.WriteHeader(http.StatusOK) +} + +func validateRepoName(name string) error { + // check for path traversal attempts + if name == "." || name == ".." || + strings.Contains(name, "/") || strings.Contains(name, "\\") { + return fmt.Errorf("Repository name contains invalid path characters") + } + + // check for sequences that could be used for traversal when normalized + if strings.Contains(name, "./") || strings.Contains(name, "../") || + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { + return fmt.Errorf("Repository name contains invalid path sequence") + } + + // then continue with character validation + for _, char := range name { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '-' || char == '_' || char == '.') { + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") + } + } + + // additional check to prevent multiple sequential dots + if strings.Contains(name, "..") { + return fmt.Errorf("Repository name cannot contain sequential dots") + } + + // if all checks pass + return nil +} diff --git a/knotserver/xrpc/delete_repo.go b/knotserver/xrpc/delete_repo.go new file mode 100644 index 00000000..5d797b6c --- /dev/null +++ b/knotserver/xrpc/delete_repo.go @@ -0,0 +1,82 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "DeleteRepo") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + if !isMember { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + var data tangled.RepoDelete_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + name := data.Name + + if did == "" || name == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + return + } + + relativeRepoPath := filepath.Join(did, name) + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + err = os.RemoveAll(repoPath) + if err != nil { + l.Error("deleting repo", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) + if err != nil { + l.Error("failed to delete repo from enforcer", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/knotserver/xrpc/fork_repo.go b/knotserver/xrpc/fork_repo.go new file mode 100644 index 00000000..fe25bc3f --- /dev/null +++ b/knotserver/xrpc/fork_repo.go @@ -0,0 +1,93 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/hook" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) ForkRepo(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "ForkRepo") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + if !isMember { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + var data tangled.RepoFork_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + source := data.Source + + if did == "" || source == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did and source are required"))) + return + } + + var name string + if data.Name != nil && *data.Name != "" { + name = *data.Name + } else { + name = filepath.Base(source) + } + + relativeRepoPath := filepath.Join(did, name) + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + err = git.Fork(repoPath, source) + if err != nil { + l.Error("forking repo", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + // add perms for this user to access the repo + err = x.Enforcer.AddRepo(did, rbac.ThisServer, relativeRepoPath) + if err != nil { + l.Error("adding repo permissions", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + hook.SetupRepo( + hook.Config( + hook.WithScanPath(x.Config.Repo.ScanPath), + hook.WithInternalApi(x.Config.Server.InternalListenAddr), + ), + repoPath, + ) + + w.WriteHeader(http.StatusOK) +} diff --git a/knotserver/xrpc/fork_status.go b/knotserver/xrpc/fork_status.go new file mode 100644 index 00000000..80827447 --- /dev/null +++ b/knotserver/xrpc/fork_status.go @@ -0,0 +1,111 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "ForkStatus") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + var data tangled.RepoForkStatus_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + source := data.Source + branch := data.Branch + hiddenRef := data.HiddenRef + + if did == "" || source == "" || branch == "" || hiddenRef == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) + return + } + + var name string + if data.Name != "" { + name = data.Name + } else { + name = filepath.Base(source) + } + + relativeRepoPath := filepath.Join(did, name) + + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + gr, err := git.PlainOpen(repoPath) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + forkCommit, err := gr.ResolveRevision(branch) + if err != nil { + l.Error("error resolving ref revision", "msg", err.Error()) + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) + return + } + + sourceCommit, err := gr.ResolveRevision(hiddenRef) + if err != nil { + l.Error("error resolving hidden ref revision", "msg", err.Error()) + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) + return + } + + status := types.UpToDate + if forkCommit.Hash.String() != sourceCommit.Hash.String() { + isAncestor, err := forkCommit.IsAncestor(sourceCommit) + if err != nil { + l.Error("error checking ancestor relationship", "error", err.Error()) + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) + return + } + + if isAncestor { + status = types.FastForwardable + } else { + status = types.Conflict + } + } + + response := tangled.RepoForkStatus_Output{ + Status: int64(status), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/knotserver/xrpc/fork_sync.go b/knotserver/xrpc/fork_sync.go new file mode 100644 index 00000000..e2a2ece1 --- /dev/null +++ b/knotserver/xrpc/fork_sync.go @@ -0,0 +1,73 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "ForkSync") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + var data tangled.RepoForkSync_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + name := data.Name + branch := data.Branch + + if did == "" || name == "" || branch == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did, name, and branch are required"))) + return + } + + relativeRepoPath := filepath.Join(did, name) + + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + gr, err := git.PlainOpen(repoPath) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + err = gr.Sync(branch) + if err != nil { + l.Error("error syncing repo fork", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/knotserver/xrpc/hidden_ref.go b/knotserver/xrpc/hidden_ref.go new file mode 100644 index 00000000..96267d75 --- /dev/null +++ b/knotserver/xrpc/hidden_ref.go @@ -0,0 +1,104 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "HiddenRef") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + var data tangled.RepoHiddenRef_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + forkRef := data.ForkRef + remoteRef := data.RemoteRef + repoAtUri := data.Repo + + if forkRef == "" || remoteRef == "" || repoAtUri == "" { + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) + return + } + + repoAt, err := syntax.ParseATURI(repoAtUri) + if err != nil { + fail(xrpcerr.InvalidRepoError(repoAtUri)) + return + } + + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) + return + } + + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + gr, err := git.PlainOpen(repoPath) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) + if err != nil { + l.Error("error tracking hidden remote ref", "error", err.Error()) + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + return + } + + response := tangled.RepoHiddenRef_Output{ + Success: true, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/knotserver/xrpc/merge.go b/knotserver/xrpc/merge.go new file mode 100644 index 00000000..554c0e10 --- /dev/null +++ b/knotserver/xrpc/merge.go @@ -0,0 +1,112 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/patchutil" + "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "Merge") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + var data tangled.RepoMerge_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + name := data.Name + + if did == "" || name == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + return + } + + relativeRepoPath, err := securejoin.SecureJoin(did, name) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + gr, err := git.Open(repoPath, data.Branch) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + mo := &git.MergeOptions{} + if data.AuthorName != nil { + mo.AuthorName = *data.AuthorName + } + if data.AuthorEmail != nil { + mo.AuthorEmail = *data.AuthorEmail + } + if data.CommitBody != nil { + mo.CommitBody = *data.CommitBody + } + if data.CommitMessage != nil { + mo.CommitMessage = *data.CommitMessage + } + + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) + + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) + if err != nil { + var mergeErr *git.ErrMerge + if errors.As(err, &mergeErr) { + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) + for i, conflict := range mergeErr.Conflicts { + conflicts[i] = types.ConflictInfo{ + Filename: conflict.Filename, + Reason: conflict.Reason, + } + } + + conflictErr := xrpcerr.NewXrpcError( + xrpcerr.WithTag("MergeConflict"), + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), + ) + writeError(w, conflictErr, http.StatusConflict) + return + } else { + l.Error("failed to merge", "error", err.Error()) + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) +} diff --git a/knotserver/xrpc/merge_check.go b/knotserver/xrpc/merge_check.go new file mode 100644 index 00000000..48b9f27e --- /dev/null +++ b/knotserver/xrpc/merge_check.go @@ -0,0 +1,105 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/rbac" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "MergeCheck") + fail := func(e xrpcerr.XrpcError) { + l.Error("failed", "kind", e.Tag, "error", e.Message) + writeError(w, e, http.StatusBadRequest) + } + + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + fail(xrpcerr.MissingActorDidError) + return + } + + isMember, err := x.Enforcer.IsKnotMember(actorDid.String(), rbac.ThisServer) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + if !isMember { + fail(xrpcerr.AccessControlError(actorDid.String())) + return + } + + var data tangled.RepoMergeCheck_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + did := data.Did + name := data.Name + + if did == "" || name == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) + return + } + + relativeRepoPath, err := securejoin.SecureJoin(did, name) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + gr, err := git.Open(repoPath, data.Branch) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + err = gr.MergeCheck([]byte(data.Patch), data.Branch) + + response := tangled.RepoMergeCheck_Output{ + Is_conflicted: false, + } + + if err != nil { + var mergeErr *git.ErrMerge + if errors.As(err, &mergeErr) { + response.Is_conflicted = true + + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) + for i, conflict := range mergeErr.Conflicts { + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ + Filename: conflict.Filename, + Reason: conflict.Reason, + } + } + response.Conflicts = conflicts + + if mergeErr.Message != "" { + response.Message = &mergeErr.Message + } + } else { + response.Is_conflicted = true + errMsg := err.Error() + response.Error = &errMsg + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/knotserver/xrpc/router.go b/knotserver/xrpc/router.go index d4d01c99..59c341dd 100644 --- a/knotserver/xrpc/router.go +++ b/knotserver/xrpc/router.go @@ -31,15 +31,24 @@ type Xrpc struct { func (x *Xrpc) Router() http.Handler { r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(x.ServiceAuth.VerifyServiceAuth) - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) + r.Post("/"+tangled.RepoForkNSID, x.ForkRepo) + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) + + r.Post("/"+tangled.RepoMergeNSID, x.Merge) + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) + }) return r } -// this is slightly different from http_util::write_error to follow the spec: -// -// the json object returned must include an "error" and a "message" func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) -- 2.51.2