aboutsummaryrefslogtreecommitdiff
path: root/backend/fetchers/app.bsky.actor.go
diff options
context:
space:
mode:
Diffstat (limited to 'backend/fetchers/app.bsky.actor.go')
-rw-r--r--backend/fetchers/app.bsky.actor.go184
1 files changed, 184 insertions, 0 deletions
diff --git a/backend/fetchers/app.bsky.actor.go b/backend/fetchers/app.bsky.actor.go
new file mode 100644
index 0000000..3c0154a
--- /dev/null
+++ b/backend/fetchers/app.bsky.actor.go
@@ -0,0 +1,184 @@
+package fetchers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "llostre/backend/general"
+ "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(did, pdsURL string, cache *Cache) (*general.Profile, error) {
+ recordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", pdsURL, did)
+
+ resp, err := http.Get(recordURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("profile fetch failed: %d", resp.StatusCode)
+ }
+
+ handle, err := FetchHandle(did)
+ if err != nil {
+ handle = "handle.invalid"
+ }
+
+ var response general.ProfileRecordResponse
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return nil, err
+ }
+
+ var avatarURL, bannerURL string
+ if response.Value.Avatar != nil {
+ avatarURL = fmt.Sprintf("https://cdn.bsky.app/img/avatar/plain/%s/%s@jpeg", did, response.Value.Avatar.Ref.Link)
+ }
+ if response.Value.Banner != nil {
+ bannerURL = fmt.Sprintf("https://cdn.bsky.app/img/banner/plain/%s/%s@jpeg", did, response.Value.Banner.Ref.Link)
+ }
+
+ profile := &general.Profile{
+ Handle: handle,
+ DisplayName: response.Value.DisplayName,
+ AvatarURL: avatarURL,
+ BannerURL: bannerURL,
+ Pronouns: response.Value.Pronouns,
+ }
+
+ cache.SetProfile(handle, profile)
+ return profile, nil
+}
+
+func FetchProfiles(dids []string, pdsURL string, cache *Cache) map[string]general.Profile {
+ profiles := make(map[string]general.Profile)
+ for _, did := range dids {
+ profile, err := FetchProfile(did, pdsURL, cache)
+ if err == nil {
+ cache.SetProfile(did, profile)
+ profiles[did] = *profile
+ }
+ }
+ return profiles
+}