diff options
| -rw-r--r-- | backend/fetchers/app.bsky.go | 83 | ||||
| -rw-r--r-- | backend/fetchers/cache.go | 73 | ||||
| -rw-r--r-- | backend/fetchers/profiles.go | 14 | ||||
| -rw-r--r-- | backend/general/utils.go | 16 | ||||
| -rw-r--r-- | backend/notes/app.bsky.feed.post.go | 4 |
5 files changed, 160 insertions, 30 deletions
diff --git a/backend/fetchers/app.bsky.go b/backend/fetchers/app.bsky.go index 6b25f0b..069a183 100644 --- a/backend/fetchers/app.bsky.go +++ b/backend/fetchers/app.bsky.go @@ -3,7 +3,9 @@ package fetchers import ( "encoding/json" "fmt" + "io" "llostre/backend/auth" + "llostre/backend/general" "llostre/backend/notes" "log/slog" "net/http" @@ -28,7 +30,7 @@ func FetchAppBskyFeedPosts(logger *slog.Logger, did, pdsURL, accessToken string, if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer " + accessToken) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) @@ -42,7 +44,7 @@ func FetchAppBskyFeedPosts(logger *slog.Logger, did, pdsURL, accessToken string, return nil, err } - profile, err := FetchProfile(did, pdsURL) + profile, err := FetchProfile(did, pdsURL, notesCache) if err != nil { logger.Error("profile fetch failed", "did", did, "error", err) } else { @@ -62,3 +64,80 @@ func FetchAppBskyFeedPosts(logger *slog.Logger, did, pdsURL, accessToken string, 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.AppBskyFeedPostFromRecord(record, profile) + if err != nil { + return nil, err + } + + // cache it + cache.Set(note.URI, note) + + return note, nil +} diff --git a/backend/fetchers/cache.go b/backend/fetchers/cache.go index 12c68a8..b863d60 100644 --- a/backend/fetchers/cache.go +++ b/backend/fetchers/cache.go @@ -1,54 +1,55 @@ package fetchers import ( + "llostre/backend/general" "llostre/backend/notes" "sync" "time" ) type CacheEntry struct { - Note *notes.Note - ExpiresAt time.Time + Note *notes.Note + ExpiresAt time.Time } type Cache struct { - posts map[string]*CacheEntry - mu sync.RWMutex + posts map[string]*CacheEntry + profiles map[string]*ProfileCacheEntry + mu sync.RWMutex } func NewCache() *Cache { return &Cache{ - posts: make(map[string]*CacheEntry), + posts: make(map[string]*CacheEntry), + profiles: make(map[string]*ProfileCacheEntry), } } func (c *Cache) Get(uri string) (*notes.Note, bool) { - c.mu.Lock() - defer c.mu.Unlock() + c.mu.Lock() + defer c.mu.Unlock() - entry, exists := c.posts[uri] + entry, exists := c.posts[uri] - if !exists { - return nil, false - } + if !exists { + return nil, false + } - if time.Now().After(entry.ExpiresAt) { - delete(c.posts, uri) - return nil, false - } + if time.Now().After(entry.ExpiresAt) { + delete(c.posts, uri) + return nil, false + } - return entry.Note, true + return entry.Note, true } func (c *Cache) Set(uri string, note *notes.Note) { c.mu.Lock() defer c.mu.Unlock() - ttl := time.Now().Add(15 * time.Minute) - c.posts[uri] = &CacheEntry{ - Note: note, - ExpiresAt: ttl, + Note: note, + ExpiresAt: time.Now().Add(15 * time.Minute), } } @@ -75,3 +76,35 @@ func (c *Cache) GetMany(uris []string) map[string]*notes.Note { return result } + +type ProfileCacheEntry struct { + Profile *general.Profile + ExpiresAt time.Time +} + +func (c *Cache) GetProfile(did string) (*general.Profile, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.profiles[did] + if !exists { + return nil, false + } + + if time.Now().After(entry.ExpiresAt) { + delete(c.profiles, did) + return nil, false + } + + return entry.Profile, true +} + +func (c *Cache) SetProfile(did string, profile *general.Profile) { + c.mu.Lock() + defer c.mu.Unlock() + + c.profiles[did] = &ProfileCacheEntry{ + Profile: profile, + ExpiresAt: time.Now().Add(15 * time.Minute), + } +} diff --git a/backend/fetchers/profiles.go b/backend/fetchers/profiles.go index 01a27c0..3c0154a 100644 --- a/backend/fetchers/profiles.go +++ b/backend/fetchers/profiles.go @@ -128,7 +128,7 @@ func FetchHandleAndPDS(did string) (string, string, error) { return handle, pds, nil } -func FetchProfile(did string, pdsURL string) (*general.Profile, error) { +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) @@ -159,20 +159,24 @@ func FetchProfile(did string, pdsURL string) (*general.Profile, error) { bannerURL = fmt.Sprintf("https://cdn.bsky.app/img/banner/plain/%s/%s@jpeg", did, response.Value.Banner.Ref.Link) } - return &general.Profile{ + profile := &general.Profile{ Handle: handle, DisplayName: response.Value.DisplayName, AvatarURL: avatarURL, BannerURL: bannerURL, Pronouns: response.Value.Pronouns, - }, nil + } + + cache.SetProfile(handle, profile) + return profile, nil } -func FetchProfiles(dids []string, pdsURL string) map[string]general.Profile { +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) + profile, err := FetchProfile(did, pdsURL, cache) if err == nil { + cache.SetProfile(did, profile) profiles[did] = *profile } } diff --git a/backend/general/utils.go b/backend/general/utils.go new file mode 100644 index 0000000..fe0854a --- /dev/null +++ b/backend/general/utils.go @@ -0,0 +1,16 @@ +package general + +import ( + "fmt" + "strings" +) + +func ParseURI(uri string) (repo, collection, rkey string, err error) { + // URI format: 'at://DID/collection/recordkey' + parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("invalid URI format: %s", uri) + } + + return parts[0], parts[1], parts[2], nil +} diff --git a/backend/notes/app.bsky.feed.post.go b/backend/notes/app.bsky.feed.post.go index 1c11733..c4311e7 100644 --- a/backend/notes/app.bsky.feed.post.go +++ b/backend/notes/app.bsky.feed.post.go @@ -3,7 +3,6 @@ package notes import ( "fmt" "llostre/backend/general" - "strings" "time" ) @@ -47,8 +46,7 @@ func AppBskyFeedPostFromRecord(record AppBskyFeedPostRecord, profile *general.Pr replyTo = &record.Value.Reply.Parent.URI } - parts := strings.Split(record.URI, "/") // at://DID/collection/rkey - did := parts[2] + did, _, _, err := general.ParseURI(record.URI) var facets []Facet for _, f := range record.Value.Facets { |
