1package api
2
3import (
4 "encoding/json"
5 "io"
6 "log"
7 "net/http"
8 "net/url"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14
15 "margin.at/internal/db"
16)
17
18type Handler struct {
19 db *db.DB
20 annotationService *AnnotationService
21 refresher *TokenRefresher
22}
23
24func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler {
25 return &Handler{db: database, annotationService: annotationService, refresher: refresher}
26}
27
28func (h *Handler) RegisterRoutes(r chi.Router) {
29 r.Get("/health", h.Health)
30
31 r.Route("/api", func(r chi.Router) {
32 r.Get("/annotations", h.GetAnnotations)
33 r.Get("/annotations/feed", h.GetFeed)
34 r.Get("/annotation", h.GetAnnotation)
35 r.Get("/annotations/history", h.GetEditHistory)
36 r.Put("/annotations", h.annotationService.UpdateAnnotation)
37
38 r.Get("/highlights", h.GetHighlights)
39 r.Put("/highlights", h.annotationService.UpdateHighlight)
40
41 r.Get("/bookmarks", h.GetBookmarks)
42 r.Post("/bookmarks", h.annotationService.CreateBookmark)
43 r.Put("/bookmarks", h.annotationService.UpdateBookmark)
44
45 collectionService := NewCollectionService(h.db, h.refresher)
46 r.Post("/collections", collectionService.CreateCollection)
47 r.Get("/collections", collectionService.GetCollections)
48 r.Put("/collections", collectionService.UpdateCollection)
49 r.Delete("/collections", collectionService.DeleteCollection)
50 r.Post("/collections/{collection}/items", collectionService.AddCollectionItem)
51 r.Get("/collections/{collection}/items", collectionService.GetCollectionItems)
52 r.Delete("/collections/items", collectionService.RemoveCollectionItem)
53 r.Get("/collections/containing", collectionService.GetAnnotationCollections)
54
55 r.Get("/targets", h.GetByTarget)
56
57 r.Get("/users/{did}/annotations", h.GetUserAnnotations)
58 r.Get("/users/{did}/highlights", h.GetUserHighlights)
59 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks)
60
61 r.Get("/replies", h.GetReplies)
62 r.Get("/likes", h.GetLikeCount)
63 r.Get("/url-metadata", h.GetURLMetadata)
64 r.Get("/notifications", h.GetNotifications)
65 r.Get("/notifications/count", h.GetUnreadNotificationCount)
66 r.Post("/notifications/read", h.MarkNotificationsRead)
67 })
68}
69
70func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
71 w.Header().Set("Content-Type", "application/json")
72 json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": "1.0"})
73}
74
75func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) {
76 source := r.URL.Query().Get("source")
77 if source == "" {
78 source = r.URL.Query().Get("url")
79 }
80
81 limit := parseIntParam(r, "limit", 50)
82 offset := parseIntParam(r, "offset", 0)
83 motivation := r.URL.Query().Get("motivation")
84 tag := r.URL.Query().Get("tag")
85
86 var annotations []db.Annotation
87 var err error
88
89 if source != "" {
90 urlHash := db.HashURL(source)
91 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
92 } else if motivation != "" {
93 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset)
94 } else if tag != "" {
95 annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset)
96 } else {
97 annotations, err = h.db.GetRecentAnnotations(limit, offset)
98 }
99
100 if err != nil {
101 http.Error(w, err.Error(), http.StatusInternalServerError)
102 return
103 }
104
105 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
106
107 w.Header().Set("Content-Type", "application/json")
108 json.NewEncoder(w).Encode(map[string]interface{}{
109 "@context": "http://www.w3.org/ns/anno.jsonld",
110 "type": "AnnotationCollection",
111 "items": enriched,
112 "totalItems": len(enriched),
113 })
114}
115
116func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
117 limit := parseIntParam(r, "limit", 50)
118 tag := r.URL.Query().Get("tag")
119 creator := r.URL.Query().Get("creator")
120
121 var annotations []db.Annotation
122 var highlights []db.Highlight
123 var bookmarks []db.Bookmark
124 var collectionItems []db.CollectionItem
125 var err error
126
127 if tag != "" {
128 if creator != "" {
129 annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0)
130 highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0)
131 bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0)
132 collectionItems = []db.CollectionItem{}
133 } else {
134 annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0)
135 highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0)
136 bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0)
137 collectionItems = []db.CollectionItem{}
138 }
139 } else if creator != "" {
140 annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0)
141 highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0)
142 bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0)
143 collectionItems = []db.CollectionItem{}
144 } else {
145 annotations, _ = h.db.GetRecentAnnotations(limit, 0)
146 highlights, _ = h.db.GetRecentHighlights(limit, 0)
147 bookmarks, _ = h.db.GetRecentBookmarks(limit, 0)
148 collectionItems, err = h.db.GetRecentCollectionItems(limit, 0)
149 if err != nil {
150 log.Printf("Error fetching collection items: %v\n", err)
151 }
152 }
153
154 viewerDID := h.getViewerDID(r)
155 authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID)
156 authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID)
157 authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
158
159 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID)
160
161 var feed []interface{}
162 for _, a := range authAnnos {
163 feed = append(feed, a)
164 }
165 for _, h := range authHighs {
166 feed = append(feed, h)
167 }
168 for _, b := range authBooks {
169 feed = append(feed, b)
170 }
171 for _, ci := range authCollectionItems {
172 feed = append(feed, ci)
173 }
174
175 for i := 0; i < len(feed); i++ {
176 for j := i + 1; j < len(feed); j++ {
177 t1 := getCreatedAt(feed[i])
178 t2 := getCreatedAt(feed[j])
179 if t1.Before(t2) {
180 feed[i], feed[j] = feed[j], feed[i]
181 }
182 }
183 }
184
185 if len(feed) > limit {
186 feed = feed[:limit]
187 }
188
189 w.Header().Set("Content-Type", "application/json")
190 json.NewEncoder(w).Encode(map[string]interface{}{
191 "@context": "http://www.w3.org/ns/anno.jsonld",
192 "type": "Collection",
193 "items": feed,
194 "totalItems": len(feed),
195 })
196}
197
198func getCreatedAt(item interface{}) time.Time {
199 switch v := item.(type) {
200 case APIAnnotation:
201 return v.CreatedAt
202 case APIHighlight:
203 return v.CreatedAt
204 case APIBookmark:
205 return v.CreatedAt
206 case APICollectionItem:
207 return v.CreatedAt
208 default:
209 return time.Time{}
210 }
211}
212
213func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) {
214 uri := r.URL.Query().Get("uri")
215 if uri == "" {
216 http.Error(w, "uri query parameter required", http.StatusBadRequest)
217 return
218 }
219
220 serveResponse := func(data interface{}, context string) {
221 w.Header().Set("Content-Type", "application/json")
222 response := map[string]interface{}{
223 "@context": context,
224 }
225 jsonData, _ := json.Marshal(data)
226 json.Unmarshal(jsonData, &response)
227 json.NewEncoder(w).Encode(response)
228 }
229
230 if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
231 if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 {
232 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
233 return
234 }
235 }
236
237 if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
238 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
239 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
240 return
241 }
242 }
243
244 if strings.Contains(uri, "at.margin.annotation") {
245 highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
246 if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
247 if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
248 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
249 return
250 }
251 }
252 }
253
254 if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
255 if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
256 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
257 return
258 }
259 }
260
261 if strings.Contains(uri, "at.margin.annotation") {
262 bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
263 if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
264 if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
265 serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
266 return
267 }
268 }
269 }
270
271 http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound)
272
273}
274
275func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
276 source := r.URL.Query().Get("source")
277 if source == "" {
278 source = r.URL.Query().Get("url")
279 }
280 if source == "" {
281 http.Error(w, "source or url parameter required", http.StatusBadRequest)
282 return
283 }
284
285 limit := parseIntParam(r, "limit", 50)
286 offset := parseIntParam(r, "offset", 0)
287
288 urlHash := db.HashURL(source)
289
290 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
291 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
292
293 enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
294 enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
295
296 w.Header().Set("Content-Type", "application/json")
297 json.NewEncoder(w).Encode(map[string]interface{}{
298 "@context": "http://www.w3.org/ns/anno.jsonld",
299 "source": source,
300 "sourceHash": urlHash,
301 "annotations": enrichedAnnotations,
302 "highlights": enrichedHighlights,
303 })
304}
305
306func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
307 did := r.URL.Query().Get("creator")
308 tag := r.URL.Query().Get("tag")
309 limit := parseIntParam(r, "limit", 50)
310 offset := parseIntParam(r, "offset", 0)
311
312 var highlights []db.Highlight
313 var err error
314
315 if did != "" {
316 highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset)
317 } else if tag != "" {
318 highlights, err = h.db.GetHighlightsByTag(tag, limit, offset)
319 } else {
320 highlights, err = h.db.GetRecentHighlights(limit, offset)
321 }
322
323 if err != nil {
324 http.Error(w, err.Error(), http.StatusInternalServerError)
325 return
326 }
327
328 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
329
330 w.Header().Set("Content-Type", "application/json")
331 json.NewEncoder(w).Encode(map[string]interface{}{
332 "@context": "http://www.w3.org/ns/anno.jsonld",
333 "type": "HighlightCollection",
334 "items": enriched,
335 "totalItems": len(enriched),
336 })
337}
338
339func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) {
340 did := r.URL.Query().Get("creator")
341 limit := parseIntParam(r, "limit", 50)
342 offset := parseIntParam(r, "offset", 0)
343
344 if did == "" {
345 http.Error(w, "creator parameter required", http.StatusBadRequest)
346 return
347 }
348
349 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset)
350 if err != nil {
351 http.Error(w, err.Error(), http.StatusInternalServerError)
352 return
353 }
354
355 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
356
357 w.Header().Set("Content-Type", "application/json")
358 json.NewEncoder(w).Encode(map[string]interface{}{
359 "@context": "http://www.w3.org/ns/anno.jsonld",
360 "type": "BookmarkCollection",
361 "items": enriched,
362 "totalItems": len(enriched),
363 })
364}
365
366func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) {
367 did := chi.URLParam(r, "did")
368 if decoded, err := url.QueryUnescape(did); err == nil {
369 did = decoded
370 }
371 limit := parseIntParam(r, "limit", 50)
372 offset := parseIntParam(r, "offset", 0)
373
374 annotations, err := h.db.GetAnnotationsByAuthor(did, limit, offset)
375 if err != nil {
376 http.Error(w, err.Error(), http.StatusInternalServerError)
377 return
378 }
379
380 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
381
382 w.Header().Set("Content-Type", "application/json")
383 json.NewEncoder(w).Encode(map[string]interface{}{
384 "@context": "http://www.w3.org/ns/anno.jsonld",
385 "type": "AnnotationCollection",
386 "creator": did,
387 "items": enriched,
388 "totalItems": len(enriched),
389 })
390}
391
392func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) {
393 did := chi.URLParam(r, "did")
394 if decoded, err := url.QueryUnescape(did); err == nil {
395 did = decoded
396 }
397 limit := parseIntParam(r, "limit", 50)
398 offset := parseIntParam(r, "offset", 0)
399
400 highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
401 if err != nil {
402 http.Error(w, err.Error(), http.StatusInternalServerError)
403 return
404 }
405
406 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
407
408 w.Header().Set("Content-Type", "application/json")
409 json.NewEncoder(w).Encode(map[string]interface{}{
410 "@context": "http://www.w3.org/ns/anno.jsonld",
411 "type": "HighlightCollection",
412 "creator": did,
413 "items": enriched,
414 "totalItems": len(enriched),
415 })
416}
417
418func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) {
419 did := chi.URLParam(r, "did")
420 if decoded, err := url.QueryUnescape(did); err == nil {
421 did = decoded
422 }
423 limit := parseIntParam(r, "limit", 50)
424 offset := parseIntParam(r, "offset", 0)
425
426 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset)
427 if err != nil {
428 http.Error(w, err.Error(), http.StatusInternalServerError)
429 return
430 }
431
432 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
433
434 w.Header().Set("Content-Type", "application/json")
435 json.NewEncoder(w).Encode(map[string]interface{}{
436 "@context": "http://www.w3.org/ns/anno.jsonld",
437 "type": "BookmarkCollection",
438 "creator": did,
439 "items": enriched,
440 "totalItems": len(enriched),
441 })
442}
443
444func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) {
445 uri := r.URL.Query().Get("uri")
446 if uri == "" {
447 http.Error(w, "uri query parameter required", http.StatusBadRequest)
448 return
449 }
450
451 replies, err := h.db.GetRepliesByRoot(uri)
452 if err != nil {
453 http.Error(w, err.Error(), http.StatusInternalServerError)
454 return
455 }
456
457 enriched, _ := hydrateReplies(replies)
458
459 w.Header().Set("Content-Type", "application/json")
460 json.NewEncoder(w).Encode(map[string]interface{}{
461 "@context": "http://www.w3.org/ns/anno.jsonld",
462 "type": "ReplyCollection",
463 "inReplyTo": uri,
464 "items": enriched,
465 "totalItems": len(enriched),
466 })
467}
468
469func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) {
470 uri := r.URL.Query().Get("uri")
471 if uri == "" {
472 http.Error(w, "uri query parameter required", http.StatusBadRequest)
473 return
474 }
475
476 count, err := h.db.GetLikeCount(uri)
477 if err != nil {
478 http.Error(w, err.Error(), http.StatusInternalServerError)
479 return
480 }
481
482 liked := false
483 cookie, err := r.Cookie("margin_session")
484 if err == nil && cookie != nil {
485 session, err := h.refresher.GetSessionWithAutoRefresh(r)
486 if err == nil {
487 userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri)
488 if err == nil && userLike != nil {
489 liked = true
490 }
491 }
492 }
493
494 w.Header().Set("Content-Type", "application/json")
495 json.NewEncoder(w).Encode(map[string]interface{}{
496 "count": count,
497 "liked": liked,
498 })
499}
500
501func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) {
502 uri := r.URL.Query().Get("uri")
503 if uri == "" {
504 http.Error(w, "uri query parameter required", http.StatusBadRequest)
505 return
506 }
507
508 history, err := h.db.GetEditHistory(uri)
509 if err != nil {
510 http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError)
511 return
512 }
513
514 if history == nil {
515 history = []db.EditHistory{}
516 }
517
518 w.Header().Set("Content-Type", "application/json")
519 json.NewEncoder(w).Encode(history)
520}
521
522func parseIntParam(r *http.Request, name string, defaultVal int) int {
523 val := r.URL.Query().Get(name)
524 if val == "" {
525 return defaultVal
526 }
527 i, err := strconv.Atoi(val)
528 if err != nil {
529 return defaultVal
530 }
531 return i
532}
533
534func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) {
535 url := r.URL.Query().Get("url")
536 if url == "" {
537 http.Error(w, "url parameter required", http.StatusBadRequest)
538 return
539 }
540
541 client := &http.Client{Timeout: 10 * time.Second}
542 resp, err := client.Get(url)
543 if err != nil {
544 w.Header().Set("Content-Type", "application/json")
545 json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"})
546 return
547 }
548 defer resp.Body.Close()
549
550 body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
551 if err != nil {
552 w.Header().Set("Content-Type", "application/json")
553 json.NewEncoder(w).Encode(map[string]string{"title": ""})
554 return
555 }
556
557 title := ""
558 htmlStr := string(body)
559 if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 {
560 start := idx + 7
561 if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 {
562 title = strings.TrimSpace(htmlStr[start : start+endIdx])
563 }
564 }
565
566 w.Header().Set("Content-Type", "application/json")
567 json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url})
568}
569
570func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) {
571 session, err := h.refresher.GetSessionWithAutoRefresh(r)
572 if err != nil {
573 http.Error(w, err.Error(), http.StatusUnauthorized)
574 return
575 }
576
577 limit := parseIntParam(r, "limit", 50)
578 offset := parseIntParam(r, "offset", 0)
579
580 notifications, err := h.db.GetNotifications(session.DID, limit, offset)
581 if err != nil {
582 http.Error(w, "Failed to get notifications", http.StatusInternalServerError)
583 return
584 }
585
586 enriched, err := hydrateNotifications(h.db, notifications)
587 if err != nil {
588 log.Printf("Failed to hydrate notifications: %v\n", err)
589 }
590
591 w.Header().Set("Content-Type", "application/json")
592 if enriched != nil {
593 json.NewEncoder(w).Encode(map[string]interface{}{"items": enriched})
594 } else {
595 json.NewEncoder(w).Encode(map[string]interface{}{"items": notifications})
596 }
597}
598
599func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
600 session, err := h.refresher.GetSessionWithAutoRefresh(r)
601 if err != nil {
602 http.Error(w, err.Error(), http.StatusUnauthorized)
603 return
604 }
605
606 count, err := h.db.GetUnreadNotificationCount(session.DID)
607 if err != nil {
608 http.Error(w, "Failed to get count", http.StatusInternalServerError)
609 return
610 }
611
612 w.Header().Set("Content-Type", "application/json")
613 json.NewEncoder(w).Encode(map[string]int{"count": count})
614}
615
616func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
617 session, err := h.refresher.GetSessionWithAutoRefresh(r)
618 if err != nil {
619 http.Error(w, err.Error(), http.StatusUnauthorized)
620 return
621 }
622
623 if err := h.db.MarkNotificationsRead(session.DID); err != nil {
624 http.Error(w, "Failed to mark as read", http.StatusInternalServerError)
625 return
626 }
627
628 w.Header().Set("Content-Type", "application/json")
629 json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
630}
631func (h *Handler) getViewerDID(r *http.Request) string {
632 cookie, err := r.Cookie("margin_session")
633 if err != nil {
634 return ""
635 }
636 did, _, _, _, _, err := h.db.GetSession(cookie.Value)
637 if err != nil {
638 return ""
639 }
640 return did
641}