a love letter to tangled (android, iOS, and a search API)
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}