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