diff --git a/knotserver/handler.go b/knotserver/handler.go index 76e7ec00..2268bd95 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 d116b900..5ad4a591 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" @@ -653,67 +652,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", "RepoForkAheadBehind") diff --git a/knotserver/xrpc/create_repo.go b/knotserver/xrpc/create_repo.go new file mode 100644 index 00000000..6779b817 --- /dev/null +++ b/knotserver/xrpc/create_repo.go @@ -0,0 +1,156 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + 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" + 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.IsRepoCreateAllowed(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 + } + + rkey := data.Rkey + + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(xrpcerr.GenericError(err)) + return + } + + xrpcc := xrpc.Client{ + Host: ident.PDSEndpoint(), + } + + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) + if err != nil { + fail(xrpcerr.GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + + defaultBranch := h.Config.Repo.MainBranch + if data.DefaultBranch != nil && *data.DefaultBranch != "" { + defaultBranch = *data.DefaultBranch + } + + if err := validateRepoName(repo.Name); err != nil { + l.Error("creating repo", "error", err.Error()) + fail(xrpcerr.GenericError(err)) + return + } + + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) + + if data.Source != nil && *data.Source != "" { + err = git.Fork(repoPath, *data.Source) + if err != nil { + l.Error("forking repo", "error", err.Error()) + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) + return + } + } else { + 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(actorDid.String(), 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..af141929 --- /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.IsRepoDeleteAllowed(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_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..aa6cecf7 --- /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 == "" { + fail(xrpcerr.GenericError(fmt.Errorf("did, name 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.Open(repoPath, branch) + if err != nil { + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) + return + } + + err = gr.Sync() + 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..c49f4761 --- /dev/null +++ b/knotserver/xrpc/merge_check.go @@ -0,0 +1,87 @@ +package xrpc + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + securejoin "github.com/cyphar/filepath-securejoin" + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + 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) + } + + 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)