forked from
tangled.org/core
Monorepo for Tangled
1package xrpc
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11
12 "github.com/bluesky-social/indigo/api/atproto"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/knotmirror/db"
17 "tangled.org/core/knotmirror/models"
18)
19
20var mirrorToKnotNSID = map[string]string{
21 tangled.GitTempListBranchesNSID: tangled.RepoBranchesNSID,
22 tangled.GitTempListTagsNSID: tangled.RepoTagsNSID,
23 tangled.GitTempListCommitsNSID: tangled.RepoLogNSID,
24 tangled.GitTempGetTreeNSID: tangled.RepoTreeNSID,
25 tangled.GitTempGetBranchNSID: tangled.RepoBranchNSID,
26 tangled.GitTempGetBlobNSID: tangled.RepoBlobNSID,
27 tangled.GitTempGetTagNSID: tangled.RepoTagNSID,
28 tangled.GitTempGetArchiveNSID: tangled.RepoArchiveNSID,
29 tangled.RepoBlobNSID: tangled.RepoBlobNSID,
30 tangled.GitTempListLanguagesNSID: tangled.RepoLanguagesNSID,
31}
32
33var hopByHopHeaders = map[string]bool{
34 "Connection": true,
35 "Keep-Alive": true,
36 "Transfer-Encoding": true,
37 "Te": true,
38 "Trailer": true,
39 "Upgrade": true,
40 "Proxy-Authorization": true,
41 "Proxy-Authenticate": true,
42}
43
44type knotInfo struct {
45 baseURL string
46 repoIdentifier string
47}
48
49// validateKnotURL ensures a knot base URL is safe to proxy to.
50// It rejects URLs with path components, query strings, or fragments
51// that could be used for path injection.
52func validateKnotURL(raw string) (string, error) {
53 u, err := url.Parse(raw)
54 if err != nil {
55 return "", fmt.Errorf("invalid knot URL: %w", err)
56 }
57 if u.Scheme != "http" && u.Scheme != "https" {
58 return "", errors.New("knot URL must use http or https scheme")
59 }
60 if u.Path != "" && u.Path != "/" {
61 return "", fmt.Errorf("knot URL must not contain a path: %q", raw)
62 }
63 if u.RawQuery != "" || u.Fragment != "" {
64 return "", fmt.Errorf("knot URL must not contain query or fragment: %q", raw)
65 }
66 if u.User != nil {
67 return "", fmt.Errorf("knot URL must not contain userinfo: %q", raw)
68 }
69 // Strip trailing slash for consistent formatting
70 return strings.TrimRight(u.String(), "/"), nil
71}
72
73func (x *Xrpc) resolveKnot(ctx context.Context, repoAt syntax.ATURI) (*knotInfo, error) {
74 repo, err := db.GetRepoByAtUri(ctx, x.db, repoAt)
75 if err == nil && repo != nil {
76 knotURL := repo.KnotDomain
77 if !strings.Contains(repo.KnotDomain, "://") {
78 if host, _ := db.GetHost(ctx, x.db, repo.KnotDomain); host != nil {
79 knotURL = host.URL()
80 } else {
81 x.logger.Warn("repo is from unknown knot")
82 if x.cfg.KnotUseSSL {
83 knotURL = "https://" + knotURL
84 } else {
85 knotURL = "http://" + knotURL
86 }
87 }
88 }
89 knotURL, err = validateKnotURL(knotURL)
90 if err != nil {
91 return nil, err
92 }
93 return &knotInfo{baseURL: knotURL, repoIdentifier: repo.RepoIdentifier()}, nil
94 }
95
96 owner, err := x.resolver.ResolveIdent(ctx, repoAt.Authority().String())
97 if err != nil {
98 return nil, fmt.Errorf("resolving repo owner: %w", err)
99 }
100
101 xrpcc := indigoxrpc.Client{Host: owner.PDSEndpoint()}
102 out, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
103 if err != nil {
104 return nil, fmt.Errorf("fetching repo record from PDS: %w", err)
105 }
106
107 record := out.Value.Val.(*tangled.Repo)
108 if record.RepoDid == nil || *record.RepoDid == "" {
109 return nil, fmt.Errorf("repo record has no repo_did")
110 }
111 knotURL := record.Knot
112 if !strings.Contains(record.Knot, "://") {
113 if host, _ := db.GetHost(ctx, x.db, record.Knot); host != nil {
114 knotURL = host.URL()
115 } else {
116 x.logger.Warn("repo is from unknown knot")
117 if x.cfg.KnotUseSSL {
118 knotURL = "https://" + knotURL
119 } else {
120 knotURL = "http://" + knotURL
121 }
122 }
123 }
124
125 rkey := repoAt.RecordKey().String()
126 repoDid := syntax.DID(*record.RepoDid)
127 go func() {
128 bgCtx := context.Background()
129 pending := &models.Repo{
130 Did: owner.DID,
131 Rkey: repoAt.RecordKey(),
132 Cid: (*syntax.CID)(out.Cid),
133 Name: rkey,
134 KnotDomain: knotURL,
135 RepoDid: repoDid,
136 State: models.RepoStatePending,
137 }
138 if upsertErr := db.UpsertRepo(bgCtx, x.db, pending); upsertErr != nil {
139 x.logger.Error("failed to upsert repo after proxy resolution", "err", upsertErr)
140 }
141 }()
142
143 knotURL, err = validateKnotURL(knotURL)
144 if err != nil {
145 return nil, err
146 }
147 return &knotInfo{
148 baseURL: knotURL,
149 repoIdentifier: repoDid.String(),
150 }, nil
151}
152
153func (x *Xrpc) proxyToKnot(w http.ResponseWriter, r *http.Request, repoAt syntax.ATURI) bool {
154 mirrorNSID := strings.TrimPrefix(r.URL.Path, "/xrpc/")
155 knotNSID, ok := mirrorToKnotNSID[mirrorNSID]
156 if !ok {
157 return false
158 }
159
160 knot, err := x.resolveKnot(r.Context(), repoAt)
161 if err != nil {
162 x.logger.Warn("proxy: failed to resolve knot", "repo", repoAt, "err", err)
163 return false
164 }
165
166 params := make(url.Values)
167 for k, v := range r.URL.Query() {
168 params[k] = v
169 }
170 params.Set("repo", knot.repoIdentifier)
171
172 target := fmt.Sprintf("%s/xrpc/%s?%s", knot.baseURL, knotNSID, params.Encode())
173
174 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
175 if err != nil {
176 x.logger.Warn("proxy: failed to build request", "target", target, "err", err)
177 return false
178 }
179
180 resp, err := x.httpClient.Do(req)
181 if err != nil {
182 x.logger.Warn("proxy: knot request failed", "target", target, "err", err)
183 return false
184 }
185 defer resp.Body.Close()
186
187 for k, vv := range resp.Header {
188 if hopByHopHeaders[k] {
189 continue
190 }
191 for _, v := range vv {
192 w.Header().Add(k, v)
193 }
194 }
195 w.WriteHeader(resp.StatusCode)
196 if _, err := io.Copy(w, resp.Body); err != nil {
197 x.logger.Warn("proxy: response copy interrupted", "target", target, "err", err)
198 }
199
200 x.logger.Info("proxy: served from knot", "repo", repoAt, "knot", knot.baseURL, "status", resp.StatusCode)
201 return true
202}