forked from
margin.at/margin
Write on the margins of the internet. Powered by the AT Protocol.
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}