A vibe coded tangled fork which supports pijul.
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "flag"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "net/url"
12 "os"
13 "strings"
14
15 appviewdb "tangled.org/core/appview/db"
16 tlog "tangled.org/core/log"
17
18 _ "github.com/mattn/go-sqlite3"
19)
20
21var (
22 dbPath = flag.String("db", "appview.db", "path to the appview SQLite database")
23 pdsHost = flag.String("pds", "https://tngl.sh", "PDS host URL")
24 dryRun = flag.Bool("dry-run", false, "print what would be done without writing to the database")
25 verbose = flag.Bool("v", false, "log pagination progress")
26)
27
28type repo struct {
29 DID string `json:"did"`
30 Head string `json:"head"`
31}
32
33type listReposResponse struct {
34 Cursor string `json:"cursor"`
35 Repos []repo `json:"repos"`
36}
37
38type describeRepoResponse struct {
39 Handle string `json:"handle"`
40 DID string `json:"did"`
41}
42
43func main() {
44 flag.Usage = func() {
45 fmt.Fprintf(os.Stderr, "Usage: claimer [flags]\n\n")
46 fmt.Fprintf(os.Stderr, "Backfills domain_claims for all existing users on the tngl.sh PDS.\n")
47 fmt.Fprintf(os.Stderr, "DIDs are fetched via com.atproto.sync.listRepos, handles are resolved\n")
48 fmt.Fprintf(os.Stderr, "via com.atproto.repo.describeRepo (no DNS verification required).\n\n")
49 fmt.Fprintf(os.Stderr, "Flags:\n")
50 flag.PrintDefaults()
51 }
52 flag.Parse()
53
54 ctx := context.Background()
55 logger := tlog.New("claimer")
56 ctx = tlog.IntoContext(ctx, logger)
57
58 pdsDomain, err := domainFromURL(*pdsHost)
59 if err != nil {
60 logger.Error("invalid pds host", "host", *pdsHost, "error", err)
61 os.Exit(1)
62 }
63
64 logger.Info("starting claimer",
65 "pds", *pdsHost,
66 "pds_domain", pdsDomain,
67 "db", *dbPath,
68 "dry_run", *dryRun,
69 )
70
71 database, err := appviewdb.Make(ctx, *dbPath)
72 if err != nil {
73 logger.Error("failed to open database", "error", err)
74 os.Exit(1)
75 }
76 defer database.Close()
77
78 dids, err := fetchAllDIDs(ctx, *pdsHost, logger)
79 if err != nil {
80 logger.Error("failed to fetch repos from PDS", "error", err)
81 os.Exit(1)
82 }
83
84 logger.Info("fetched repos", "count", len(dids))
85
86 suffix := "." + pdsDomain
87
88 var (
89 created int
90 skipped int
91 errCount int
92 )
93
94 for _, did := range dids {
95 handle, err := describeRepo(ctx, *pdsHost, did)
96 if err != nil {
97 logger.Error("failed to describe repo", "did", did, "error", err)
98 errCount++
99 continue
100 }
101
102 if !strings.HasSuffix(handle, suffix) {
103 logger.Info("skipping account: handle does not match pds domain",
104 "did", did, "handle", handle, "suffix", suffix)
105 skipped++
106 continue
107 }
108
109 // The handle itself is the claim domain: <username>.<pds-domain>
110 claimDomain := handle
111
112 if *dryRun {
113 logger.Info("[dry-run] would claim domain",
114 "did", did,
115 "handle", handle,
116 "domain", claimDomain,
117 )
118 created++
119 continue
120 }
121
122 if err := appviewdb.ClaimDomain(database, did, claimDomain); err != nil {
123 switch {
124 case errors.Is(err, appviewdb.ErrDomainTaken):
125 logger.Warn("skipping: domain already claimed by another user",
126 "did", did, "domain", claimDomain)
127 skipped++
128 case errors.Is(err, appviewdb.ErrAlreadyClaimed):
129 logger.Warn("skipping: DID already has an active claim",
130 "did", did, "domain", claimDomain)
131 skipped++
132 case errors.Is(err, appviewdb.ErrDomainCooldown):
133 logger.Warn("skipping: domain is in 30-day cooldown",
134 "did", did, "domain", claimDomain)
135 skipped++
136 default:
137 logger.Error("failed to claim domain",
138 "did", did, "domain", claimDomain, "error", err)
139 errCount++
140 }
141 continue
142 }
143
144 logger.Info("claimed domain", "did", did, "domain", claimDomain)
145 created++
146 }
147
148 logger.Info("claimer finished",
149 slog.Group("results",
150 "created", created,
151 "skipped", skipped,
152 "errors", errCount,
153 ),
154 )
155
156 if errCount > 0 {
157 os.Exit(1)
158 }
159}
160
161// fetchAllDIDs pages through com.atproto.sync.listRepos (public, no auth)
162// and returns every DID hosted on the PDS.
163func fetchAllDIDs(ctx context.Context, pdsHost string, logger *slog.Logger) ([]string, error) {
164 var all []string
165 cursor := ""
166
167 for {
168 batch, nextCursor, err := listRepos(ctx, pdsHost, cursor)
169 if err != nil {
170 return nil, err
171 }
172
173 for _, r := range batch {
174 all = append(all, r.DID)
175 }
176
177 if *verbose {
178 logger.Info("fetched page", "count", len(batch), "total", len(all), "next_cursor", nextCursor)
179 }
180
181 if nextCursor == "" || nextCursor == cursor {
182 break
183 }
184 cursor = nextCursor
185 }
186
187 return all, nil
188}
189
190// listRepos fetches one page of com.atproto.sync.listRepos.
191func listRepos(ctx context.Context, pdsHost, cursor string) ([]repo, string, error) {
192 params := url.Values{}
193 params.Set("limit", "100")
194 if cursor != "" {
195 params.Set("cursor", cursor)
196 }
197
198 endpoint := pdsHost + "/xrpc/com.atproto.sync.listRepos?" + params.Encode()
199 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
200 if err != nil {
201 return nil, "", fmt.Errorf("building listRepos request: %w", err)
202 }
203
204 resp, err := http.DefaultClient.Do(req)
205 if err != nil {
206 return nil, "", fmt.Errorf("executing listRepos request: %w", err)
207 }
208 defer resp.Body.Close()
209
210 if resp.StatusCode != http.StatusOK {
211 var errBody struct {
212 Error string `json:"error"`
213 Message string `json:"message"`
214 }
215 json.NewDecoder(resp.Body).Decode(&errBody)
216 return nil, "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message)
217 }
218
219 var result listReposResponse
220 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
221 return nil, "", fmt.Errorf("decoding listRepos response: %w", err)
222 }
223
224 return result.Repos, result.Cursor, nil
225}
226
227// describeRepo calls com.atproto.repo.describeRepo on the PDS (public, no auth)
228// and returns the handle for the given DID. This avoids DNS-based verification
229// so accounts with broken handles still resolve correctly.
230func describeRepo(ctx context.Context, pdsHost, did string) (string, error) {
231 params := url.Values{}
232 params.Set("repo", did)
233
234 endpoint := pdsHost + "/xrpc/com.atproto.repo.describeRepo?" + params.Encode()
235 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
236 if err != nil {
237 return "", fmt.Errorf("building describeRepo request: %w", err)
238 }
239
240 resp, err := http.DefaultClient.Do(req)
241 if err != nil {
242 return "", fmt.Errorf("executing describeRepo request: %w", err)
243 }
244 defer resp.Body.Close()
245
246 if resp.StatusCode != http.StatusOK {
247 var errBody struct {
248 Error string `json:"error"`
249 Message string `json:"message"`
250 }
251 json.NewDecoder(resp.Body).Decode(&errBody)
252 return "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message)
253 }
254
255 var result describeRepoResponse
256 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
257 return "", fmt.Errorf("decoding describeRepo response: %w", err)
258 }
259
260 return result.Handle, nil
261}
262
263// domainFromURL strips the scheme from a URL and returns the bare hostname.
264func domainFromURL(rawURL string) (string, error) {
265 if !strings.Contains(rawURL, "://") {
266 return rawURL, nil
267 }
268 u, err := url.Parse(rawURL)
269 if err != nil {
270 return "", err
271 }
272 host := u.Hostname()
273 if host == "" {
274 return "", fmt.Errorf("no hostname in URL %q", rawURL)
275 }
276 return host, nil
277}