1package api
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "net/http"
8 "net/url"
9 "strings"
10 "sync"
11 "time"
12
13 "margin.at/internal/db"
14)
15
16var (
17 Cache ProfileCache = NewInMemoryCache(5 * time.Minute)
18)
19
20type Author struct {
21 DID string `json:"did"`
22 Handle string `json:"handle"`
23 DisplayName string `json:"displayName,omitempty"`
24 Avatar string `json:"avatar,omitempty"`
25}
26
27type APISelector struct {
28 Type string `json:"type"`
29 Exact string `json:"exact,omitempty"`
30 Prefix string `json:"prefix,omitempty"`
31 Suffix string `json:"suffix,omitempty"`
32 Start *int `json:"start,omitempty"`
33 End *int `json:"end,omitempty"`
34 Value string `json:"value,omitempty"`
35 ConformsTo string `json:"conformsTo,omitempty"`
36}
37
38type APIBody struct {
39 Value string `json:"value,omitempty"`
40 Format string `json:"format,omitempty"`
41 URI string `json:"uri,omitempty"`
42}
43
44type APITarget struct {
45 Source string `json:"source"`
46 Title string `json:"title,omitempty"`
47 Selector *APISelector `json:"selector,omitempty"`
48}
49
50type APIGenerator struct {
51 ID string `json:"id"`
52 Type string `json:"type"`
53 Name string `json:"name"`
54}
55
56type APIAnnotation struct {
57 ID string `json:"id"`
58 CID string `json:"cid"`
59 Type string `json:"type"`
60 Motivation string `json:"motivation,omitempty"`
61 Author Author `json:"creator"`
62 Body *APIBody `json:"body,omitempty"`
63 Target APITarget `json:"target"`
64 Tags []string `json:"tags,omitempty"`
65 Generator *APIGenerator `json:"generator,omitempty"`
66 CreatedAt time.Time `json:"created"`
67 IndexedAt time.Time `json:"indexed"`
68 LikeCount int `json:"likeCount"`
69 ReplyCount int `json:"replyCount"`
70 ViewerHasLiked bool `json:"viewerHasLiked"`
71}
72
73type APIHighlight struct {
74 ID string `json:"id"`
75 Type string `json:"type"`
76 Author Author `json:"creator"`
77 Target APITarget `json:"target"`
78 Color string `json:"color,omitempty"`
79 Tags []string `json:"tags,omitempty"`
80 CreatedAt time.Time `json:"created"`
81 CID string `json:"cid,omitempty"`
82 LikeCount int `json:"likeCount"`
83 ReplyCount int `json:"replyCount"`
84 ViewerHasLiked bool `json:"viewerHasLiked"`
85}
86
87type APIBookmark struct {
88 ID string `json:"id"`
89 Type string `json:"type"`
90 Author Author `json:"creator"`
91 Source string `json:"source"`
92 Title string `json:"title,omitempty"`
93 Description string `json:"description,omitempty"`
94 Tags []string `json:"tags,omitempty"`
95 CreatedAt time.Time `json:"created"`
96 CID string `json:"cid,omitempty"`
97 LikeCount int `json:"likeCount"`
98 ReplyCount int `json:"replyCount"`
99 ViewerHasLiked bool `json:"viewerHasLiked"`
100}
101
102type APIReply struct {
103 ID string `json:"id"`
104 Type string `json:"type"`
105 Author Author `json:"creator"`
106 ParentURI string `json:"inReplyTo"`
107 RootURI string `json:"rootUri"`
108 Text string `json:"text"`
109 Format string `json:"format,omitempty"`
110 CreatedAt time.Time `json:"created"`
111 CID string `json:"cid,omitempty"`
112}
113
114type APICollection struct {
115 URI string `json:"uri"`
116 Name string `json:"name"`
117 Description string `json:"description,omitempty"`
118 Icon string `json:"icon,omitempty"`
119 Creator Author `json:"creator"`
120 CreatedAt time.Time `json:"createdAt"`
121 IndexedAt time.Time `json:"indexedAt"`
122}
123
124type APICollectionItem struct {
125 ID string `json:"id"`
126 Type string `json:"type"`
127 Author Author `json:"creator"`
128 CollectionURI string `json:"collectionUri"`
129 Collection *APICollection `json:"collection,omitempty"`
130 Annotation *APIAnnotation `json:"annotation,omitempty"`
131 Highlight *APIHighlight `json:"highlight,omitempty"`
132 Bookmark *APIBookmark `json:"bookmark,omitempty"`
133 CreatedAt time.Time `json:"created"`
134 Position int `json:"position"`
135}
136
137type APINotification struct {
138 ID int `json:"id"`
139 Recipient Author `json:"recipient"`
140 Actor Author `json:"actor"`
141 Type string `json:"type"`
142 SubjectURI string `json:"subjectUri"`
143 Subject interface{} `json:"subject,omitempty"`
144 CreatedAt time.Time `json:"createdAt"`
145 ReadAt *time.Time `json:"readAt,omitempty"`
146}
147
148func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) {
149 if len(annotations) == 0 {
150 return []APIAnnotation{}, nil
151 }
152
153 profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID }))
154
155 var likeCounts map[string]int
156 var replyCounts map[string]int
157 var viewerLikes map[string]bool
158
159 if database != nil {
160 uris := make([]string, len(annotations))
161 for i, a := range annotations {
162 uris[i] = a.URI
163 }
164
165 likeCounts, _ = database.GetLikeCounts(uris)
166 replyCounts, _ = database.GetReplyCounts(uris)
167 if viewerDID != "" {
168 viewerLikes, _ = database.GetViewerLikes(viewerDID, uris)
169 }
170 }
171
172 result := make([]APIAnnotation, len(annotations))
173 for i, a := range annotations {
174 var body *APIBody
175 if a.BodyValue != nil || a.BodyURI != nil {
176 body = &APIBody{}
177 if a.BodyValue != nil {
178 body.Value = *a.BodyValue
179 }
180 if a.BodyFormat != nil {
181 body.Format = *a.BodyFormat
182 }
183 if a.BodyURI != nil {
184 body.URI = *a.BodyURI
185 }
186 }
187
188 var selector *APISelector
189 if a.SelectorJSON != nil && *a.SelectorJSON != "" {
190 selector = &APISelector{}
191 json.Unmarshal([]byte(*a.SelectorJSON), selector)
192 }
193
194 var tags []string
195 if a.TagsJSON != nil && *a.TagsJSON != "" {
196 json.Unmarshal([]byte(*a.TagsJSON), &tags)
197 }
198
199 title := ""
200 if a.TargetTitle != nil {
201 title = *a.TargetTitle
202 }
203
204 cid := ""
205 if a.CID != nil {
206 cid = *a.CID
207 }
208
209 result[i] = APIAnnotation{
210 ID: a.URI,
211 CID: cid,
212 Type: "Annotation",
213 Motivation: a.Motivation,
214 Author: profiles[a.AuthorDID],
215 Body: body,
216 Target: APITarget{
217 Source: a.TargetSource,
218 Title: title,
219 Selector: selector,
220 },
221 Tags: tags,
222 Generator: &APIGenerator{
223 ID: "https://margin.at",
224 Type: "Software",
225 Name: "Margin",
226 },
227 CreatedAt: a.CreatedAt,
228 IndexedAt: a.IndexedAt,
229 }
230
231 if database != nil {
232 result[i].LikeCount = likeCounts[a.URI]
233 result[i].ReplyCount = replyCounts[a.URI]
234 if viewerLikes != nil && viewerLikes[a.URI] {
235 result[i].ViewerHasLiked = true
236 }
237 }
238 }
239
240 return result, nil
241}
242
243func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) {
244 if len(highlights) == 0 {
245 return []APIHighlight{}, nil
246 }
247
248 profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID }))
249
250 var likeCounts map[string]int
251 var replyCounts map[string]int
252 var viewerLikes map[string]bool
253
254 if database != nil {
255 uris := make([]string, len(highlights))
256 for i, h := range highlights {
257 uris[i] = h.URI
258 }
259
260 likeCounts, _ = database.GetLikeCounts(uris)
261 replyCounts, _ = database.GetReplyCounts(uris)
262 if viewerDID != "" {
263 viewerLikes, _ = database.GetViewerLikes(viewerDID, uris)
264 }
265 }
266
267 result := make([]APIHighlight, len(highlights))
268 for i, h := range highlights {
269 var selector *APISelector
270 if h.SelectorJSON != nil && *h.SelectorJSON != "" {
271 selector = &APISelector{}
272 json.Unmarshal([]byte(*h.SelectorJSON), selector)
273 }
274
275 var tags []string
276 if h.TagsJSON != nil && *h.TagsJSON != "" {
277 json.Unmarshal([]byte(*h.TagsJSON), &tags)
278 }
279
280 title := ""
281 if h.TargetTitle != nil {
282 title = *h.TargetTitle
283 }
284
285 color := ""
286 if h.Color != nil {
287 color = *h.Color
288 }
289
290 cid := ""
291 if h.CID != nil {
292 cid = *h.CID
293 }
294
295 result[i] = APIHighlight{
296 ID: h.URI,
297 Type: "Highlight",
298 Author: profiles[h.AuthorDID],
299 Target: APITarget{
300 Source: h.TargetSource,
301 Title: title,
302 Selector: selector,
303 },
304 Color: color,
305 Tags: tags,
306 CreatedAt: h.CreatedAt,
307 CID: cid,
308 }
309
310 if database != nil {
311 result[i].LikeCount = likeCounts[h.URI]
312 result[i].ReplyCount = replyCounts[h.URI]
313 if viewerLikes != nil && viewerLikes[h.URI] {
314 result[i].ViewerHasLiked = true
315 }
316 }
317 }
318
319 return result, nil
320}
321
322func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) {
323 if len(bookmarks) == 0 {
324 return []APIBookmark{}, nil
325 }
326
327 profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID }))
328
329 var likeCounts map[string]int
330 var replyCounts map[string]int
331 var viewerLikes map[string]bool
332
333 if database != nil {
334 uris := make([]string, len(bookmarks))
335 for i, b := range bookmarks {
336 uris[i] = b.URI
337 }
338
339 likeCounts, _ = database.GetLikeCounts(uris)
340 replyCounts, _ = database.GetReplyCounts(uris)
341 if viewerDID != "" {
342 viewerLikes, _ = database.GetViewerLikes(viewerDID, uris)
343 }
344 }
345
346 result := make([]APIBookmark, len(bookmarks))
347 for i, b := range bookmarks {
348 var tags []string
349 if b.TagsJSON != nil && *b.TagsJSON != "" {
350 json.Unmarshal([]byte(*b.TagsJSON), &tags)
351 }
352
353 title := ""
354 if b.Title != nil {
355 title = *b.Title
356 }
357
358 desc := ""
359 if b.Description != nil {
360 desc = *b.Description
361 }
362
363 cid := ""
364 if b.CID != nil {
365 cid = *b.CID
366 }
367
368 result[i] = APIBookmark{
369 ID: b.URI,
370 Type: "Bookmark",
371 Author: profiles[b.AuthorDID],
372 Source: b.Source,
373 Title: title,
374 Description: desc,
375 Tags: tags,
376 CreatedAt: b.CreatedAt,
377 CID: cid,
378 }
379 if database != nil {
380 result[i].LikeCount = likeCounts[b.URI]
381 result[i].ReplyCount = replyCounts[b.URI]
382 if viewerLikes != nil && viewerLikes[b.URI] {
383 result[i].ViewerHasLiked = true
384 }
385 }
386 }
387
388 return result, nil
389}
390
391func hydrateReplies(replies []db.Reply) ([]APIReply, error) {
392 if len(replies) == 0 {
393 return []APIReply{}, nil
394 }
395
396 profiles := fetchProfilesForDIDs(collectDIDs(replies, func(r db.Reply) string { return r.AuthorDID }))
397
398 result := make([]APIReply, len(replies))
399 for i, r := range replies {
400 format := "text/plain"
401 if r.Format != nil {
402 format = *r.Format
403 }
404
405 cid := ""
406 if r.CID != nil {
407 cid = *r.CID
408 }
409
410 result[i] = APIReply{
411 ID: r.URI,
412 Type: "Reply",
413 Author: profiles[r.AuthorDID],
414 ParentURI: r.ParentURI,
415 RootURI: r.RootURI,
416 Text: r.Text,
417 Format: format,
418 CreatedAt: r.CreatedAt,
419 CID: cid,
420 }
421 }
422 return result, nil
423}
424
425func collectDIDs[T any](items []T, getDID func(T) string) []string {
426 uniqueDIDs := make(map[string]bool)
427 for _, item := range items {
428 uniqueDIDs[getDID(item)] = true
429 }
430
431 dids := make([]string, 0, len(uniqueDIDs))
432 for did := range uniqueDIDs {
433 dids = append(dids, did)
434 }
435 return dids
436}
437
438func fetchProfilesForDIDs(dids []string) map[string]Author {
439 profiles := make(map[string]Author)
440 missingDIDs := make([]string, 0)
441
442 for _, did := range dids {
443 if author, ok := Cache.Get(did); ok {
444 profiles[did] = author
445 } else {
446 missingDIDs = append(missingDIDs, did)
447 }
448 }
449
450 if len(missingDIDs) == 0 {
451 return profiles
452 }
453
454 batchSize := 25
455 var wg sync.WaitGroup
456 var mu sync.Mutex
457
458 for i := 0; i < len(missingDIDs); i += batchSize {
459 end := i + batchSize
460 if end > len(missingDIDs) {
461 end = len(missingDIDs)
462 }
463 batch := missingDIDs[i:end]
464
465 wg.Add(1)
466 go func(actors []string) {
467 defer wg.Done()
468 fetched, err := fetchProfiles(actors)
469 if err == nil {
470 mu.Lock()
471 defer mu.Unlock()
472 for k, v := range fetched {
473 profiles[k] = v
474 Cache.Set(k, v)
475 }
476 }
477 }(batch)
478 }
479 wg.Wait()
480
481 return profiles
482}
483
484func fetchProfiles(dids []string) (map[string]Author, error) {
485 if len(dids) == 0 {
486 return nil, nil
487 }
488
489 q := url.Values{}
490 for _, did := range dids {
491 q.Add("actors", did)
492 }
493
494 resp, err := http.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?" + q.Encode())
495 if err != nil {
496 log.Printf("Hydration fetch error: %v\n", err)
497 return nil, err
498 }
499 defer resp.Body.Close()
500
501 if resp.StatusCode != 200 {
502 log.Printf("Hydration fetch status error: %d\n", resp.StatusCode)
503 return nil, fmt.Errorf("failed to fetch profiles: %d", resp.StatusCode)
504 }
505
506 var output struct {
507 Profiles []struct {
508 DID string `json:"did"`
509 Handle string `json:"handle"`
510 DisplayName string `json:"displayName"`
511 Avatar string `json:"avatar"`
512 } `json:"profiles"`
513 }
514
515 if err := json.NewDecoder(resp.Body).Decode(&output); err != nil {
516 return nil, err
517 }
518
519 result := make(map[string]Author)
520 for _, p := range output.Profiles {
521 result[p.DID] = Author{
522 DID: p.DID,
523 Handle: p.Handle,
524 DisplayName: p.DisplayName,
525 Avatar: getProxiedAvatarURL(p.DID, p.Avatar),
526 }
527 }
528
529 return result, nil
530}
531
532func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) {
533 if len(items) == 0 {
534 return []APICollectionItem{}, nil
535 }
536
537 profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID }))
538
539 var collectionURIs []string
540 var annotationURIs []string
541 var highlightURIs []string
542 var bookmarkURIs []string
543
544 for _, item := range items {
545 collectionURIs = append(collectionURIs, item.CollectionURI)
546 if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
547 annotationURIs = append(annotationURIs, item.AnnotationURI)
548 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
549 highlightURIs = append(highlightURIs, item.AnnotationURI)
550 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
551 bookmarkURIs = append(bookmarkURIs, item.AnnotationURI)
552 }
553 }
554
555 collectionsMap := make(map[string]APICollection)
556 if len(collectionURIs) > 0 {
557 colls, err := database.GetCollectionsByURIs(collectionURIs)
558 if err == nil {
559 collProfiles := fetchProfilesForDIDs(collectDIDs(colls, func(c db.Collection) string { return c.AuthorDID }))
560 for _, coll := range colls {
561 icon := ""
562 if coll.Icon != nil {
563 icon = *coll.Icon
564 }
565 desc := ""
566 if coll.Description != nil {
567 desc = *coll.Description
568 }
569 collectionsMap[coll.URI] = APICollection{
570 URI: coll.URI,
571 Name: coll.Name,
572 Description: desc,
573 Icon: icon,
574 Creator: collProfiles[coll.AuthorDID],
575 CreatedAt: coll.CreatedAt,
576 IndexedAt: coll.IndexedAt,
577 }
578 }
579 }
580 }
581
582 annotationsMap := make(map[string]APIAnnotation)
583 if len(annotationURIs) > 0 {
584 rawAnnos, err := database.GetAnnotationsByURIs(annotationURIs)
585 if err == nil {
586 hydrated, _ := hydrateAnnotations(database, rawAnnos, viewerDID)
587 for _, a := range hydrated {
588 annotationsMap[a.ID] = a
589 }
590 }
591 }
592
593 highlightsMap := make(map[string]APIHighlight)
594 if len(highlightURIs) > 0 {
595 rawHighlights, err := database.GetHighlightsByURIs(highlightURIs)
596 if err == nil {
597 hydrated, _ := hydrateHighlights(database, rawHighlights, viewerDID)
598 for _, h := range hydrated {
599 highlightsMap[h.ID] = h
600 }
601 }
602 }
603
604 bookmarksMap := make(map[string]APIBookmark)
605 if len(bookmarkURIs) > 0 {
606 rawBookmarks, err := database.GetBookmarksByURIs(bookmarkURIs)
607 if err == nil {
608 hydrated, _ := hydrateBookmarks(database, rawBookmarks, viewerDID)
609 for _, b := range hydrated {
610 bookmarksMap[b.ID] = b
611 }
612 }
613 }
614
615 result := make([]APICollectionItem, len(items))
616 for i, item := range items {
617 apiItem := APICollectionItem{
618 ID: item.URI,
619 Type: "CollectionItem",
620 Author: profiles[item.AuthorDID],
621 CollectionURI: item.CollectionURI,
622 CreatedAt: item.CreatedAt,
623 Position: item.Position,
624 }
625
626 if coll, ok := collectionsMap[item.CollectionURI]; ok {
627 apiItem.Collection = &coll
628 }
629
630 if val, ok := annotationsMap[item.AnnotationURI]; ok {
631 apiItem.Annotation = &val
632 } else if val, ok := highlightsMap[item.AnnotationURI]; ok {
633 apiItem.Highlight = &val
634 } else if val, ok := bookmarksMap[item.AnnotationURI]; ok {
635 apiItem.Bookmark = &val
636 }
637
638 result[i] = apiItem
639 }
640 return result, nil
641}
642
643func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]APINotification, error) {
644 if len(notifications) == 0 {
645 return []APINotification{}, nil
646 }
647
648 dids := make([]string, 0)
649 uniqueDIDs := make(map[string]bool)
650 for _, n := range notifications {
651 if !uniqueDIDs[n.ActorDID] {
652 dids = append(dids, n.ActorDID)
653 uniqueDIDs[n.ActorDID] = true
654 }
655 if !uniqueDIDs[n.RecipientDID] {
656 dids = append(dids, n.RecipientDID)
657 uniqueDIDs[n.RecipientDID] = true
658 }
659 }
660
661 profiles := fetchProfilesForDIDs(dids)
662
663 replyURIs := make([]string, 0)
664 for _, n := range notifications {
665 if n.Type == "reply" {
666 replyURIs = append(replyURIs, n.SubjectURI)
667 }
668 }
669
670 replyMap := make(map[string]APIReply)
671 if len(replyURIs) > 0 {
672 replies, err := database.GetRepliesByURIs(replyURIs)
673 if err == nil {
674 hydratedReplies, _ := hydrateReplies(replies)
675 for _, r := range hydratedReplies {
676 replyMap[r.ID] = r
677 }
678 }
679 }
680
681 result := make([]APINotification, len(notifications))
682 for i, n := range notifications {
683 var subject interface{}
684 if n.Type == "reply" {
685 if val, ok := replyMap[n.SubjectURI]; ok {
686 subject = val
687 }
688 }
689
690 result[i] = APINotification{
691 ID: n.ID,
692 Recipient: profiles[n.RecipientDID],
693 Actor: profiles[n.ActorDID],
694 Type: n.Type,
695 SubjectURI: n.SubjectURI,
696 Subject: subject,
697 CreatedAt: n.CreatedAt,
698 ReadAt: n.ReadAt,
699 }
700 }
701
702 return result, nil
703}