1package server
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "sort"
9 "time"
10
11 "github.com/bluesky-social/indigo/lex/util"
12 "github.com/labstack/echo/v4"
13 "github.com/vylet-app/go/database/client"
14 vyletdatabase "github.com/vylet-app/go/database/proto"
15 "github.com/vylet-app/go/generated/handlers"
16 "github.com/vylet-app/go/generated/vylet"
17 "github.com/vylet-app/go/internal/helpers"
18 "golang.org/x/sync/errgroup"
19)
20
21func (s *Server) getPosts(ctx context.Context, uris []string) (map[string]*vylet.FeedPost, error) {
22 resp, err := s.client.Post.GetPosts(ctx, &vyletdatabase.GetPostsRequest{
23 Uris: uris,
24 })
25 if err != nil {
26 return nil, fmt.Errorf("failed to get post: %w", err)
27 }
28 if client.IsNotFoundError(resp.Error) {
29 return nil, ErrDatabaseNotFound
30 }
31
32 feedPosts := make(map[string]*vylet.FeedPost)
33 for _, post := range resp.Posts {
34 feedPost := &vylet.FeedPost{
35 Caption: post.Caption,
36 CreatedAt: post.CreatedAt.AsTime().Format(time.RFC3339Nano),
37 Media: &vylet.FeedPost_Media{},
38 }
39
40 if post.Images == nil {
41 return nil, fmt.Errorf("bad post, contains no media")
42 }
43
44 media := vylet.FeedPost_Media{
45 MediaImages: &vylet.MediaImages{
46 Images: make([]*vylet.MediaImages_Image, 0, len(post.Images)),
47 },
48 }
49
50 for _, img := range post.Images {
51 mediaImg := &vylet.MediaImages_Image{
52 Image: &util.LexBlob{
53 Ref: helpers.StrToLexLink(img.Cid),
54 MimeType: img.Mime,
55 Size: img.Size,
56 },
57 }
58 if img.Alt != nil {
59 mediaImg.Alt = *img.Alt
60 }
61 if img.Width != nil && img.Height != nil {
62 mediaImg.AspectRatio = &vylet.MediaDefs_AspectRatio{
63 Width: *img.Width,
64 Height: *img.Height,
65 }
66 }
67
68 media.MediaImages.Images = append(media.MediaImages.Images, mediaImg)
69 }
70
71 if post.Facets != nil {
72 var facets []*vylet.RichtextFacet
73 if err := json.Unmarshal(post.Facets, &facets); err != nil {
74 return nil, fmt.Errorf("failed to unmarshal post facets: %w", err)
75 }
76 feedPost.Facets = facets
77 }
78 }
79
80 return feedPosts, nil
81}
82
83func (s *Server) getPostViews(ctx context.Context, uris []string, viewer string) (map[string]*vylet.FeedDefs_PostView, error) {
84 resp, err := s.client.Post.GetPosts(ctx, &vyletdatabase.GetPostsRequest{
85 Uris: uris,
86 })
87 if err != nil {
88 return nil, fmt.Errorf("failed to get post: %w", err)
89 }
90 if client.IsNotFoundError(resp.Error) {
91 return nil, ErrDatabaseNotFound
92 }
93
94 feedPostViews, err := s.postsToPostViews(ctx, resp.Posts, viewer)
95 if err != nil {
96 return nil, err
97 }
98
99 return feedPostViews, nil
100}
101
102func (s *Server) postsToPostViews(ctx context.Context, posts map[string]*vyletdatabase.Post, viewer string) (map[string]*vylet.FeedDefs_PostView, error) {
103 logger := s.logger.With("name", "postsToPostViews")
104
105 uris := make([]string, 0, len(posts))
106 dids := make([]string, 0, len(posts))
107 addedDids := make(map[string]struct{})
108 for uri, post := range posts {
109 uris = append(uris, uri)
110
111 if _, ok := addedDids[post.AuthorDid]; ok {
112 continue
113 }
114 dids = append(dids, post.AuthorDid)
115 addedDids[post.AuthorDid] = struct{}{}
116 }
117
118 g, gCtx := errgroup.WithContext(ctx)
119 var profiles map[string]*vylet.ActorDefs_ProfileViewBasic
120 var countsResp *vyletdatabase.GetPostsInteractionCountsResponse
121 g.Go(func() error {
122 maybeProfiles, err := s.getProfilesBasic(gCtx, dids)
123 if err != nil {
124 return err
125 }
126 profiles = maybeProfiles
127 return nil
128 })
129 g.Go(func() error {
130 maybeCounts, err := s.client.Post.GetPostsInteractionCounts(gCtx, &vyletdatabase.GetPostsInteractionCountsRequest{Uris: uris})
131 if err != nil {
132 return err
133 }
134 if maybeCounts.Error != nil {
135 return fmt.Errorf("failed to get post interaction counts: %s", *maybeCounts.Error)
136 }
137 countsResp = maybeCounts
138 return nil
139 })
140 if err := g.Wait(); err != nil {
141 return nil, fmt.Errorf("error getting metadata: %w", err)
142 }
143
144 feedPostViews := make(map[string]*vylet.FeedDefs_PostView)
145 for _, post := range posts {
146 profileBasic, ok := profiles[post.AuthorDid]
147 if !ok {
148 logger.Warn("failed to get profile for post", "did", post.AuthorDid, "uri", post.Uri)
149 continue
150 }
151 counts, ok := countsResp.Counts[post.Uri]
152 if !ok {
153 logger.Warn("failed to get counts for post", "uri", post.Uri)
154 continue
155 }
156
157 postView := &vylet.FeedDefs_PostView{
158 Author: profileBasic,
159 Caption: post.Caption,
160 Cid: post.Cid,
161 Facets: []*vylet.RichtextFacet{},
162 // Labels: []*atproto.LabelDefs_Label{},
163 Media: &vylet.FeedDefs_PostView_Media{},
164 LikeCount: counts.Likes,
165 ReplyCount: counts.Replies,
166 Uri: post.Uri,
167 Viewer: &vylet.FeedDefs_ViewerState{},
168 CreatedAt: post.CreatedAt.AsTime().Format(time.RFC3339Nano),
169 IndexedAt: post.IndexedAt.AsTime().Format(time.RFC3339Nano),
170 }
171
172 media := vylet.FeedDefs_PostView_Media{
173 MediaImages_View: &vylet.MediaImages_View{
174 Images: make([]*vylet.MediaImages_ViewImage, 0, len(post.Images)),
175 },
176 }
177 for _, img := range post.Images {
178 mediaImg := &vylet.MediaImages_ViewImage{
179 Alt: img.Alt,
180 Fullsize: helpers.ImageCidToCdnUrl(s.cdnHost, "fullsize", post.AuthorDid, img.Cid),
181 Thumbnail: helpers.ImageCidToCdnUrl(s.cdnHost, "thumb", post.AuthorDid, img.Cid),
182 }
183 if img.Width != nil && img.Height != nil {
184 mediaImg.AspectRatio = &vylet.MediaDefs_AspectRatio{
185 Width: *img.Width,
186 Height: *img.Height,
187 }
188 }
189
190 media.MediaImages_View.Images = append(media.MediaImages_View.Images, mediaImg)
191 }
192 postView.Media = &media
193
194 if post.Facets != nil {
195 var facets []*vylet.RichtextFacet
196 if err := json.Unmarshal(post.Facets, &facets); err != nil {
197 logger.Error("failed to unmarshal post facets", "uri", post.Uri, "err", err)
198 continue
199 }
200 postView.Facets = facets
201 }
202
203 feedPostViews[post.Uri] = postView
204 }
205
206 return feedPostViews, nil
207}
208
209func (s *Server) FeedGetPostsRequiresAuth() bool {
210 return false
211}
212
213func (s *Server) HandleFeedGetPosts(e echo.Context, input *handlers.FeedGetPostsInput) (*vylet.FeedGetPosts_Output, *echo.HTTPError) {
214 ctx := e.Request().Context()
215 viewer := getViewer(e)
216
217 logger := s.logger.With("name", "HandleFeedGetPosts", "viewer", viewer)
218
219 if len(input.Uris) == 0 {
220 return nil, NewValidationError("uris", "must supply at least one AT-URI")
221 }
222
223 if len(input.Uris) > 25 {
224 return nil, NewValidationError("uris", "no more than 25 AT-URIs may be supplied")
225 }
226
227 if allValid, err := helpers.ValidateUris(input.Uris); !allValid {
228 logger.Warn("received invalid URIs", "uris", input.Uris, "err", err)
229 return nil, NewValidationError("uris", "all URIs must be valid AT-URIs")
230 }
231
232 postViews, err := s.getPostViews(ctx, input.Uris, viewer)
233 if err != nil {
234 logger.Error("failed to get posts", "err", err)
235 return nil, ErrInternalServerErr
236 }
237
238 if len(postViews) == 0 {
239 return nil, ErrNotFound
240 }
241
242 orderedPostViews := make([]*vylet.FeedDefs_PostView, 0, len(postViews))
243 for _, uri := range input.Uris {
244 postView, ok := postViews[uri]
245 if !ok {
246 logger.Warn("failed to find post for uri", "uri", uri)
247 continue
248 }
249 orderedPostViews = append(orderedPostViews, postView)
250 }
251
252 return &vylet.FeedGetPosts_Output{
253 Posts: orderedPostViews,
254 }, nil
255}
256
257func (s *Server) FeedGetActorPostsRequiresAuth() bool {
258 return false
259}
260
261func (s *Server) HandleFeedGetActorPosts(e echo.Context, input *handlers.FeedGetActorPostsInput) (*vylet.FeedGetActorPosts_Output, *echo.HTTPError) {
262 ctx := e.Request().Context()
263 viewer := getViewer(e)
264
265 logger := s.logger.With("name", "handleGetActorPosts", "viewer", viewer)
266
267 if input.Limit != nil && (*input.Limit < 1 || *input.Limit > 100) {
268 return nil, NewValidationError("limit", "limit must be between 1 and 100")
269 } else if input.Limit == nil {
270 input.Limit = helpers.ToInt64Ptr(25)
271 }
272
273 logger = logger.With("actor", input.Actor, "limit", *input.Limit, "cursor", input.Cursor)
274
275 did, _, err := s.fetchDidHandleFromActor(ctx, input.Actor)
276 if err != nil {
277 if errors.Is(err, ErrActorNotValid) {
278 return nil, NewValidationError("actor", "actor must be a valid DID or handle")
279 }
280 logger.Error("error fetching did and handle", "err", err)
281 return nil, ErrInternalServerErr
282 }
283
284 resp, err := s.client.Post.GetPostsByActor(ctx, &vyletdatabase.GetPostsByActorRequest{
285 Did: did,
286 Limit: *input.Limit,
287 Cursor: input.Cursor,
288 })
289 if err != nil {
290 logger.Error("failed to get posts", "did", did)
291 return nil, ErrInternalServerErr
292 }
293
294 postViews, err := s.postsToPostViews(ctx, resp.Posts, viewer)
295 if err != nil {
296 s.logger.Error("failed to get post views", "err", err)
297 return nil, ErrInternalServerErr
298 }
299
300 sortedPostViews := make([]*vylet.FeedDefs_PostView, 0, len(postViews))
301 for _, postView := range postViews {
302 sortedPostViews = append(sortedPostViews, postView)
303 }
304 sort.Slice(sortedPostViews, func(i, j int) bool {
305 return sortedPostViews[i].CreatedAt > sortedPostViews[j].CreatedAt
306 })
307
308 return &vylet.FeedGetActorPosts_Output{
309 Posts: sortedPostViews,
310 Cursor: resp.Cursor,
311 }, nil
312}