Monorepo for Tangled
tangled.org
1package xrpc
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "log/slog"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12
13 securejoin "github.com/cyphar/filepath-securejoin"
14 "github.com/go-chi/chi/v5"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/idresolver"
17 "tangled.org/core/knotserver/config"
18 "tangled.org/core/knotserver/db"
19 "tangled.org/core/knotserver/sandbox"
20 "tangled.org/core/notifier"
21 "tangled.org/core/rbac"
22 xrpcerr "tangled.org/core/xrpc/errors"
23 "tangled.org/core/xrpc/serviceauth"
24)
25
26const ActorDid = serviceauth.ActorDid
27
28type DidIngester interface {
29 AddDid(did string)
30 RemoveDid(did string)
31}
32
33type Xrpc struct {
34 Config *config.Config
35 Db *db.DB
36 Ingester DidIngester
37 Enforcer *rbac.Enforcer
38 Logger *slog.Logger
39 Notifier *notifier.Notifier
40 Resolver *idresolver.Resolver
41 ServiceAuth *serviceauth.ServiceAuth
42 Sandbox sandbox.Backend
43}
44
45func (x *Xrpc) Router() http.Handler {
46 r := chi.NewRouter()
47
48 r.Group(func(r chi.Router) {
49 r.Use(x.ServiceAuth.VerifyServiceAuth)
50
51 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
52 r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch)
53 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
54 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
55 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
56 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
57 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
58 r.Post("/"+tangled.RepoMergeNSID, x.Merge)
59 r.Post("/"+tangled.KnotAddMemberNSID, x.AddMember)
60 r.Post("/"+tangled.KnotRemoveMemberNSID, x.RemoveMember)
61 r.Post("/"+tangled.RepoAddCollaboratorNSID, x.AddCollaborator)
62 r.Post("/"+tangled.RepoRemoveCollaboratorNSID, x.RemoveCollaborator)
63 })
64
65 // merge check is an open endpoint
66 //
67 // TODO: should we constrain this more?
68 // - we can calculate on PR submit/resubmit/gitRefUpdate etc.
69 // - use ETags on clients to keep requests to a minimum
70 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
71
72 // repo query endpoints (no auth required)
73 r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
74 r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
75 r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
76 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
77 r.Get("/"+tangled.RepoTagNSID, x.RepoTag)
78 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
79 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
80 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
81 r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
82 r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo)
83 r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
84 r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
85 r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
86 r.Get("/"+tangled.RepoListCollaboratorsNSID, x.ListCollaborators)
87
88 // knot query endpoints (no auth required)
89 r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
90 r.Get("/"+tangled.KnotListMembersNSID, x.ListMembers)
91 r.Get("/"+tangled.KnotVersionNSID, x.Version)
92
93 // service query endpoints (no auth required)
94 r.Get("/"+tangled.OwnerNSID, x.Owner)
95
96 return r
97}
98
99func (x *Xrpc) parseRepoParam(repo string) (string, error) {
100 if repo == "" || !strings.HasPrefix(repo, "did:") {
101 return "", xrpcerr.NewXrpcError(
102 xrpcerr.WithTag("InvalidRequest"),
103 xrpcerr.WithMessage("missing or invalid repo parameter, expected a repo DID"),
104 )
105 }
106
107 if !strings.Contains(repo, "/") {
108 repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repo)
109 if err != nil {
110 return "", xrpcerr.RepoNotFoundError
111 }
112 return repoPath, nil
113 }
114
115 parts := strings.SplitN(repo, "/", 2)
116 ownerDid, repoName := parts[0], parts[1]
117
118 repoDid, err := x.Db.GetRepoDid(ownerDid, repoName)
119 if err == nil {
120 repoPath, _, _, resolveErr := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, repoDid)
121 if resolveErr == nil {
122 return repoPath, nil
123 }
124 }
125
126 repoPath, joinErr := securejoin.SecureJoin(x.Config.Repo.ScanPath, filepath.Join(ownerDid, repoName))
127 if joinErr != nil {
128 return "", xrpcerr.RepoNotFoundError
129 }
130 if _, statErr := os.Stat(repoPath); statErr != nil {
131 return "", xrpcerr.RepoNotFoundError
132 }
133 return repoPath, nil
134}
135
136func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
137 w.Header().Set("Content-Type", "application/json")
138 w.WriteHeader(status)
139 json.NewEncoder(w).Encode(e)
140}
141
142type limitWriter struct {
143 buf bytes.Buffer
144 limit int
145 written int
146}
147
148var errResponseTooLarge = errors.New("response too large")
149
150func (lw *limitWriter) Write(p []byte) (int, error) {
151 if lw.written+len(p) > lw.limit {
152 return 0, errResponseTooLarge
153 }
154 n, err := lw.buf.Write(p)
155 lw.written += n
156 return n, err
157}
158
159func (x *Xrpc) writeJson(w http.ResponseWriter, response any) {
160 lw := &limitWriter{limit: x.Config.Server.MaxResponseKB * 1024}
161 if err := json.NewEncoder(lw).Encode(response); err != nil {
162 if errors.Is(err, errResponseTooLarge) {
163 writeError(w, xrpcerr.RequestTooLargeError, http.StatusRequestEntityTooLarge)
164 } else {
165 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
166 }
167 return
168 }
169 w.Header().Set("Content-Type", "application/json")
170 w.Write(lw.buf.Bytes())
171}