A vibe coded tangled fork which supports pijul.
at master 277 lines 7.1 kB view raw
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}