From 403fee1f53727d5afd80e387a1b121e8e2f29251 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Fri, 25 Jul 2025 11:11:50 +0100 Subject: [PATCH] knotserver: add xrpc api for set default branch Change-Id: nozqtwvsrvkxvmluspvukmotsqouklzw Signed-off-by: oppiliappan --- knotserver/config/config.go | 6 ++ knotserver/handler.go | 55 ++++++---- knotserver/xrpc/router.go | 149 ++++++++++++++++++++++++++ knotserver/xrpc/set_default_branch.go | 87 +++++++++++++++ 4 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 knotserver/xrpc/router.go create mode 100644 knotserver/xrpc/set_default_branch.go diff --git a/knotserver/config/config.go b/knotserver/config/config.go index bad42db..a98137b 100644 --- a/knotserver/config/config.go +++ b/knotserver/config/config.go @@ -2,7 +2,9 @@ package config import ( "context" + "fmt" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/sethvargo/go-envconfig" ) @@ -25,6 +27,10 @@ type Server struct { Dev bool `env:"DEV, default=false"` } +func (s Server) Did() syntax.DID { + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) +} + type Config struct { Repo Repo `env:",prefix=KNOT_REPO_"` Server Server `env:",prefix=KNOT_SERVER_"` diff --git a/knotserver/handler.go b/knotserver/handler.go index a7dc849..766346e 100644 --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -8,24 +8,24 @@ import ( "runtime/debug" "github.com/go-chi/chi/v5" + "tangled.sh/tangled.sh/core/idresolver" "tangled.sh/tangled.sh/core/jetstream" "tangled.sh/tangled.sh/core/knotserver/config" "tangled.sh/tangled.sh/core/knotserver/db" + "tangled.sh/tangled.sh/core/knotserver/xrpc" + tlog "tangled.sh/tangled.sh/core/log" "tangled.sh/tangled.sh/core/notifier" "tangled.sh/tangled.sh/core/rbac" ) -const ( - ThisServer = "thisserver" // resource identifier for rbac enforcement -) - type Handle struct { - c *config.Config - db *db.DB - jc *jetstream.JetstreamClient - e *rbac.Enforcer - l *slog.Logger - n *notifier.Notifier + c *config.Config + db *db.DB + jc *jetstream.JetstreamClient + e *rbac.Enforcer + l *slog.Logger + n *notifier.Notifier + resolver *idresolver.Resolver // init is a channel that is closed when the knot has been initailized // i.e. when the first user (knot owner) has been added. @@ -37,16 +37,17 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j r := chi.NewRouter() h := Handle{ - c: c, - db: db, - e: e, - l: l, - jc: jc, - n: n, - init: make(chan struct{}), + c: c, + db: db, + e: e, + l: l, + jc: jc, + n: n, + resolver: idresolver.DefaultResolver(), + init: make(chan struct{}), } - err := e.AddKnot(ThisServer) + err := e.AddKnot(rbac.ThisServer) if err != nil { return nil, fmt.Errorf("failed to setup enforcer: %w", err) } @@ -131,6 +132,9 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j }) }) + // xrpc apis + r.Mount("/xrpc", h.XrpcRouter()) + // Create a new repository. r.Route("/repo", func(r chi.Router) { r.Use(h.VerifySignature) @@ -163,6 +167,21 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j return r, nil } +func (h *Handle) XrpcRouter() http.Handler { + logger := tlog.New("knots") + + xrpc := &xrpc.Xrpc{ + Config: h.c, + Db: h.db, + Ingester: h.jc, + Enforcer: h.e, + Logger: logger, + Notifier: h.n, + Resolver: h.resolver, + } + return xrpc.Router() +} + // version is set during build time. var version string diff --git a/knotserver/xrpc/router.go b/knotserver/xrpc/router.go new file mode 100644 index 0000000..f02a8d8 --- /dev/null +++ b/knotserver/xrpc/router.go @@ -0,0 +1,149 @@ +package xrpc + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/idresolver" + "tangled.sh/tangled.sh/core/jetstream" + "tangled.sh/tangled.sh/core/knotserver/config" + "tangled.sh/tangled.sh/core/knotserver/db" + "tangled.sh/tangled.sh/core/notifier" + "tangled.sh/tangled.sh/core/rbac" + + "github.com/bluesky-social/indigo/atproto/auth" + "github.com/go-chi/chi/v5" +) + +type Xrpc struct { + Config *config.Config + Db *db.DB + Ingester *jetstream.JetstreamClient + Enforcer *rbac.Enforcer + Logger *slog.Logger + Notifier *notifier.Notifier + Resolver *idresolver.Resolver +} + +func (x *Xrpc) Router() http.Handler { + r := chi.NewRouter() + + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) + + return r +} + +func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("url", r.URL) + + token := r.Header.Get("Authorization") + token = strings.TrimPrefix(token, "Bearer ") + + s := auth.ServiceAuthValidator{ + Audience: x.Config.Server.Did().String(), + Dir: x.Resolver.Directory(), + } + + did, err := s.Validate(r.Context(), token, nil) + if err != nil { + l.Error("signature verification failed", "err", err) + writeError(w, AuthError(err), http.StatusForbidden) + return + } + + r = r.WithContext( + context.WithValue(r.Context(), ActorDid, did), + ) + + next.ServeHTTP(w, r) + }) +} + +type XrpcError struct { + Tag string `json:"error"` + Message string `json:"message"` +} + +func NewXrpcError(opts ...ErrOpt) XrpcError { + x := XrpcError{} + for _, o := range opts { + o(&x) + } + + return x +} + +type ErrOpt = func(xerr *XrpcError) + +func WithTag(tag string) ErrOpt { + return func(xerr *XrpcError) { + xerr.Tag = tag + } +} + +func WithMessage[S ~string](s S) ErrOpt { + return func(xerr *XrpcError) { + xerr.Message = string(s) + } +} + +func WithError(e error) ErrOpt { + return func(xerr *XrpcError) { + xerr.Message = e.Error() + } +} + +var MissingActorDidError = NewXrpcError( + WithTag("MissingActorDid"), + WithMessage("actor DID not supplied"), +) + +var AuthError = func(err error) XrpcError { + return NewXrpcError( + WithTag("Auth"), + WithError(fmt.Errorf("signature verification failed: %w", err)), + ) +} + +var InvalidRepoError = func(r string) XrpcError { + return NewXrpcError( + WithTag("InvalidRepo"), + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), + ) +} + +var AccessControlError = func(d string) XrpcError { + return NewXrpcError( + WithTag("AccessControl"), + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), + ) +} + +var GitError = func(e error) XrpcError { + return NewXrpcError( + WithTag("Git"), + WithError(fmt.Errorf("git error: %w", e)), + ) +} + +func GenericError(err error) XrpcError { + return NewXrpcError( + WithTag("InvalidRepo"), + WithError(err), + ) +} + +// 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 XrpcError, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(e) +} diff --git a/knotserver/xrpc/set_default_branch.go b/knotserver/xrpc/set_default_branch.go new file mode 100644 index 0000000..06c9a05 --- /dev/null +++ b/knotserver/xrpc/set_default_branch.go @@ -0,0 +1,87 @@ +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" +) + +const ActorDid string = "ActorDid" + +func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { + l := x.Logger + fail := func(e 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(MissingActorDidError) + return + } + + var data tangled.RepoSetDefaultBranch_Input + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + fail(GenericError(err)) + return + } + + // unfortunately we have to resolve repo-at here + repoAt, err := syntax.ParseATURI(data.Repo) + if err != nil { + fail(InvalidRepoError(data.Repo)) + return + } + + // resolve this aturi to extract the repo record + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) + if err != nil || ident.Handle.IsInvalidHandle() { + fail(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(GenericError(err)) + return + } + + repo := resp.Value.Val.(*tangled.Repo) + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) + if err != nil { + fail(GenericError(err)) + return + } + + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { + l.Error("insufficent permissions", "did", actorDid.String()) + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) + return + } + + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) + gr, err := git.PlainOpen(path) + if err != nil { + fail(InvalidRepoError(data.Repo)) + return + } + + err = gr.SetDefaultBranch(data.DefaultBranch) + if err != nil { + l.Error("setting default branch", "error", err.Error()) + writeError(w, GitError(err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} -- 2.43.0