From e7b376211cdf013528c1919a28caa28cc0ec9c93 Mon Sep 17 00:00:00 2001 From: Guillermo Serrahima Date: Wed, 18 Mar 2026 17:00:51 +0100 Subject: file renaming --- backend/fetchers/app.bsky.actor.go | 184 +++++++++++++++++++++++++++++++++++++ backend/fetchers/app.bsky.feed.go | 143 ++++++++++++++++++++++++++++ backend/fetchers/app.bsky.go | 143 ---------------------------- backend/fetchers/profiles.go | 184 ------------------------------------- 4 files changed, 327 insertions(+), 327 deletions(-) create mode 100644 backend/fetchers/app.bsky.actor.go create mode 100644 backend/fetchers/app.bsky.feed.go delete mode 100644 backend/fetchers/app.bsky.go delete mode 100644 backend/fetchers/profiles.go 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 +} diff --git a/backend/fetchers/app.bsky.feed.go b/backend/fetchers/app.bsky.feed.go new file mode 100644 index 0000000..92303fc --- /dev/null +++ b/backend/fetchers/app.bsky.feed.go @@ -0,0 +1,143 @@ +package fetchers + +import ( + "encoding/json" + "fmt" + "io" + "llostre/backend/auth" + "llostre/backend/general" + "llostre/backend/notes" + "log/slog" + "net/http" +) + +type ListRecordsResponse struct { + Records []notes.AppBskyFeedPostRecord `json:"records"` +} + +func FetchAppBskyFeedPosts(logger *slog.Logger, did, pdsURL, accessToken string, keypair *auth.DPoPKeyPair, notesCache *Cache) ([]notes.Note, error) { + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=app.bsky.feed.post", pdsURL, did) + + logger.Debug("generating DPoP proof", "url", url) + dpopProof, err := keypair.GenerateDPoPProof("GET", url, "") + if err != nil { + logger.Error("DPoP generation failed", "error", err) + return nil, err + } + + logger.Debug("making request to PDS") + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("DPoP", dpopProof) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var response ListRecordsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, err + } + + profile, err := FetchProfile(did, pdsURL, notesCache) + if err != nil { + logger.Error("profile fetch failed", "did", did, "error", err) + } else { + logger.Debug("fetched profile") + } + + posts := make([]notes.Note, 0, len(response.Records)) + for _, record := range response.Records { + bp, err := notes.NoteFromAppBskyFeedPostRecord(record, profile) + if err != nil { + logger.Error("failed to convert record", "error", err, "uri", record.URI) + continue + } + notesCache.Set(bp.URI, bp) + posts = append(posts, *bp) + } + + return posts, nil +} + +func FetchPostByURI(uri, accessToken string, keypair *auth.DPoPKeyPair, cache *Cache) (*notes.Note, error) { + // check cache + if cached, found := cache.Get(uri); found { + return cached, nil + } + + repo, collection, rkey, err := general.ParseURI(uri) + + // need to resolve DID to PDS + pds, err := FetchPDS(repo) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + pds, repo, collection, rkey) + + // fetch with DPoP + dpopProof, err := keypair.GenerateDPoPProof("GET", url, "") + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("DPoP", dpopProof) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + URI string `json:"uri"` + Value json.RawMessage `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // convert to AppBskyFeedPostRecord + record := notes.AppBskyFeedPostRecord{ + URI: result.URI, + } + if err := json.Unmarshal(result.Value, &record.Value); err != nil { + return nil, err + } + + // get author of the note + profile, found := cache.GetProfile(repo) + if !found { + profile, err = FetchProfile(repo, pds, cache) + } + if err != nil { + return nil, err + } + + note, err := notes.NoteFromAppBskyFeedPostRecord(record, profile) + if err != nil { + return nil, err + } + + // cache it + cache.Set(note.URI, note) + + return note, nil +} diff --git a/backend/fetchers/app.bsky.go b/backend/fetchers/app.bsky.go deleted file mode 100644 index 92303fc..0000000 --- a/backend/fetchers/app.bsky.go +++ /dev/null @@ -1,143 +0,0 @@ -package fetchers - -import ( - "encoding/json" - "fmt" - "io" - "llostre/backend/auth" - "llostre/backend/general" - "llostre/backend/notes" - "log/slog" - "net/http" -) - -type ListRecordsResponse struct { - Records []notes.AppBskyFeedPostRecord `json:"records"` -} - -func FetchAppBskyFeedPosts(logger *slog.Logger, did, pdsURL, accessToken string, keypair *auth.DPoPKeyPair, notesCache *Cache) ([]notes.Note, error) { - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=app.bsky.feed.post", pdsURL, did) - - logger.Debug("generating DPoP proof", "url", url) - dpopProof, err := keypair.GenerateDPoPProof("GET", url, "") - if err != nil { - logger.Error("DPoP generation failed", "error", err) - return nil, err - } - - logger.Debug("making request to PDS") - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("DPoP", dpopProof) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var response ListRecordsResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, err - } - - profile, err := FetchProfile(did, pdsURL, notesCache) - if err != nil { - logger.Error("profile fetch failed", "did", did, "error", err) - } else { - logger.Debug("fetched profile") - } - - posts := make([]notes.Note, 0, len(response.Records)) - for _, record := range response.Records { - bp, err := notes.NoteFromAppBskyFeedPostRecord(record, profile) - if err != nil { - logger.Error("failed to convert record", "error", err, "uri", record.URI) - continue - } - notesCache.Set(bp.URI, bp) - posts = append(posts, *bp) - } - - return posts, nil -} - -func FetchPostByURI(uri, accessToken string, keypair *auth.DPoPKeyPair, cache *Cache) (*notes.Note, error) { - // check cache - if cached, found := cache.Get(uri); found { - return cached, nil - } - - repo, collection, rkey, err := general.ParseURI(uri) - - // need to resolve DID to PDS - pds, err := FetchPDS(repo) - if err != nil { - return nil, err - } - - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pds, repo, collection, rkey) - - // fetch with DPoP - dpopProof, err := keypair.GenerateDPoPProof("GET", url, "") - if err != nil { - return nil, err - } - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("DPoP", dpopProof) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - URI string `json:"uri"` - Value json.RawMessage `json:"value"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // convert to AppBskyFeedPostRecord - record := notes.AppBskyFeedPostRecord{ - URI: result.URI, - } - if err := json.Unmarshal(result.Value, &record.Value); err != nil { - return nil, err - } - - // get author of the note - profile, found := cache.GetProfile(repo) - if !found { - profile, err = FetchProfile(repo, pds, cache) - } - if err != nil { - return nil, err - } - - note, err := notes.NoteFromAppBskyFeedPostRecord(record, profile) - if err != nil { - return nil, err - } - - // cache it - cache.Set(note.URI, note) - - return note, nil -} diff --git a/backend/fetchers/profiles.go b/backend/fetchers/profiles.go deleted file mode 100644 index 3c0154a..0000000 --- a/backend/fetchers/profiles.go +++ /dev/null @@ -1,184 +0,0 @@ -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 -} -- cgit v1.2.3