Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 18 kB view raw
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}