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}