1package xrpc
2
3import (
4 "context"
5 _ "embed"
6 "encoding/json"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "strings"
11
12 "github.com/bluesky-social/indigo/atproto/auth"
13 "github.com/go-chi/chi/v5"
14
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/idresolver"
17 "tangled.sh/tangled.sh/core/rbac"
18 "tangled.sh/tangled.sh/core/spindle/config"
19 "tangled.sh/tangled.sh/core/spindle/db"
20 "tangled.sh/tangled.sh/core/spindle/engine"
21 "tangled.sh/tangled.sh/core/spindle/secrets"
22)
23
24const ActorDid string = "ActorDid"
25
26type Xrpc struct {
27 Logger *slog.Logger
28 Db *db.DB
29 Enforcer *rbac.Enforcer
30 Engine *engine.Engine
31 Config *config.Config
32 Resolver *idresolver.Resolver
33 Vault secrets.Manager
34}
35
36func (x *Xrpc) Router() http.Handler {
37 r := chi.NewRouter()
38
39 r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
40 r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
41 r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
42
43 return r
44}
45
46func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
47 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48 l := x.Logger.With("url", r.URL)
49
50 token := r.Header.Get("Authorization")
51 token = strings.TrimPrefix(token, "Bearer ")
52
53 s := auth.ServiceAuthValidator{
54 Audience: x.Config.Server.Did().String(),
55 Dir: x.Resolver.Directory(),
56 }
57
58 did, err := s.Validate(r.Context(), token, nil)
59 if err != nil {
60 l.Error("signature verification failed", "err", err)
61 writeError(w, AuthError(err), http.StatusForbidden)
62 return
63 }
64
65 r = r.WithContext(
66 context.WithValue(r.Context(), ActorDid, did),
67 )
68
69 next.ServeHTTP(w, r)
70 })
71}
72
73type XrpcError struct {
74 Tag string `json:"error"`
75 Message string `json:"message"`
76}
77
78func NewXrpcError(opts ...ErrOpt) XrpcError {
79 x := XrpcError{}
80 for _, o := range opts {
81 o(&x)
82 }
83
84 return x
85}
86
87type ErrOpt = func(xerr *XrpcError)
88
89func WithTag(tag string) ErrOpt {
90 return func(xerr *XrpcError) {
91 xerr.Tag = tag
92 }
93}
94
95func WithMessage[S ~string](s S) ErrOpt {
96 return func(xerr *XrpcError) {
97 xerr.Message = string(s)
98 }
99}
100
101func WithError(e error) ErrOpt {
102 return func(xerr *XrpcError) {
103 xerr.Message = e.Error()
104 }
105}
106
107var MissingActorDidError = NewXrpcError(
108 WithTag("MissingActorDid"),
109 WithMessage("actor DID not supplied"),
110)
111
112var AuthError = func(err error) XrpcError {
113 return NewXrpcError(
114 WithTag("Auth"),
115 WithError(fmt.Errorf("signature verification failed: %w", err)),
116 )
117}
118
119var InvalidRepoError = func(r string) XrpcError {
120 return NewXrpcError(
121 WithTag("InvalidRepo"),
122 WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
123 )
124}
125
126func GenericError(err error) XrpcError {
127 return NewXrpcError(
128 WithTag("Generic"),
129 WithError(err),
130 )
131}
132
133var AccessControlError = func(d string) XrpcError {
134 return NewXrpcError(
135 WithTag("AccessControl"),
136 WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
137 )
138}
139
140// this is slightly different from http_util::write_error to follow the spec:
141//
142// the json object returned must include an "error" and a "message"
143func writeError(w http.ResponseWriter, e XrpcError, status int) {
144 w.Header().Set("Content-Type", "application/json")
145 w.WriteHeader(status)
146 json.NewEncoder(w).Encode(e)
147}