a love letter to tangled (android, iOS, and a search API)
at main 224 lines 5.6 kB view raw
1package backfill 2 3import ( 4 "bytes" 5 "context" 6 "encoding/base64" 7 "encoding/json" 8 "fmt" 9 "io" 10 "net/http" 11 "net/url" 12 "strings" 13 "time" 14) 15 16type tapAdmin interface { 17 RepoStatus(ctx context.Context, did string) (RepoStatus, error) 18 AddRepos(ctx context.Context, dids []string) error 19} 20 21type RepoStatus struct { 22 Found bool 23 Tracked bool 24 Backfilled bool 25 Backfilling bool 26 State string 27} 28 29// HTTPTapAdmin calls Tap admin endpoints for backfill orchestration. 30type HTTPTapAdmin struct { 31 baseURL string 32 password string 33 client *http.Client 34} 35 36func NewHTTPTapAdmin(tapURL, password string) (*HTTPTapAdmin, error) { 37 baseURL, err := normalizeTapBaseURL(tapURL) 38 if err != nil { 39 return nil, err 40 } 41 return &HTTPTapAdmin{ 42 baseURL: baseURL, 43 password: password, 44 client: &http.Client{ 45 Timeout: 15 * time.Second, 46 }, 47 }, nil 48} 49 50func (t *HTTPTapAdmin) RepoStatus(ctx context.Context, did string) (RepoStatus, error) { 51 endpoint := fmt.Sprintf("%s/info/%s", t.baseURL, url.PathEscape(did)) 52 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 53 if err != nil { 54 return RepoStatus{}, fmt.Errorf("build tap info request: %w", err) 55 } 56 t.addAuth(req) 57 58 resp, err := t.client.Do(req) 59 if err != nil { 60 return RepoStatus{}, fmt.Errorf("tap info request: %w", err) 61 } 62 defer resp.Body.Close() 63 64 if resp.StatusCode == http.StatusNotFound { 65 return RepoStatus{Found: false}, nil 66 } 67 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 68 return RepoStatus{}, fmt.Errorf("tap info request failed: status %d", resp.StatusCode) 69 } 70 71 body, err := io.ReadAll(resp.Body) 72 if err != nil { 73 return RepoStatus{}, fmt.Errorf("read tap info response: %w", err) 74 } 75 if len(bytes.TrimSpace(body)) == 0 { 76 return RepoStatus{Found: true, Tracked: true}, nil 77 } 78 79 var payload map[string]any 80 if err := json.Unmarshal(body, &payload); err != nil { 81 return RepoStatus{}, fmt.Errorf("decode tap info response: %w", err) 82 } 83 84 status := RepoStatus{Found: true, Tracked: true} 85 if tracked, ok := boolFromAnyWithPresence(payload, "tracked", "isTracked", "enabled", "registered"); ok { 86 status.Tracked = tracked 87 } 88 status.Backfilled = boolFromAny(payload, "backfilled", "isBackfilled", "complete", "done") 89 status.Backfilling = boolFromAny(payload, "backfilling", "inProgress", "in_progress", "pendingBackfill") 90 status.State = stringFromAny(payload, "status", "state") 91 92 if stateImpliesBackfilled(status.State) { 93 status.Backfilled = true 94 } 95 if stateImpliesBackfilling(status.State) { 96 status.Backfilling = true 97 } 98 return status, nil 99} 100 101func (t *HTTPTapAdmin) AddRepos(ctx context.Context, dids []string) error { 102 if len(dids) == 0 { 103 return nil 104 } 105 106 payload, err := json.Marshal(map[string][]string{"dids": dids}) 107 if err != nil { 108 return fmt.Errorf("marshal repos add payload: %w", err) 109 } 110 111 endpoint := t.baseURL + "/repos/add" 112 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) 113 if err != nil { 114 return fmt.Errorf("build repos add request: %w", err) 115 } 116 req.Header.Set("Content-Type", "application/json") 117 t.addAuth(req) 118 119 resp, err := t.client.Do(req) 120 if err != nil { 121 return fmt.Errorf("repos add request: %w", err) 122 } 123 defer resp.Body.Close() 124 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 125 return fmt.Errorf("repos add failed: status %d", resp.StatusCode) 126 } 127 return nil 128} 129 130func (t *HTTPTapAdmin) addAuth(req *http.Request) { 131 if t.password == "" { 132 return 133 } 134 token := base64.StdEncoding.EncodeToString([]byte("admin:" + t.password)) 135 req.Header.Set("Authorization", "Basic "+token) 136} 137 138func normalizeTapBaseURL(raw string) (string, error) { 139 raw = strings.TrimSpace(raw) 140 if raw == "" { 141 return "", fmt.Errorf("tap url is required") 142 } 143 u, err := url.Parse(raw) 144 if err != nil { 145 return "", fmt.Errorf("parse tap url: %w", err) 146 } 147 switch u.Scheme { 148 case "ws": 149 u.Scheme = "http" 150 case "wss": 151 u.Scheme = "https" 152 case "http", "https": 153 default: 154 return "", fmt.Errorf("unsupported tap url scheme %q", u.Scheme) 155 } 156 157 u.RawQuery = "" 158 u.Fragment = "" 159 u.Path = strings.TrimSuffix(u.Path, "/") 160 u.Path = strings.TrimSuffix(u.Path, "/channel") 161 if u.Path == "/" { 162 u.Path = "" 163 } 164 return strings.TrimSuffix(u.String(), "/"), nil 165} 166 167func boolFromAny(payload map[string]any, keys ...string) bool { 168 v, _ := boolFromAnyWithPresence(payload, keys...) 169 return v 170} 171 172func boolFromAnyWithPresence(payload map[string]any, keys ...string) (bool, bool) { 173 for _, key := range keys { 174 raw, ok := payload[key] 175 if !ok { 176 continue 177 } 178 switch v := raw.(type) { 179 case bool: 180 return v, true 181 case float64: 182 return v != 0, true 183 case string: 184 switch strings.ToLower(strings.TrimSpace(v)) { 185 case "true", "1", "yes", "y", "active", "complete", "done", "backfilled", "backfilling", "in_progress", "in-progress": 186 return true, true 187 case "false", "0", "no", "n", "inactive": 188 return false, true 189 } 190 } 191 } 192 return false, false 193} 194 195func stringFromAny(payload map[string]any, keys ...string) string { 196 for _, key := range keys { 197 raw, ok := payload[key] 198 if !ok { 199 continue 200 } 201 if value, ok := raw.(string); ok { 202 return strings.TrimSpace(strings.ToLower(value)) 203 } 204 } 205 return "" 206} 207 208func stateImpliesBackfilled(state string) bool { 209 switch strings.TrimSpace(strings.ToLower(state)) { 210 case "backfilled", "complete", "completed", "done", "ready", "synced": 211 return true 212 default: 213 return false 214 } 215} 216 217func stateImpliesBackfilling(state string) bool { 218 switch strings.TrimSpace(strings.ToLower(state)) { 219 case "backfilling", "in-progress", "in_progress", "pending", "queued", "running": 220 return true 221 default: 222 return false 223 } 224}