Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.13 19 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 "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}