Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 24 kB view raw
1package api 2 3import ( 4 "encoding/json" 5 "io" 6 "log" 7 "net/http" 8 "net/url" 9 "strconv" 10 "strings" 11 "sync" 12 "time" 13 14 "github.com/go-chi/chi/v5" 15 16 "margin.at/internal/db" 17 "margin.at/internal/xrpc" 18) 19 20type Handler struct { 21 db *db.DB 22 annotationService *AnnotationService 23 refresher *TokenRefresher 24 apiKeys *APIKeyHandler 25} 26 27func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 28 return &Handler{ 29 db: database, 30 annotationService: annotationService, 31 refresher: refresher, 32 apiKeys: NewAPIKeyHandler(database, refresher), 33 } 34} 35 36func (h *Handler) RegisterRoutes(r chi.Router) { 37 r.Get("/health", h.Health) 38 39 r.Route("/api", func(r chi.Router) { 40 r.Get("/annotations", h.GetAnnotations) 41 r.Get("/annotations/feed", h.GetFeed) 42 r.Get("/annotation", h.GetAnnotation) 43 r.Get("/annotations/history", h.GetEditHistory) 44 r.Put("/annotations", h.annotationService.UpdateAnnotation) 45 46 r.Get("/highlights", h.GetHighlights) 47 r.Put("/highlights", h.annotationService.UpdateHighlight) 48 49 r.Get("/bookmarks", h.GetBookmarks) 50 r.Post("/bookmarks", h.annotationService.CreateBookmark) 51 r.Put("/bookmarks", h.annotationService.UpdateBookmark) 52 53 collectionService := NewCollectionService(h.db, h.refresher) 54 r.Post("/collections", collectionService.CreateCollection) 55 r.Get("/collections", collectionService.GetCollections) 56 r.Put("/collections", collectionService.UpdateCollection) 57 r.Delete("/collections", collectionService.DeleteCollection) 58 r.Post("/collections/{collection}/items", collectionService.AddCollectionItem) 59 r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) 60 r.Delete("/collections/items", collectionService.RemoveCollectionItem) 61 r.Get("/collections/containing", collectionService.GetAnnotationCollections) 62 r.Post("/sync", h.SyncAll) 63 64 r.Get("/targets", h.GetByTarget) 65 66 r.Get("/users/{did}/annotations", h.GetUserAnnotations) 67 r.Get("/users/{did}/highlights", h.GetUserHighlights) 68 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 69 70 r.Get("/replies", h.GetReplies) 71 r.Get("/likes", h.GetLikeCount) 72 r.Get("/url-metadata", h.GetURLMetadata) 73 r.Get("/notifications", h.GetNotifications) 74 r.Get("/notifications/count", h.GetUnreadNotificationCount) 75 r.Post("/notifications/read", h.MarkNotificationsRead) 76 r.Get("/avatar/{did}", h.HandleAvatarProxy) 77 78 r.Post("/keys", h.apiKeys.CreateKey) 79 r.Get("/keys", h.apiKeys.ListKeys) 80 r.Delete("/keys/{id}", h.apiKeys.DeleteKey) 81 82 r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 83 r.Post("/quick/save", h.apiKeys.QuickSave) 84 }) 85} 86 87func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { 88 w.Header().Set("Content-Type", "application/json") 89 json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": "1.0"}) 90} 91 92func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { 93 source := r.URL.Query().Get("source") 94 if source == "" { 95 source = r.URL.Query().Get("url") 96 } 97 98 limit := parseIntParam(r, "limit", 50) 99 offset := parseIntParam(r, "offset", 0) 100 motivation := r.URL.Query().Get("motivation") 101 tag := r.URL.Query().Get("tag") 102 103 var annotations []db.Annotation 104 var err error 105 106 if source != "" { 107 urlHash := db.HashURL(source) 108 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 109 } else if motivation != "" { 110 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 111 } else if tag != "" { 112 annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 113 } else { 114 annotations, err = h.db.GetRecentAnnotations(limit, offset) 115 } 116 117 if err != nil { 118 http.Error(w, err.Error(), http.StatusInternalServerError) 119 return 120 } 121 122 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 123 124 w.Header().Set("Content-Type", "application/json") 125 json.NewEncoder(w).Encode(map[string]interface{}{ 126 "@context": "http://www.w3.org/ns/anno.jsonld", 127 "type": "AnnotationCollection", 128 "items": enriched, 129 "totalItems": len(enriched), 130 }) 131} 132 133func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 134 limit := parseIntParam(r, "limit", 50) 135 tag := r.URL.Query().Get("tag") 136 creator := r.URL.Query().Get("creator") 137 138 viewerDID := h.getViewerDID(r) 139 140 if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "")) { 141 if creator == viewerDID { 142 h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) 143 return 144 } 145 } 146 147 var annotations []db.Annotation 148 var highlights []db.Highlight 149 var bookmarks []db.Bookmark 150 var collectionItems []db.CollectionItem 151 var err error 152 153 if tag != "" { 154 if creator != "" { 155 annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 156 highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 157 bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 158 collectionItems = []db.CollectionItem{} 159 } else { 160 annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 161 highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 162 bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 163 collectionItems = []db.CollectionItem{} 164 } 165 } else if creator != "" { 166 annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 167 highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 168 bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 169 collectionItems = []db.CollectionItem{} 170 } else { 171 annotations, _ = h.db.GetRecentAnnotations(limit, 0) 172 highlights, _ = h.db.GetRecentHighlights(limit, 0) 173 bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 174 collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 175 if err != nil { 176 log.Printf("Error fetching collection items: %v\n", err) 177 } 178 } 179 180 authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) 181 authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 182 authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 183 184 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 185 186 var feed []interface{} 187 for _, a := range authAnnos { 188 feed = append(feed, a) 189 } 190 for _, h := range authHighs { 191 feed = append(feed, h) 192 } 193 for _, b := range authBooks { 194 feed = append(feed, b) 195 } 196 for _, ci := range authCollectionItems { 197 feed = append(feed, ci) 198 } 199 200 sortFeed(feed) 201 202 if len(feed) > limit { 203 feed = feed[:limit] 204 } 205 206 w.Header().Set("Content-Type", "application/json") 207 json.NewEncoder(w).Encode(map[string]interface{}{ 208 "@context": "http://www.w3.org/ns/anno.jsonld", 209 "type": "Collection", 210 "items": feed, 211 "totalItems": len(feed), 212 }) 213} 214 215func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit int) { 216 var wg sync.WaitGroup 217 var rawAnnos, rawHighs, rawBooks []interface{} 218 var errAnnos, errHighs, errBooks error 219 220 fetchLimit := limit * 2 221 if fetchLimit < 50 { 222 fetchLimit = 50 223 } 224 225 wg.Add(3) 226 go func() { 227 defer wg.Done() 228 rawAnnos, errAnnos = h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, fetchLimit) 229 }() 230 go func() { 231 defer wg.Done() 232 rawHighs, errHighs = h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, fetchLimit) 233 }() 234 go func() { 235 defer wg.Done() 236 rawBooks, errBooks = h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, fetchLimit) 237 }() 238 wg.Wait() 239 240 if errAnnos != nil { 241 log.Printf("PDS Fetch Error (Annos): %v", errAnnos) 242 } 243 if errHighs != nil { 244 log.Printf("PDS Fetch Error (Highs): %v", errHighs) 245 } 246 if errBooks != nil { 247 log.Printf("PDS Fetch Error (Books): %v", errBooks) 248 } 249 250 var annotations []db.Annotation 251 var highlights []db.Highlight 252 var bookmarks []db.Bookmark 253 254 for _, r := range rawAnnos { 255 if a, ok := r.(*db.Annotation); ok { 256 if tag == "" || containsTag(a.TagsJSON, tag) { 257 annotations = append(annotations, *a) 258 } 259 } 260 } 261 for _, r := range rawHighs { 262 if h, ok := r.(*db.Highlight); ok { 263 if tag == "" || containsTag(h.TagsJSON, tag) { 264 highlights = append(highlights, *h) 265 } 266 } 267 } 268 for _, r := range rawBooks { 269 if b, ok := r.(*db.Bookmark); ok { 270 if tag == "" || containsTag(b.TagsJSON, tag) { 271 bookmarks = append(bookmarks, *b) 272 } 273 } 274 } 275 276 go func() { 277 for _, a := range annotations { 278 h.db.CreateAnnotation(&a) 279 } 280 for _, hi := range highlights { 281 h.db.CreateHighlight(&hi) 282 } 283 for _, b := range bookmarks { 284 h.db.CreateBookmark(&b) 285 } 286 }() 287 288 authAnnos, _ := hydrateAnnotations(h.db, annotations, did) 289 authHighs, _ := hydrateHighlights(h.db, highlights, did) 290 authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) 291 292 var feed []interface{} 293 for _, a := range authAnnos { 294 feed = append(feed, a) 295 } 296 for _, h := range authHighs { 297 feed = append(feed, h) 298 } 299 for _, b := range authBooks { 300 feed = append(feed, b) 301 } 302 303 sortFeed(feed) 304 305 if len(feed) > limit { 306 feed = feed[:limit] 307 } 308 309 w.Header().Set("Content-Type", "application/json") 310 json.NewEncoder(w).Encode(map[string]interface{}{ 311 "@context": "http://www.w3.org/ns/anno.jsonld", 312 "type": "Collection", 313 "items": feed, 314 "totalItems": len(feed), 315 }) 316 317} 318 319func containsTag(tagsJSON *string, tag string) bool { 320 if tagsJSON == nil || *tagsJSON == "" { 321 return false 322 } 323 var tags []string 324 if err := json.Unmarshal([]byte(*tagsJSON), &tags); err != nil { 325 return false 326 } 327 for _, t := range tags { 328 if t == tag { 329 return true 330 } 331 } 332 return false 333} 334 335func sortFeed(feed []interface{}) { 336 for i := 0; i < len(feed); i++ { 337 for j := i + 1; j < len(feed); j++ { 338 t1 := getCreatedAt(feed[i]) 339 t2 := getCreatedAt(feed[j]) 340 if t1.Before(t2) { 341 feed[i], feed[j] = feed[j], feed[i] 342 } 343 } 344 } 345} 346 347func getCreatedAt(item interface{}) time.Time { 348 switch v := item.(type) { 349 case APIAnnotation: 350 return v.CreatedAt 351 case APIHighlight: 352 return v.CreatedAt 353 case APIBookmark: 354 return v.CreatedAt 355 case APICollectionItem: 356 return v.CreatedAt 357 default: 358 return time.Time{} 359 } 360} 361 362func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 363 uri := r.URL.Query().Get("uri") 364 if uri == "" { 365 http.Error(w, "uri query parameter required", http.StatusBadRequest) 366 return 367 } 368 369 serveResponse := func(data interface{}, context string) { 370 w.Header().Set("Content-Type", "application/json") 371 response := map[string]interface{}{ 372 "@context": context, 373 } 374 jsonData, _ := json.Marshal(data) 375 json.Unmarshal(jsonData, &response) 376 json.NewEncoder(w).Encode(response) 377 } 378 379 if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 380 if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 381 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 382 return 383 } 384 } 385 386 if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 387 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 388 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 389 return 390 } 391 } 392 393 if strings.Contains(uri, "at.margin.annotation") { 394 highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 395 if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 396 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 397 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 398 return 399 } 400 } 401 } 402 403 if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 404 if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 405 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 406 return 407 } 408 } 409 410 if strings.Contains(uri, "at.margin.annotation") { 411 bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 412 if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 413 if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 414 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 415 return 416 } 417 } 418 } 419 420 http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 421 422} 423 424func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { 425 source := r.URL.Query().Get("source") 426 if source == "" { 427 source = r.URL.Query().Get("url") 428 } 429 if source == "" { 430 http.Error(w, "source or url parameter required", http.StatusBadRequest) 431 return 432 } 433 434 limit := parseIntParam(r, "limit", 50) 435 offset := parseIntParam(r, "offset", 0) 436 437 urlHash := db.HashURL(source) 438 439 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 440 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 441 442 enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 443 enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 444 445 w.Header().Set("Content-Type", "application/json") 446 json.NewEncoder(w).Encode(map[string]interface{}{ 447 "@context": "http://www.w3.org/ns/anno.jsonld", 448 "source": source, 449 "sourceHash": urlHash, 450 "annotations": enrichedAnnotations, 451 "highlights": enrichedHighlights, 452 }) 453} 454 455func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 456 did := r.URL.Query().Get("creator") 457 tag := r.URL.Query().Get("tag") 458 limit := parseIntParam(r, "limit", 50) 459 offset := parseIntParam(r, "offset", 0) 460 461 var highlights []db.Highlight 462 var err error 463 464 if did != "" { 465 highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 466 } else if tag != "" { 467 highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 468 } else { 469 highlights, err = h.db.GetRecentHighlights(limit, offset) 470 } 471 472 if err != nil { 473 http.Error(w, err.Error(), http.StatusInternalServerError) 474 return 475 } 476 477 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 478 479 w.Header().Set("Content-Type", "application/json") 480 json.NewEncoder(w).Encode(map[string]interface{}{ 481 "@context": "http://www.w3.org/ns/anno.jsonld", 482 "type": "HighlightCollection", 483 "items": enriched, 484 "totalItems": len(enriched), 485 }) 486} 487 488func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) { 489 did := r.URL.Query().Get("creator") 490 limit := parseIntParam(r, "limit", 50) 491 offset := parseIntParam(r, "offset", 0) 492 493 if did == "" { 494 http.Error(w, "creator parameter required", http.StatusBadRequest) 495 return 496 } 497 498 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) 499 if err != nil { 500 http.Error(w, err.Error(), http.StatusInternalServerError) 501 return 502 } 503 504 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 505 506 w.Header().Set("Content-Type", "application/json") 507 json.NewEncoder(w).Encode(map[string]interface{}{ 508 "@context": "http://www.w3.org/ns/anno.jsonld", 509 "type": "BookmarkCollection", 510 "items": enriched, 511 "totalItems": len(enriched), 512 }) 513} 514 515func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { 516 did := chi.URLParam(r, "did") 517 if decoded, err := url.QueryUnescape(did); err == nil { 518 did = decoded 519 } 520 limit := parseIntParam(r, "limit", 50) 521 offset := parseIntParam(r, "offset", 0) 522 523 var annotations []db.Annotation 524 var err error 525 526 viewerDID := h.getViewerDID(r) 527 528 if offset == 0 && viewerDID != "" && did == viewerDID { 529 raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit) 530 if err == nil { 531 for _, r := range raw { 532 if a, ok := r.(*db.Annotation); ok { 533 annotations = append(annotations, *a) 534 } 535 } 536 go func() { 537 for _, a := range annotations { 538 h.db.CreateAnnotation(&a) 539 } 540 }() 541 } else { 542 log.Printf("PDS Fetch Error (User Annos): %v", err) 543 annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 544 } 545 } else { 546 annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 547 } 548 549 if err != nil { 550 http.Error(w, err.Error(), http.StatusInternalServerError) 551 return 552 } 553 554 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 555 556 w.Header().Set("Content-Type", "application/json") 557 json.NewEncoder(w).Encode(map[string]interface{}{ 558 "@context": "http://www.w3.org/ns/anno.jsonld", 559 "type": "AnnotationCollection", 560 "creator": did, 561 "items": enriched, 562 "totalItems": len(enriched), 563 }) 564} 565 566func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { 567 did := chi.URLParam(r, "did") 568 if decoded, err := url.QueryUnescape(did); err == nil { 569 did = decoded 570 } 571 limit := parseIntParam(r, "limit", 50) 572 offset := parseIntParam(r, "offset", 0) 573 574 var highlights []db.Highlight 575 var err error 576 577 viewerDID := h.getViewerDID(r) 578 579 if offset == 0 && viewerDID != "" && did == viewerDID { 580 raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit) 581 if err == nil { 582 for _, r := range raw { 583 if hi, ok := r.(*db.Highlight); ok { 584 highlights = append(highlights, *hi) 585 } 586 } 587 go func() { 588 for _, hi := range highlights { 589 h.db.CreateHighlight(&hi) 590 } 591 }() 592 } else { 593 log.Printf("PDS Fetch Error (User Highs): %v", err) 594 highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 595 } 596 } else { 597 highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 598 } 599 600 if err != nil { 601 http.Error(w, err.Error(), http.StatusInternalServerError) 602 return 603 } 604 605 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 606 607 w.Header().Set("Content-Type", "application/json") 608 json.NewEncoder(w).Encode(map[string]interface{}{ 609 "@context": "http://www.w3.org/ns/anno.jsonld", 610 "type": "HighlightCollection", 611 "creator": did, 612 "items": enriched, 613 "totalItems": len(enriched), 614 }) 615} 616 617func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { 618 did := chi.URLParam(r, "did") 619 if decoded, err := url.QueryUnescape(did); err == nil { 620 did = decoded 621 } 622 limit := parseIntParam(r, "limit", 50) 623 offset := parseIntParam(r, "offset", 0) 624 625 var bookmarks []db.Bookmark 626 var err error 627 628 viewerDID := h.getViewerDID(r) 629 630 if offset == 0 && viewerDID != "" && did == viewerDID { 631 raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit) 632 if err == nil { 633 for _, r := range raw { 634 if b, ok := r.(*db.Bookmark); ok { 635 bookmarks = append(bookmarks, *b) 636 } 637 } 638 go func() { 639 for _, b := range bookmarks { 640 h.db.CreateBookmark(&b) 641 } 642 }() 643 } else { 644 log.Printf("PDS Fetch Error (User Books): %v", err) 645 bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 646 } 647 } else { 648 bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 649 } 650 651 if err != nil { 652 http.Error(w, err.Error(), http.StatusInternalServerError) 653 return 654 } 655 656 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 657 658 w.Header().Set("Content-Type", "application/json") 659 json.NewEncoder(w).Encode(map[string]interface{}{ 660 "@context": "http://www.w3.org/ns/anno.jsonld", 661 "type": "BookmarkCollection", 662 "creator": did, 663 "items": enriched, 664 "totalItems": len(enriched), 665 }) 666} 667 668func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { 669 uri := r.URL.Query().Get("uri") 670 if uri == "" { 671 http.Error(w, "uri query parameter required", http.StatusBadRequest) 672 return 673 } 674 675 replies, err := h.db.GetRepliesByRoot(uri) 676 if err != nil { 677 http.Error(w, err.Error(), http.StatusInternalServerError) 678 return 679 } 680 681 enriched, _ := hydrateReplies(replies) 682 683 w.Header().Set("Content-Type", "application/json") 684 json.NewEncoder(w).Encode(map[string]interface{}{ 685 "@context": "http://www.w3.org/ns/anno.jsonld", 686 "type": "ReplyCollection", 687 "inReplyTo": uri, 688 "items": enriched, 689 "totalItems": len(enriched), 690 }) 691} 692 693func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { 694 uri := r.URL.Query().Get("uri") 695 if uri == "" { 696 http.Error(w, "uri query parameter required", http.StatusBadRequest) 697 return 698 } 699 700 count, err := h.db.GetLikeCount(uri) 701 if err != nil { 702 http.Error(w, err.Error(), http.StatusInternalServerError) 703 return 704 } 705 706 liked := false 707 cookie, err := r.Cookie("margin_session") 708 if err == nil && cookie != nil { 709 session, err := h.refresher.GetSessionWithAutoRefresh(r) 710 if err == nil { 711 userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri) 712 if err == nil && userLike != nil { 713 liked = true 714 } 715 } 716 } 717 718 w.Header().Set("Content-Type", "application/json") 719 json.NewEncoder(w).Encode(map[string]interface{}{ 720 "count": count, 721 "liked": liked, 722 }) 723} 724 725func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) { 726 uri := r.URL.Query().Get("uri") 727 if uri == "" { 728 http.Error(w, "uri query parameter required", http.StatusBadRequest) 729 return 730 } 731 732 history, err := h.db.GetEditHistory(uri) 733 if err != nil { 734 http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError) 735 return 736 } 737 738 if history == nil { 739 history = []db.EditHistory{} 740 } 741 742 w.Header().Set("Content-Type", "application/json") 743 json.NewEncoder(w).Encode(history) 744} 745 746func parseIntParam(r *http.Request, name string, defaultVal int) int { 747 val := r.URL.Query().Get(name) 748 if val == "" { 749 return defaultVal 750 } 751 i, err := strconv.Atoi(val) 752 if err != nil { 753 return defaultVal 754 } 755 return i 756} 757 758func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) { 759 url := r.URL.Query().Get("url") 760 if url == "" { 761 http.Error(w, "url parameter required", http.StatusBadRequest) 762 return 763 } 764 765 client := &http.Client{Timeout: 10 * time.Second} 766 resp, err := client.Get(url) 767 if err != nil { 768 w.Header().Set("Content-Type", "application/json") 769 json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"}) 770 return 771 } 772 defer resp.Body.Close() 773 774 body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) 775 if err != nil { 776 w.Header().Set("Content-Type", "application/json") 777 json.NewEncoder(w).Encode(map[string]string{"title": ""}) 778 return 779 } 780 781 title := "" 782 htmlStr := string(body) 783 if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 { 784 start := idx + 7 785 if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 { 786 title = strings.TrimSpace(htmlStr[start : start+endIdx]) 787 } 788 } 789 790 w.Header().Set("Content-Type", "application/json") 791 json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url}) 792} 793 794func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { 795 session, err := h.refresher.GetSessionWithAutoRefresh(r) 796 if err != nil { 797 http.Error(w, err.Error(), http.StatusUnauthorized) 798 return 799 } 800 801 limit := parseIntParam(r, "limit", 50) 802 offset := parseIntParam(r, "offset", 0) 803 804 notifications, err := h.db.GetNotifications(session.DID, limit, offset) 805 if err != nil { 806 http.Error(w, "Failed to get notifications", http.StatusInternalServerError) 807 return 808 } 809 810 enriched, err := hydrateNotifications(h.db, notifications) 811 if err != nil { 812 log.Printf("Failed to hydrate notifications: %v\n", err) 813 } 814 815 w.Header().Set("Content-Type", "application/json") 816 if enriched != nil { 817 json.NewEncoder(w).Encode(map[string]interface{}{"items": enriched}) 818 } else { 819 json.NewEncoder(w).Encode(map[string]interface{}{"items": notifications}) 820 } 821} 822 823func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) { 824 session, err := h.refresher.GetSessionWithAutoRefresh(r) 825 if err != nil { 826 http.Error(w, err.Error(), http.StatusUnauthorized) 827 return 828 } 829 830 count, err := h.db.GetUnreadNotificationCount(session.DID) 831 if err != nil { 832 http.Error(w, "Failed to get count", http.StatusInternalServerError) 833 return 834 } 835 836 w.Header().Set("Content-Type", "application/json") 837 json.NewEncoder(w).Encode(map[string]int{"count": count}) 838} 839 840func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) { 841 session, err := h.refresher.GetSessionWithAutoRefresh(r) 842 if err != nil { 843 http.Error(w, err.Error(), http.StatusUnauthorized) 844 return 845 } 846 847 if err := h.db.MarkNotificationsRead(session.DID); err != nil { 848 http.Error(w, "Failed to mark as read", http.StatusInternalServerError) 849 return 850 } 851 852 w.Header().Set("Content-Type", "application/json") 853 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 854} 855func (h *Handler) getViewerDID(r *http.Request) string { 856 cookie, err := r.Cookie("margin_session") 857 if err != nil { 858 return "" 859 } 860 did, _, _, _, _, err := h.db.GetSession(cookie.Value) 861 if err != nil { 862 return "" 863 } 864 return did 865}