Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

at master 5.9 kB View raw
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}