Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278package 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: <username>.<pds-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}