Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.11 562 lines 16 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 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}