1package xrpc
2
3import (
4 "encoding/json"
5 "log/slog"
6 "net/http"
7 "net/url"
8 "strings"
9
10 securejoin "github.com/cyphar/filepath-securejoin"
11 "tangled.sh/tangled.sh/core/api/tangled"
12 "tangled.sh/tangled.sh/core/idresolver"
13 "tangled.sh/tangled.sh/core/jetstream"
14 "tangled.sh/tangled.sh/core/knotserver/config"
15 "tangled.sh/tangled.sh/core/knotserver/db"
16 "tangled.sh/tangled.sh/core/notifier"
17 "tangled.sh/tangled.sh/core/rbac"
18 xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19 "tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
21 "github.com/go-chi/chi/v5"
22)
23
24type Xrpc struct {
25 Config *config.Config
26 Db *db.DB
27 Ingester *jetstream.JetstreamClient
28 Enforcer *rbac.Enforcer
29 Logger *slog.Logger
30 Notifier *notifier.Notifier
31 Resolver *idresolver.Resolver
32 ServiceAuth *serviceauth.ServiceAuth
33}
34
35func (x *Xrpc) Router() http.Handler {
36 r := chi.NewRouter()
37
38 r.Group(func(r chi.Router) {
39 r.Use(x.ServiceAuth.VerifyServiceAuth)
40
41 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
45 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
46 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
47 r.Post("/"+tangled.RepoMergeNSID, x.Merge)
48 })
49
50 // merge check is an open endpoint
51 //
52 // TODO: should we constrain this more?
53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc.
54 // - use ETags on clients to keep requests to a minimum
55 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
56
57 // repo query endpoints (no auth required)
58 r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
59 r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
60 r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
61 r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
62 r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
63 r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
64 r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
65 r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
66 r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
67 r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
68 r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
69
70 // knot query endpoints (no auth required)
71 r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
72 r.Get("/"+tangled.KnotVersionNSID, x.Version)
73
74 // service query endpoints (no auth required)
75 r.Get("/"+tangled.OwnerNSID, x.Owner)
76
77 return r
78}
79
80// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
81// the full repository path on disk
82func (x *Xrpc) parseRepoParam(repo string) (string, error) {
83 if repo == "" {
84 return "", xrpcerr.NewXrpcError(
85 xrpcerr.WithTag("InvalidRequest"),
86 xrpcerr.WithMessage("missing repo parameter"),
87 )
88 }
89
90 // Parse repo string (did/repoName format)
91 parts := strings.Split(repo, "/")
92 if len(parts) < 2 {
93 return "", xrpcerr.NewXrpcError(
94 xrpcerr.WithTag("InvalidRequest"),
95 xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
96 )
97 }
98
99 did := strings.Join(parts[:len(parts)-1], "/")
100 repoName := parts[len(parts)-1]
101
102 // Construct repository path using the same logic as didPath
103 didRepoPath, err := securejoin.SecureJoin(did, repoName)
104 if err != nil {
105 return "", xrpcerr.NewXrpcError(
106 xrpcerr.WithTag("RepoNotFound"),
107 xrpcerr.WithMessage("failed to access repository"),
108 )
109 }
110
111 repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
112 if err != nil {
113 return "", xrpcerr.NewXrpcError(
114 xrpcerr.WithTag("RepoNotFound"),
115 xrpcerr.WithMessage("failed to access repository"),
116 )
117 }
118
119 return repoPath, nil
120}
121
122// parseStandardParams parses common query parameters used by most handlers
123func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
124 // Parse repo parameter
125 repo = r.URL.Query().Get("repo")
126 repoPath, err = x.parseRepoParam(repo)
127 if err != nil {
128 return "", "", "", err
129 }
130
131 // Parse and unescape ref parameter
132 refParam := r.URL.Query().Get("ref")
133 if refParam == "" {
134 return "", "", "", xrpcerr.NewXrpcError(
135 xrpcerr.WithTag("InvalidRequest"),
136 xrpcerr.WithMessage("missing ref parameter"),
137 )
138 }
139
140 ref, _ = url.QueryUnescape(refParam)
141 return repo, repoPath, ref, nil
142}
143
144func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
145 w.Header().Set("Content-Type", "application/json")
146 w.WriteHeader(status)
147 json.NewEncoder(w).Encode(e)
148}