forked from tangled.org/core
this repo has no description

knotserver/xrpc: define handlers for all ops

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

anirudh.fi 6a968a8f 77474ceb

verified
-1
knotserver/handler.go
···
// 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)
-62
knotserver/routes.go
···
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"
···
w.WriteHeader(http.StatusNoContent)
return
}
-
}
-
-
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) {
+127
knotserver/xrpc/create_repo.go
···
+
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
+
}
+82
knotserver/xrpc/delete_repo.go
···
+
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)
+
}
+93
knotserver/xrpc/fork_repo.go
···
+
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)
+
}
+111
knotserver/xrpc/fork_status.go
···
+
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)
+
}
+73
knotserver/xrpc/fork_sync.go
···
+
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)
+
}
+104
knotserver/xrpc/hidden_ref.go
···
+
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)
+
}
+112
knotserver/xrpc/merge.go
···
+
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)
+
}
+105
knotserver/xrpc/merge_check.go
···
+
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)
+
}
+13 -4
knotserver/xrpc/router.go
···
func (x *Xrpc) Router() http.Handler {
r := chi.NewRouter()
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
+
+
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.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
+
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)