package fetchers import ( "encoding/json" "fmt" "io" "llostre/backend/general" "log/slog" "net" "net/http" "strings" ) type DIDDocument struct { AlsoKnownAs []string `json:"alsoKnownAs"` Service []struct { ID string `json:"id"` Type string `json:"type"` ServiceEndpoint string `json:"serviceEndpoint"` } `json:"service"` } func FetchDID(handle string) (string, error) { // return if it's a DID already if strings.HasPrefix(handle, "did:") { return handle, nil } // resolve DNS record first txtRecords, err := net.LookupTXT("_atproto." + handle) if err == nil { for _, txt := range txtRecords { if after, ok := strings.CutPrefix(txt, "did="); ok { return after, nil } } } // fallback to HTTPS well-known resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/atproto-did", handle)) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } did := strings.TrimSpace(string(body)) if !strings.HasPrefix(did, "did:") { return "", fmt.Errorf("invalid DID returned: %s", did) } return did, nil } func FetchDidDoc(did string) (*DIDDocument, error) { didDocURL := fmt.Sprintf("https://plc.directory/%s", did) resp, err := http.Get(didDocURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("DID resolution failed: %d", resp.StatusCode) } var didDoc DIDDocument if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { return nil, err } return &didDoc, nil } func FetchHandle(did string) (string, error) { var didDoc, err = FetchDidDoc(did) if err != nil { return "", err } if len(didDoc.AlsoKnownAs) == 0 { return "", fmt.Errorf("no handle found") } handle := strings.TrimPrefix(didDoc.AlsoKnownAs[0], "at://") return handle, nil } func FetchPDS(did string) (string, error) { var didDoc, err = FetchDidDoc(did) if err != nil { return "", err } for _, svc := range didDoc.Service { if svc.Type == "AtprotoPersonalDataServer" { return svc.ServiceEndpoint, nil } } return "", fmt.Errorf("no PDS found for DID %s", did) } func FetchHandleAndPDS(did string) (string, string, error) { var didDoc, err = FetchDidDoc(did) if err != nil { return "", "", err } if len(didDoc.AlsoKnownAs) == 0 { return "", "", fmt.Errorf("no handle found") } var pds string for _, svc := range didDoc.Service { if svc.Type == "AtprotoPersonalDataServer" { pds = svc.ServiceEndpoint break } } handle := strings.TrimPrefix(didDoc.AlsoKnownAs[0], "at://") return handle, pds, nil } func FetchProfile(logger *slog.Logger, did, pdsURL, slingshotURL string, cache *Cache) (*general.Profile, error) { // construct profile record URI uri := fmt.Sprintf("at://%s/app.bsky.actor.profile/self", did) // try slingshot first if slingshotURL != "" { rawRecord, err := general.FetchRawRecordByURI(logger, uri, slingshotURL) if err != nil { logger.Warn("slingshot profile fetch failed", "did", did, "error", err) } else { return parseProfile(did, rawRecord, cache) } } // fallback to PDS rawRecord, err := general.FetchRawRecordByURI(logger, uri, pdsURL) if err != nil { return nil, err } return parseProfile(did, rawRecord, cache) } func FetchProfiles(logger *slog.Logger, dids []string, pdsURL, slingshotURL string, cache *Cache) map[string]general.Profile { profiles := make(map[string]general.Profile) for _, did := range dids { profile, err := FetchProfile(logger, did, pdsURL, slingshotURL, cache) if err == nil { profiles[did] = *profile } } return profiles } func parseProfile(did string, rawRecord *general.RawRecord, cache *Cache) (*general.Profile, error) { var profileData general.ProfileRecord if err := json.Unmarshal(rawRecord.Value, &profileData); err != nil { return nil, err } var avatarURL, bannerURL string if profileData.Avatar != nil { avatarURL = fmt.Sprintf("https://cdn.bsky.app/img/avatar/plain/%s/%s@jpeg", did, profileData.Avatar.Ref.Link) } if profileData.Banner != nil { bannerURL = fmt.Sprintf("https://cdn.bsky.app/img/banner/plain/%s/%s@jpeg", did, profileData.Banner.Ref.Link) } handle, err := FetchHandle(did) if err != nil { handle = "handle.invalid" } profile := &general.Profile{ Handle: handle, DisplayName: profileData.DisplayName, AvatarURL: avatarURL, BannerURL: bannerURL, Pronouns: profileData.Pronouns, } cache.SetProfile(did, profile) return profile, nil }