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
85 var annotations []db.Annotation
86 var err error
87
88 if source != "" {
89 urlHash := db.HashURL(source)
90 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
91 } else if motivation != "" {
92 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset)
93 } else {
94 annotations, err = h.db.GetRecentAnnotations(limit, offset)
95 }
96
97 if err != nil {
98 http.Error(w, err.Error(), http.StatusInternalServerError)
99 return
100 }
101
102 enriched, _ := hydrateAnnotations(annotations)
103
104 w.Header().Set("Content-Type", "application/json")
105 json.NewEncoder(w).Encode(map[string]interface{}{
106 "@context": "http://www.w3.org/ns/anno.jsonld",
107 "type": "AnnotationCollection",
108 "items": enriched,
109 "totalItems": len(enriched),
110 })
111}
112
113func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
114 limit := parseIntParam(r, "limit", 50)
115
116 annotations, _ := h.db.GetRecentAnnotations(limit, 0)
117 highlights, _ := h.db.GetRecentHighlights(limit, 0)
118 bookmarks, _ := h.db.GetRecentBookmarks(limit, 0)
119
120 authAnnos, _ := hydrateAnnotations(annotations)
121 authHighs, _ := hydrateHighlights(highlights)
122 authBooks, _ := hydrateBookmarks(bookmarks)
123
124 collectionItems, err := h.db.GetRecentCollectionItems(limit, 0)
125 if err != nil {
126 log.Printf("Error fetching collection items: %v\n", err)
127 }
128 // log.Printf("Fetched %d collection items\n", len(collectionItems))
129 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems)
130 // log.Printf("Hydrated %d collection items\n", len(authCollectionItems))
131
132 var feed []interface{}
133 for _, a := range authAnnos {
134 feed = append(feed, a)
135 }
136 for _, h := range authHighs {
137 feed = append(feed, h)
138 }
139 for _, b := range authBooks {
140 feed = append(feed, b)
141 }
142 for _, ci := range authCollectionItems {
143 feed = append(feed, ci)
144 }
145
146 for i := 0; i < len(feed); i++ {
147 for j := i + 1; j < len(feed); j++ {
148 t1 := getCreatedAt(feed[i])
149 t2 := getCreatedAt(feed[j])
150 if t1.Before(t2) {
151 feed[i], feed[j] = feed[j], feed[i]
152 }
153 }
154 }
155
156 if len(feed) > limit {
157 feed = feed[:limit]
158 }
159
160 w.Header().Set("Content-Type", "application/json")
161 json.NewEncoder(w).Encode(map[string]interface{}{
162 "@context": "http://www.w3.org/ns/anno.jsonld",
163 "type": "Collection",
164 "items": feed,
165 "totalItems": len(feed),
166 })
167}
168
169func getCreatedAt(item interface{}) time.Time {
170 switch v := item.(type) {
171 case APIAnnotation:
172 return v.CreatedAt
173 case APIHighlight:
174 return v.CreatedAt
175 case APIBookmark:
176 return v.CreatedAt
177 case APICollectionItem:
178 return v.CreatedAt
179 default:
180 return time.Time{}
181 }
182}
183
184func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) {
185 uri := r.URL.Query().Get("uri")
186 if uri == "" {
187 http.Error(w, "uri query parameter required", http.StatusBadRequest)
188 return
189 }
190
191 annotation, err := h.db.GetAnnotationByURI(uri)
192 if err != nil {
193 http.Error(w, "Annotation not found", http.StatusNotFound)
194 return
195 }
196
197 enriched, _ := hydrateAnnotations([]db.Annotation{*annotation})
198 if len(enriched) == 0 {
199 http.Error(w, "Annotation not found", http.StatusNotFound)
200 return
201 }
202
203 w.Header().Set("Content-Type", "application/json")
204 response := map[string]interface{}{
205 "@context": "http://www.w3.org/ns/anno.jsonld",
206 }
207 annJSON, _ := json.Marshal(enriched[0])
208 json.Unmarshal(annJSON, &response)
209
210 json.NewEncoder(w).Encode(response)
211}
212
213func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
214 source := r.URL.Query().Get("source")
215 if source == "" {
216 source = r.URL.Query().Get("url")
217 }
218 if source == "" {
219 http.Error(w, "source or url parameter required", http.StatusBadRequest)
220 return
221 }
222
223 limit := parseIntParam(r, "limit", 50)
224 offset := parseIntParam(r, "offset", 0)
225
226 urlHash := db.HashURL(source)
227
228 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
229 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
230
231 enrichedAnnotations, _ := hydrateAnnotations(annotations)
232 enrichedHighlights, _ := hydrateHighlights(highlights)
233
234 w.Header().Set("Content-Type", "application/json")
235 json.NewEncoder(w).Encode(map[string]interface{}{
236 "@context": "http://www.w3.org/ns/anno.jsonld",
237 "source": source,
238 "sourceHash": urlHash,
239 "annotations": enrichedAnnotations,
240 "highlights": enrichedHighlights,
241 })
242}
243
244func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
245 did := r.URL.Query().Get("creator")
246 limit := parseIntParam(r, "limit", 50)
247 offset := parseIntParam(r, "offset", 0)
248
249 if did == "" {
250 http.Error(w, "creator parameter required", http.StatusBadRequest)
251 return
252 }
253
254 highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
255 if err != nil {
256 http.Error(w, err.Error(), http.StatusInternalServerError)
257 return
258 }
259
260 enriched, _ := hydrateHighlights(highlights)
261
262 w.Header().Set("Content-Type", "application/json")
263 json.NewEncoder(w).Encode(map[string]interface{}{
264 "@context": "http://www.w3.org/ns/anno.jsonld",
265 "type": "HighlightCollection",
266 "items": enriched,
267 "totalItems": len(enriched),
268 })
269}
270
271func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) {
272 did := r.URL.Query().Get("creator")
273 limit := parseIntParam(r, "limit", 50)
274 offset := parseIntParam(r, "offset", 0)
275
276 if did == "" {
277 http.Error(w, "creator parameter required", http.StatusBadRequest)
278 return
279 }
280
281 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset)
282 if err != nil {
283 http.Error(w, err.Error(), http.StatusInternalServerError)
284 return
285 }
286
287 enriched, _ := hydrateBookmarks(bookmarks)
288
289 w.Header().Set("Content-Type", "application/json")
290 json.NewEncoder(w).Encode(map[string]interface{}{
291 "@context": "http://www.w3.org/ns/anno.jsonld",
292 "type": "BookmarkCollection",
293 "items": enriched,
294 "totalItems": len(enriched),
295 })
296}
297
298func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) {
299 did := chi.URLParam(r, "did")
300 if decoded, err := url.QueryUnescape(did); err == nil {
301 did = decoded
302 }
303 limit := parseIntParam(r, "limit", 50)
304 offset := parseIntParam(r, "offset", 0)
305
306 annotations, err := h.db.GetAnnotationsByAuthor(did, limit, offset)
307 if err != nil {
308 http.Error(w, err.Error(), http.StatusInternalServerError)
309 return
310 }
311
312 enriched, _ := hydrateAnnotations(annotations)
313
314 w.Header().Set("Content-Type", "application/json")
315 json.NewEncoder(w).Encode(map[string]interface{}{
316 "@context": "http://www.w3.org/ns/anno.jsonld",
317 "type": "AnnotationCollection",
318 "creator": did,
319 "items": enriched,
320 "totalItems": len(enriched),
321 })
322}
323
324func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) {
325 did := chi.URLParam(r, "did")
326 if decoded, err := url.QueryUnescape(did); err == nil {
327 did = decoded
328 }
329 limit := parseIntParam(r, "limit", 50)
330 offset := parseIntParam(r, "offset", 0)
331
332 highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
333 if err != nil {
334 http.Error(w, err.Error(), http.StatusInternalServerError)
335 return
336 }
337
338 enriched, _ := hydrateHighlights(highlights)
339
340 w.Header().Set("Content-Type", "application/json")
341 json.NewEncoder(w).Encode(map[string]interface{}{
342 "@context": "http://www.w3.org/ns/anno.jsonld",
343 "type": "HighlightCollection",
344 "creator": did,
345 "items": enriched,
346 "totalItems": len(enriched),
347 })
348}
349
350func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) {
351 did := chi.URLParam(r, "did")
352 if decoded, err := url.QueryUnescape(did); err == nil {
353 did = decoded
354 }
355 limit := parseIntParam(r, "limit", 50)
356 offset := parseIntParam(r, "offset", 0)
357
358 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset)
359 if err != nil {
360 http.Error(w, err.Error(), http.StatusInternalServerError)
361 return
362 }
363
364 enriched, _ := hydrateBookmarks(bookmarks)
365
366 w.Header().Set("Content-Type", "application/json")
367 json.NewEncoder(w).Encode(map[string]interface{}{
368 "@context": "http://www.w3.org/ns/anno.jsonld",
369 "type": "BookmarkCollection",
370 "creator": did,
371 "items": enriched,
372 "totalItems": len(enriched),
373 })
374}
375
376func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) {
377 uri := r.URL.Query().Get("uri")
378 if uri == "" {
379 http.Error(w, "uri query parameter required", http.StatusBadRequest)
380 return
381 }
382
383 replies, err := h.db.GetRepliesByRoot(uri)
384 if err != nil {
385 http.Error(w, err.Error(), http.StatusInternalServerError)
386 return
387 }
388
389 enriched, _ := hydrateReplies(replies)
390
391 w.Header().Set("Content-Type", "application/json")
392 json.NewEncoder(w).Encode(map[string]interface{}{
393 "@context": "http://www.w3.org/ns/anno.jsonld",
394 "type": "ReplyCollection",
395 "inReplyTo": uri,
396 "items": enriched,
397 "totalItems": len(enriched),
398 })
399}
400
401func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) {
402 uri := r.URL.Query().Get("uri")
403 if uri == "" {
404 http.Error(w, "uri query parameter required", http.StatusBadRequest)
405 return
406 }
407
408 count, err := h.db.GetLikeCount(uri)
409 if err != nil {
410 http.Error(w, err.Error(), http.StatusInternalServerError)
411 return
412 }
413
414 liked := false
415 cookie, err := r.Cookie("margin_session")
416 if err == nil && cookie != nil {
417 session, err := h.refresher.GetSessionWithAutoRefresh(r)
418 if err == nil {
419 userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri)
420 if err == nil && userLike != nil {
421 liked = true
422 }
423 }
424 }
425
426 w.Header().Set("Content-Type", "application/json")
427 json.NewEncoder(w).Encode(map[string]interface{}{
428 "count": count,
429 "liked": liked,
430 })
431}
432
433func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) {
434 uri := r.URL.Query().Get("uri")
435 if uri == "" {
436 http.Error(w, "uri query parameter required", http.StatusBadRequest)
437 return
438 }
439
440 history, err := h.db.GetEditHistory(uri)
441 if err != nil {
442 http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError)
443 return
444 }
445
446 if history == nil {
447 history = []db.EditHistory{}
448 }
449
450 w.Header().Set("Content-Type", "application/json")
451 json.NewEncoder(w).Encode(history)
452}
453
454func parseIntParam(r *http.Request, name string, defaultVal int) int {
455 val := r.URL.Query().Get(name)
456 if val == "" {
457 return defaultVal
458 }
459 i, err := strconv.Atoi(val)
460 if err != nil {
461 return defaultVal
462 }
463 return i
464}
465
466func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) {
467 url := r.URL.Query().Get("url")
468 if url == "" {
469 http.Error(w, "url parameter required", http.StatusBadRequest)
470 return
471 }
472
473 client := &http.Client{Timeout: 10 * time.Second}
474 resp, err := client.Get(url)
475 if err != nil {
476 w.Header().Set("Content-Type", "application/json")
477 json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"})
478 return
479 }
480 defer resp.Body.Close()
481
482 body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
483 if err != nil {
484 w.Header().Set("Content-Type", "application/json")
485 json.NewEncoder(w).Encode(map[string]string{"title": ""})
486 return
487 }
488
489 title := ""
490 htmlStr := string(body)
491 if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 {
492 start := idx + 7
493 if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 {
494 title = strings.TrimSpace(htmlStr[start : start+endIdx])
495 }
496 }
497
498 w.Header().Set("Content-Type", "application/json")
499 json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url})
500}
501
502func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) {
503 session, err := h.refresher.GetSessionWithAutoRefresh(r)
504 if err != nil {
505 http.Error(w, err.Error(), http.StatusUnauthorized)
506 return
507 }
508
509 limit := parseIntParam(r, "limit", 50)
510 offset := parseIntParam(r, "offset", 0)
511
512 notifications, err := h.db.GetNotifications(session.DID, limit, offset)
513 if err != nil {
514 http.Error(w, "Failed to get notifications", http.StatusInternalServerError)
515 return
516 }
517
518 enriched, err := hydrateNotifications(notifications)
519 if err != nil {
520 log.Printf("Failed to hydrate notifications: %v\n", err)
521 }
522
523 w.Header().Set("Content-Type", "application/json")
524 if enriched != nil {
525 json.NewEncoder(w).Encode(map[string]interface{}{"items": enriched})
526 } else {
527 json.NewEncoder(w).Encode(map[string]interface{}{"items": notifications})
528 }
529}
530
531func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
532 session, err := h.refresher.GetSessionWithAutoRefresh(r)
533 if err != nil {
534 http.Error(w, err.Error(), http.StatusUnauthorized)
535 return
536 }
537
538 count, err := h.db.GetUnreadNotificationCount(session.DID)
539 if err != nil {
540 http.Error(w, "Failed to get count", http.StatusInternalServerError)
541 return
542 }
543
544 w.Header().Set("Content-Type", "application/json")
545 json.NewEncoder(w).Encode(map[string]int{"count": count})
546}
547
548func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
549 session, err := h.refresher.GetSessionWithAutoRefresh(r)
550 if err != nil {
551 http.Error(w, err.Error(), http.StatusUnauthorized)
552 return
553 }
554
555 if err := h.db.MarkNotificationsRead(session.DID); err != nil {
556 http.Error(w, "Failed to mark as read", http.StatusInternalServerError)
557 return
558 }
559
560 w.Header().Set("Content-Type", "application/json")
561 json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
562}