knotserver: add xrpc api for set default branch #348

merged
opened by oppi.li targeting master from push-nozqtwvsrvkx
Changed files
+279 -18
knotserver
+6
knotserver/config/config.go
···
import (
"context"
+
"fmt"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/sethvargo/go-envconfig"
)
···
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_"`
+37 -18
knotserver/handler.go
···
"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.
···
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)
}
···
})
})
+
// xrpc apis
+
r.Mount("/xrpc", h.XrpcRouter())
+
// Create a new repository.
r.Route("/repo", func(r chi.Router) {
r.Use(h.VerifySignature)
···
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
+149
knotserver/xrpc/router.go
···
+
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)
+
}
+87
knotserver/xrpc/set_default_branch.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"
+
)
+
+
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)
+
}