Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 468 lines 13 kB view raw
1package api 2 3import ( 4 "encoding/json" 5 "log" 6 "net/http" 7 "net/url" 8 "strings" 9 "time" 10 11 "github.com/go-chi/chi/v5" 12 13 "margin.at/internal/db" 14 "margin.at/internal/xrpc" 15) 16 17type CollectionService struct { 18 db *db.DB 19 refresher *TokenRefresher 20} 21 22func NewCollectionService(database *db.DB, refresher *TokenRefresher) *CollectionService { 23 return &CollectionService{db: database, refresher: refresher} 24} 25 26type CreateCollectionRequest struct { 27 Name string `json:"name"` 28 Description string `json:"description"` 29 Icon string `json:"icon"` 30} 31 32type AddCollectionItemRequest struct { 33 AnnotationURI string `json:"annotationUri"` 34 Position int `json:"position"` 35} 36 37func (s *CollectionService) CreateCollection(w http.ResponseWriter, r *http.Request) { 38 session, err := s.refresher.GetSessionWithAutoRefresh(r) 39 if err != nil { 40 http.Error(w, err.Error(), http.StatusUnauthorized) 41 return 42 } 43 44 var req CreateCollectionRequest 45 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 46 http.Error(w, "Invalid request body", http.StatusBadRequest) 47 return 48 } 49 50 if req.Name == "" { 51 http.Error(w, "Name is required", http.StatusBadRequest) 52 return 53 } 54 55 record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 56 57 if err := record.Validate(); err != nil { 58 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 59 return 60 } 61 62 var result *xrpc.CreateRecordOutput 63 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 64 var createErr error 65 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollection, record) 66 return createErr 67 }) 68 if err != nil { 69 http.Error(w, "Failed to create collection: "+err.Error(), http.StatusInternalServerError) 70 return 71 } 72 73 did := session.DID 74 var descPtr, iconPtr *string 75 if req.Description != "" { 76 descPtr = &req.Description 77 } 78 if req.Icon != "" { 79 iconPtr = &req.Icon 80 } 81 collection := &db.Collection{ 82 URI: result.URI, 83 AuthorDID: did, 84 Name: req.Name, 85 Description: descPtr, 86 Icon: iconPtr, 87 CreatedAt: time.Now(), 88 IndexedAt: time.Now(), 89 } 90 s.db.CreateCollection(collection) 91 92 w.Header().Set("Content-Type", "application/json") 93 json.NewEncoder(w).Encode(result) 94} 95 96func (s *CollectionService) AddCollectionItem(w http.ResponseWriter, r *http.Request) { 97 collectionURIRaw := chi.URLParam(r, "collection") 98 if collectionURIRaw == "" { 99 http.Error(w, "Collection URI required", http.StatusBadRequest) 100 return 101 } 102 103 collectionURI, _ := url.QueryUnescape(collectionURIRaw) 104 105 session, err := s.refresher.GetSessionWithAutoRefresh(r) 106 if err != nil { 107 http.Error(w, err.Error(), http.StatusUnauthorized) 108 return 109 } 110 111 var req AddCollectionItemRequest 112 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 113 http.Error(w, "Invalid request body", http.StatusBadRequest) 114 return 115 } 116 117 if req.AnnotationURI == "" { 118 http.Error(w, "Annotation URI required", http.StatusBadRequest) 119 return 120 } 121 122 record := xrpc.NewCollectionItemRecord(collectionURI, req.AnnotationURI, req.Position) 123 124 if err := record.Validate(); err != nil { 125 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 126 return 127 } 128 129 var result *xrpc.CreateRecordOutput 130 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 131 var createErr error 132 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollectionItem, record) 133 return createErr 134 }) 135 if err != nil { 136 http.Error(w, "Failed to add item: "+err.Error(), http.StatusInternalServerError) 137 return 138 } 139 140 did := session.DID 141 item := &db.CollectionItem{ 142 URI: result.URI, 143 AuthorDID: did, 144 CollectionURI: collectionURI, 145 AnnotationURI: req.AnnotationURI, 146 Position: req.Position, 147 CreatedAt: time.Now(), 148 IndexedAt: time.Now(), 149 } 150 if err := s.db.AddToCollection(item); err != nil { 151 log.Printf("Failed to add to collection in DB: %v", err) 152 } 153 154 w.Header().Set("Content-Type", "application/json") 155 json.NewEncoder(w).Encode(result) 156} 157 158func (s *CollectionService) RemoveCollectionItem(w http.ResponseWriter, r *http.Request) { 159 itemURI := r.URL.Query().Get("uri") 160 if itemURI == "" { 161 http.Error(w, "Item URI required", http.StatusBadRequest) 162 return 163 } 164 165 session, err := s.refresher.GetSessionWithAutoRefresh(r) 166 if err != nil { 167 http.Error(w, err.Error(), http.StatusUnauthorized) 168 return 169 } 170 171 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 172 return client.DeleteRecordByURI(r.Context(), itemURI) 173 }) 174 if err != nil { 175 log.Printf("Warning: PDS delete failed for %s: %v", itemURI, err) 176 } 177 178 s.db.RemoveFromCollection(itemURI) 179 180 w.Header().Set("Content-Type", "application/json") 181 w.WriteHeader(http.StatusOK) 182 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 183} 184 185func (s *CollectionService) GetAnnotationCollections(w http.ResponseWriter, r *http.Request) { 186 annotationURI := r.URL.Query().Get("uri") 187 if annotationURI == "" { 188 http.Error(w, "uri parameter required", http.StatusBadRequest) 189 return 190 } 191 192 uris, err := s.db.GetCollectionURIsForAnnotation(annotationURI) 193 if err != nil { 194 http.Error(w, err.Error(), http.StatusInternalServerError) 195 return 196 } 197 198 if uris == nil { 199 uris = []string{} 200 } 201 202 w.Header().Set("Content-Type", "application/json") 203 json.NewEncoder(w).Encode(uris) 204} 205 206func (s *CollectionService) GetCollections(w http.ResponseWriter, r *http.Request) { 207 authorDID := r.URL.Query().Get("author") 208 if authorDID == "" { 209 session, err := s.refresher.GetSessionWithAutoRefresh(r) 210 if err == nil { 211 authorDID = session.DID 212 } 213 } 214 215 if authorDID == "" { 216 http.Error(w, "Author DID required", http.StatusBadRequest) 217 return 218 } 219 220 collections, err := s.db.GetCollectionsByAuthor(authorDID) 221 if err != nil { 222 http.Error(w, err.Error(), http.StatusInternalServerError) 223 return 224 } 225 226 profiles := fetchProfilesForDIDs([]string{authorDID}) 227 creator := profiles[authorDID] 228 229 apiCollections := make([]APICollection, len(collections)) 230 for i, c := range collections { 231 icon := "" 232 if c.Icon != nil { 233 icon = *c.Icon 234 } 235 desc := "" 236 if c.Description != nil { 237 desc = *c.Description 238 } 239 apiCollections[i] = APICollection{ 240 URI: c.URI, 241 Name: c.Name, 242 Description: desc, 243 Icon: icon, 244 Creator: creator, 245 CreatedAt: c.CreatedAt, 246 IndexedAt: c.IndexedAt, 247 } 248 } 249 250 w.Header().Set("Content-Type", "application/json") 251 json.NewEncoder(w).Encode(map[string]interface{}{ 252 "@context": "http://www.w3.org/ns/anno.jsonld", 253 "type": "Collection", 254 "items": apiCollections, 255 "totalItems": len(apiCollections), 256 }) 257} 258 259type EnrichedCollectionItem struct { 260 URI string `json:"uri"` 261 CollectionURI string `json:"collectionUri"` 262 AnnotationURI string `json:"annotationUri"` 263 Position int `json:"position"` 264 CreatedAt time.Time `json:"createdAt"` 265 Type string `json:"type"` 266 Annotation *APIAnnotation `json:"annotation,omitempty"` 267 Highlight *APIHighlight `json:"highlight,omitempty"` 268 Bookmark *APIBookmark `json:"bookmark,omitempty"` 269} 270 271func (s *CollectionService) GetCollectionItems(w http.ResponseWriter, r *http.Request) { 272 collectionURI := r.URL.Query().Get("collection") 273 if collectionURI == "" { 274 collectionURIRaw := chi.URLParam(r, "collection") 275 collectionURI, _ = url.QueryUnescape(collectionURIRaw) 276 } 277 278 if collectionURI == "" { 279 http.Error(w, "Collection URI required", http.StatusBadRequest) 280 return 281 } 282 283 items, err := s.db.GetCollectionItems(collectionURI) 284 if err != nil { 285 http.Error(w, err.Error(), http.StatusInternalServerError) 286 return 287 } 288 289 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 290 291 session, err := s.refresher.GetSessionWithAutoRefresh(r) 292 viewerDID := "" 293 if err == nil { 294 viewerDID = session.DID 295 } 296 297 for _, item := range items { 298 enriched := EnrichedCollectionItem{ 299 URI: item.URI, 300 CollectionURI: item.CollectionURI, 301 AnnotationURI: item.AnnotationURI, 302 Position: item.Position, 303 CreatedAt: item.CreatedAt, 304 } 305 306 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 307 enriched.Type = "annotation" 308 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 309 hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID) 310 if len(hydrated) > 0 { 311 enriched.Annotation = &hydrated[0] 312 } 313 } 314 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 315 enriched.Type = "highlight" 316 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 317 hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID) 318 if len(hydrated) > 0 { 319 enriched.Highlight = &hydrated[0] 320 } 321 } 322 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 323 enriched.Type = "bookmark" 324 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 325 hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID) 326 if len(hydrated) > 0 { 327 enriched.Bookmark = &hydrated[0] 328 } 329 } else { 330 log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 331 } 332 } else { 333 log.Printf("Unknown annotation type for URI: %s\n", item.AnnotationURI) 334 } 335 336 if enriched.Annotation != nil || enriched.Highlight != nil || enriched.Bookmark != nil { 337 enrichedItems = append(enrichedItems, enriched) 338 } 339 } 340 341 w.Header().Set("Content-Type", "application/json") 342 json.NewEncoder(w).Encode(enrichedItems) 343} 344 345type UpdateCollectionRequest struct { 346 Name string `json:"name"` 347 Description string `json:"description"` 348 Icon string `json:"icon"` 349} 350 351func (s *CollectionService) UpdateCollection(w http.ResponseWriter, r *http.Request) { 352 uri := r.URL.Query().Get("uri") 353 if uri == "" { 354 http.Error(w, "URI required", http.StatusBadRequest) 355 return 356 } 357 358 session, err := s.refresher.GetSessionWithAutoRefresh(r) 359 if err != nil { 360 http.Error(w, err.Error(), http.StatusUnauthorized) 361 return 362 } 363 364 if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 365 http.Error(w, "Not authorized to update this collection", http.StatusForbidden) 366 return 367 } 368 369 var req UpdateCollectionRequest 370 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 371 http.Error(w, "Invalid request body", http.StatusBadRequest) 372 return 373 } 374 375 if req.Name == "" { 376 http.Error(w, "Name is required", http.StatusBadRequest) 377 return 378 } 379 380 record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 381 382 if err := record.Validate(); err != nil { 383 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 384 return 385 } 386 387 parts := strings.Split(uri, "/") 388 rkey := parts[len(parts)-1] 389 390 var result *xrpc.PutRecordOutput 391 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 392 var updateErr error 393 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 394 if updateErr != nil { 395 log.Printf("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr) 396 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 397 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 398 } 399 return updateErr 400 }) 401 402 if err != nil { 403 http.Error(w, "Failed to update collection: "+err.Error(), http.StatusInternalServerError) 404 return 405 } 406 407 var descPtr, iconPtr *string 408 if req.Description != "" { 409 descPtr = &req.Description 410 } 411 if req.Icon != "" { 412 iconPtr = &req.Icon 413 } 414 415 collection := &db.Collection{ 416 URI: result.URI, 417 AuthorDID: session.DID, 418 Name: req.Name, 419 Description: descPtr, 420 Icon: iconPtr, 421 CreatedAt: time.Now(), 422 IndexedAt: time.Now(), 423 } 424 s.db.CreateCollection(collection) 425 426 w.Header().Set("Content-Type", "application/json") 427 json.NewEncoder(w).Encode(result) 428} 429 430func (s *CollectionService) DeleteCollection(w http.ResponseWriter, r *http.Request) { 431 uri := r.URL.Query().Get("uri") 432 if uri == "" { 433 http.Error(w, "URI required", http.StatusBadRequest) 434 return 435 } 436 437 session, err := s.refresher.GetSessionWithAutoRefresh(r) 438 if err != nil { 439 http.Error(w, err.Error(), http.StatusUnauthorized) 440 return 441 } 442 443 if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 444 http.Error(w, "Not authorized to delete this collection", http.StatusForbidden) 445 return 446 } 447 448 items, _ := s.db.GetCollectionItems(uri) 449 450 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 451 for _, item := range items { 452 client.DeleteRecordByURI(r.Context(), item.URI) 453 } 454 455 parts := strings.Split(uri, "/") 456 rkey := parts[len(parts)-1] 457 return client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 458 }) 459 if err != nil { 460 http.Error(w, "Failed to delete collection: "+err.Error(), http.StatusInternalServerError) 461 return 462 } 463 464 s.db.DeleteCollection(uri) 465 466 w.WriteHeader(http.StatusOK) 467 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 468}