Monorepo for Tangled tangled.org

cmd/claimer: backfill tool to claim tngl.sh domains

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

anirudh.fi 652324f6 b36688a8

verified
+277
+277
cmd/claimer/main.go
··· 1 + package main 2 + 3 + import ( 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 + 21 + var ( 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 + 28 + type repo struct { 29 + DID string `json:"did"` 30 + Head string `json:"head"` 31 + } 32 + 33 + type listReposResponse struct { 34 + Cursor string `json:"cursor"` 35 + Repos []repo `json:"repos"` 36 + } 37 + 38 + type describeRepoResponse struct { 39 + Handle string `json:"handle"` 40 + DID string `json:"did"` 41 + } 42 + 43 + func 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. 163 + func 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. 191 + func 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. 230 + func 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. 264 + func 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 + }