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