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