diff --git a/cmd/claimer/main.go b/cmd/claimer/main.go new file mode 100644 --- /dev/null +++ b/cmd/claimer/main.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + + appviewdb "tangled.org/core/appview/db" + tlog "tangled.org/core/log" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + dbPath = flag.String("db", "appview.db", "path to the appview SQLite database") + pdsHost = flag.String("pds", "https://tngl.sh", "PDS host URL") + dryRun = flag.Bool("dry-run", false, "print what would be done without writing to the database") + verbose = flag.Bool("v", false, "log pagination progress") +) + +type repo struct { + DID string `json:"did"` + Head string `json:"head"` +} + +type listReposResponse struct { + Cursor string `json:"cursor"` + Repos []repo `json:"repos"` +} + +type describeRepoResponse struct { + Handle string `json:"handle"` + DID string `json:"did"` +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: claimer [flags]\n\n") + fmt.Fprintf(os.Stderr, "Backfills domain_claims for all existing users on the tngl.sh PDS.\n") + fmt.Fprintf(os.Stderr, "DIDs are fetched via com.atproto.sync.listRepos, handles are resolved\n") + fmt.Fprintf(os.Stderr, "via com.atproto.repo.describeRepo (no DNS verification required).\n\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + } + flag.Parse() + + ctx := context.Background() + logger := tlog.New("claimer") + ctx = tlog.IntoContext(ctx, logger) + + pdsDomain, err := domainFromURL(*pdsHost) + if err != nil { + logger.Error("invalid pds host", "host", *pdsHost, "error", err) + os.Exit(1) + } + + logger.Info("starting claimer", + "pds", *pdsHost, + "pds_domain", pdsDomain, + "db", *dbPath, + "dry_run", *dryRun, + ) + + database, err := appviewdb.Make(ctx, *dbPath) + if err != nil { + logger.Error("failed to open database", "error", err) + os.Exit(1) + } + defer database.Close() + + dids, err := fetchAllDIDs(ctx, *pdsHost, logger) + if err != nil { + logger.Error("failed to fetch repos from PDS", "error", err) + os.Exit(1) + } + + logger.Info("fetched repos", "count", len(dids)) + + suffix := "." + pdsDomain + + var ( + created int + skipped int + errCount int + ) + + for _, did := range dids { + handle, err := describeRepo(ctx, *pdsHost, did) + if err != nil { + logger.Error("failed to describe repo", "did", did, "error", err) + errCount++ + continue + } + + if !strings.HasSuffix(handle, suffix) { + logger.Info("skipping account: handle does not match pds domain", + "did", did, "handle", handle, "suffix", suffix) + skipped++ + continue + } + + // The handle itself is the claim domain: . + claimDomain := handle + + if *dryRun { + logger.Info("[dry-run] would claim domain", + "did", did, + "handle", handle, + "domain", claimDomain, + ) + created++ + continue + } + + if err := appviewdb.ClaimDomain(database, did, claimDomain); err != nil { + switch { + case errors.Is(err, appviewdb.ErrDomainTaken): + logger.Warn("skipping: domain already claimed by another user", + "did", did, "domain", claimDomain) + skipped++ + case errors.Is(err, appviewdb.ErrAlreadyClaimed): + logger.Warn("skipping: DID already has an active claim", + "did", did, "domain", claimDomain) + skipped++ + case errors.Is(err, appviewdb.ErrDomainCooldown): + logger.Warn("skipping: domain is in 30-day cooldown", + "did", did, "domain", claimDomain) + skipped++ + default: + logger.Error("failed to claim domain", + "did", did, "domain", claimDomain, "error", err) + errCount++ + } + continue + } + + logger.Info("claimed domain", "did", did, "domain", claimDomain) + created++ + } + + logger.Info("claimer finished", + slog.Group("results", + "created", created, + "skipped", skipped, + "errors", errCount, + ), + ) + + if errCount > 0 { + os.Exit(1) + } +} + +// fetchAllDIDs pages through com.atproto.sync.listRepos (public, no auth) +// and returns every DID hosted on the PDS. +func fetchAllDIDs(ctx context.Context, pdsHost string, logger *slog.Logger) ([]string, error) { + var all []string + cursor := "" + + for { + batch, nextCursor, err := listRepos(ctx, pdsHost, cursor) + if err != nil { + return nil, err + } + + for _, r := range batch { + all = append(all, r.DID) + } + + if *verbose { + logger.Info("fetched page", "count", len(batch), "total", len(all), "next_cursor", nextCursor) + } + + if nextCursor == "" || nextCursor == cursor { + break + } + cursor = nextCursor + } + + return all, nil +} + +// listRepos fetches one page of com.atproto.sync.listRepos. +func listRepos(ctx context.Context, pdsHost, cursor string) ([]repo, string, error) { + params := url.Values{} + params.Set("limit", "100") + if cursor != "" { + params.Set("cursor", cursor) + } + + endpoint := pdsHost + "/xrpc/com.atproto.sync.listRepos?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, "", fmt.Errorf("building listRepos request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("executing listRepos request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errBody struct { + Error string `json:"error"` + Message string `json:"message"` + } + json.NewDecoder(resp.Body).Decode(&errBody) + return nil, "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message) + } + + var result listReposResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, "", fmt.Errorf("decoding listRepos response: %w", err) + } + + return result.Repos, result.Cursor, nil +} + +// describeRepo calls com.atproto.repo.describeRepo on the PDS (public, no auth) +// and returns the handle for the given DID. This avoids DNS-based verification +// so accounts with broken handles still resolve correctly. +func describeRepo(ctx context.Context, pdsHost, did string) (string, error) { + params := url.Values{} + params.Set("repo", did) + + endpoint := pdsHost + "/xrpc/com.atproto.repo.describeRepo?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("building describeRepo request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("executing describeRepo request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errBody struct { + Error string `json:"error"` + Message string `json:"message"` + } + json.NewDecoder(resp.Body).Decode(&errBody) + return "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message) + } + + var result describeRepoResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decoding describeRepo response: %w", err) + } + + return result.Handle, nil +} + +// domainFromURL strips the scheme from a URL and returns the bare hostname. +func domainFromURL(rawURL string) (string, error) { + if !strings.Contains(rawURL, "://") { + return rawURL, nil + } + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("no hostname in URL %q", rawURL) + } + return host, nil +}