package xrpc import ( "context" "fmt" "io" "net/http" "net/url" "strings" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/knotmirror/db" "tangled.org/core/knotmirror/models" ) var mirrorToKnotNSID = map[string]string{ tangled.GitTempListBranchesNSID: tangled.RepoBranchesNSID, tangled.GitTempListTagsNSID: tangled.RepoTagsNSID, tangled.GitTempListCommitsNSID: tangled.RepoLogNSID, tangled.GitTempGetTreeNSID: tangled.RepoTreeNSID, tangled.GitTempGetBranchNSID: tangled.RepoBranchNSID, tangled.GitTempGetBlobNSID: tangled.RepoBlobNSID, tangled.GitTempGetTagNSID: tangled.RepoTagNSID, tangled.GitTempGetArchiveNSID: tangled.RepoArchiveNSID, tangled.GitTempListLanguagesNSID: tangled.RepoLanguagesNSID, } var hopByHopHeaders = map[string]bool{ "Connection": true, "Keep-Alive": true, "Transfer-Encoding": true, "Te": true, "Trailer": true, "Upgrade": true, "Proxy-Authorization": true, "Proxy-Authenticate": true, } type knotInfo struct { baseURL string didSlashRepo string } func (x *Xrpc) resolveKnot(ctx context.Context, repoAt syntax.ATURI) (*knotInfo, error) { repo, err := db.GetRepoByAtUri(ctx, x.db, repoAt) if err == nil && repo != nil { if repo.State != models.RepoStatePending && repo.State != models.RepoStateResyncing { go func() { if err := db.UpdateRepoState(context.Background(), x.db, repo.Did, repo.Rkey, models.RepoStatePending); err != nil { x.logger.Error("failed to mark repo for resync after proxy", "err", err) } }() } return &knotInfo{baseURL: repo.KnotDomain, didSlashRepo: repo.DidSlashRepo()}, nil } owner, err := x.resolver.ResolveIdent(ctx, repoAt.Authority().String()) if err != nil { return nil, fmt.Errorf("resolving repo owner: %w", err) } xrpcc := indigoxrpc.Client{Host: owner.PDSEndpoint()} out, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) if err != nil { return nil, fmt.Errorf("fetching repo record from PDS: %w", err) } record := out.Value.Val.(*tangled.Repo) knotURL := record.Knot if !strings.Contains(knotURL, "://") { scheme := "http" if x.cfg.KnotUseSSL { scheme = "https" } knotURL = scheme + "://" + knotURL } go func() { bgCtx := context.Background() pending := &models.Repo{ Did: owner.DID, Rkey: repoAt.RecordKey(), Cid: (*syntax.CID)(out.Cid), Name: record.Name, KnotDomain: knotURL, State: models.RepoStatePending, } if upsertErr := db.UpsertRepo(bgCtx, x.db, pending); upsertErr != nil { x.logger.Error("failed to upsert repo after proxy resolution", "err", upsertErr) } }() return &knotInfo{ baseURL: knotURL, didSlashRepo: fmt.Sprintf("%s/%s", owner.DID, record.Name), }, nil } func (x *Xrpc) proxyToKnot(w http.ResponseWriter, r *http.Request, repoAt syntax.ATURI) bool { mirrorNSID := strings.TrimPrefix(r.URL.Path, "/xrpc/") knotNSID, ok := mirrorToKnotNSID[mirrorNSID] if !ok { return false } knot, err := x.resolveKnot(r.Context(), repoAt) if err != nil { x.logger.Warn("proxy: failed to resolve knot", "repo", repoAt, "err", err) return false } params := make(url.Values) for k, v := range r.URL.Query() { params[k] = v } params.Set("repo", knot.didSlashRepo) target := fmt.Sprintf("%s/xrpc/%s?%s", knot.baseURL, knotNSID, params.Encode()) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) if err != nil { x.logger.Warn("proxy: failed to build request", "target", target, "err", err) return false } resp, err := x.httpClient.Do(req) if err != nil { x.logger.Warn("proxy: knot request failed", "target", target, "err", err) return false } defer resp.Body.Close() for k, vv := range resp.Header { if hopByHopHeaders[k] { continue } for _, v := range vv { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) if _, err := io.Copy(w, resp.Body); err != nil { x.logger.Warn("proxy: response copy interrupted", "target", target, "err", err) } x.logger.Info("proxy: served from knot", "repo", repoAt, "knot", knot.baseURL, "status", resp.StatusCode) return true }