package api import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/go-chi/chi/v5" "margin.at/internal/db" "margin.at/internal/xrpc" ) type CollectionService struct { db *db.DB refresher *TokenRefresher } func NewCollectionService(database *db.DB, refresher *TokenRefresher) *CollectionService { return &CollectionService{db: database, refresher: refresher} } type CreateCollectionRequest struct { Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` } type AddCollectionItemRequest struct { AnnotationURI string `json:"annotationUri"` Position int `json:"position"` } func (s *CollectionService) CreateCollection(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } var req CreateCollectionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) if err := record.Validate(); err != nil { http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) return } var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollection, record) return createErr }) if err != nil { http.Error(w, "Failed to create collection: "+err.Error(), http.StatusInternalServerError) return } did := session.DID var descPtr, iconPtr *string if req.Description != "" { descPtr = &req.Description } if req.Icon != "" { iconPtr = &req.Icon } collection := &db.Collection{ URI: result.URI, AuthorDID: did, Name: req.Name, Description: descPtr, Icon: iconPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), } s.db.CreateCollection(collection) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (s *CollectionService) AddCollectionItem(w http.ResponseWriter, r *http.Request) { collectionURIRaw := chi.URLParam(r, "collection") if collectionURIRaw == "" { http.Error(w, "Collection URI required", http.StatusBadRequest) return } collectionURI, _ := url.QueryUnescape(collectionURIRaw) session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } var req AddCollectionItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.AnnotationURI == "" { http.Error(w, "Annotation URI required", http.StatusBadRequest) return } record := xrpc.NewCollectionItemRecord(collectionURI, req.AnnotationURI, req.Position) if err := record.Validate(); err != nil { http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) return } var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollectionItem, record) return createErr }) if err != nil { http.Error(w, "Failed to add item: "+err.Error(), http.StatusInternalServerError) return } did := session.DID item := &db.CollectionItem{ URI: result.URI, AuthorDID: did, CollectionURI: collectionURI, AnnotationURI: req.AnnotationURI, Position: req.Position, CreatedAt: time.Now(), IndexedAt: time.Now(), } if err := s.db.AddToCollection(item); err != nil { log.Printf("Failed to add to collection in DB: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (s *CollectionService) RemoveCollectionItem(w http.ResponseWriter, r *http.Request) { itemURI := r.URL.Query().Get("uri") if itemURI == "" { http.Error(w, "Item URI required", http.StatusBadRequest) return } session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { return client.DeleteRecordByURI(r.Context(), itemURI) }) if err != nil { log.Printf("Warning: PDS delete failed for %s: %v", itemURI, err) } s.db.RemoveFromCollection(itemURI) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } func (s *CollectionService) GetAnnotationCollections(w http.ResponseWriter, r *http.Request) { annotationURI := r.URL.Query().Get("uri") if annotationURI == "" { http.Error(w, "uri parameter required", http.StatusBadRequest) return } uris, err := s.db.GetCollectionURIsForAnnotation(annotationURI) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if uris == nil { uris = []string{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(uris) } func (s *CollectionService) GetCollections(w http.ResponseWriter, r *http.Request) { authorDID := r.URL.Query().Get("author") if authorDID == "" { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err == nil { authorDID = session.DID } } if authorDID == "" { http.Error(w, "Author DID required", http.StatusBadRequest) return } collections, err := s.db.GetCollectionsByAuthor(authorDID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } profiles := fetchProfilesForDIDs([]string{authorDID}) creator := profiles[authorDID] apiCollections := make([]APICollection, len(collections)) for i, c := range collections { icon := "" if c.Icon != nil { icon = *c.Icon } desc := "" if c.Description != nil { desc = *c.Description } apiCollections[i] = APICollection{ URI: c.URI, Name: c.Name, Description: desc, Icon: icon, Creator: creator, CreatedAt: c.CreatedAt, IndexedAt: c.IndexedAt, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "Collection", "items": apiCollections, "totalItems": len(apiCollections), }) } type EnrichedCollectionItem struct { URI string `json:"uri"` CollectionURI string `json:"collectionUri"` AnnotationURI string `json:"annotationUri"` Position int `json:"position"` CreatedAt time.Time `json:"createdAt"` Type string `json:"type"` Annotation *APIAnnotation `json:"annotation,omitempty"` Highlight *APIHighlight `json:"highlight,omitempty"` Bookmark *APIBookmark `json:"bookmark,omitempty"` } func (s *CollectionService) GetCollectionItems(w http.ResponseWriter, r *http.Request) { collectionURI := r.URL.Query().Get("collection") if collectionURI == "" { collectionURIRaw := chi.URLParam(r, "collection") collectionURI, _ = url.QueryUnescape(collectionURIRaw) } if collectionURI == "" { http.Error(w, "Collection URI required", http.StatusBadRequest) return } items, err := s.db.GetCollectionItems(collectionURI) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } var sembleURIs []string for _, item := range items { if strings.Contains(item.AnnotationURI, "network.cosmik.card") { sembleURIs = append(sembleURIs, item.AnnotationURI) } } if len(sembleURIs) > 0 { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() ensureSembleCardsIndexed(ctx, s.db, sembleURIs) } session, err := s.refresher.GetSessionWithAutoRefresh(r) viewerDID := "" if err == nil { viewerDID = session.DID } enrichedItems, err := hydrateCollectionItems(s.db, items, viewerDID) if err != nil { log.Printf("Hydration error: %v", err) enrichedItems = []APICollectionItem{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(enrichedItems) } type UpdateCollectionRequest struct { Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` } func (s *CollectionService) UpdateCollection(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "URI required", http.StatusBadRequest) return } session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { http.Error(w, "Not authorized to update this collection", http.StatusForbidden) return } var req UpdateCollectionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) if err := record.Validate(); err != nil { http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) return } parts := strings.Split(uri, "/") rkey := parts[len(parts)-1] var result *xrpc.PutRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var updateErr error result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) if updateErr != nil { log.Printf("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr) _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) } return updateErr }) if err != nil { http.Error(w, "Failed to update collection: "+err.Error(), http.StatusInternalServerError) return } var descPtr, iconPtr *string if req.Description != "" { descPtr = &req.Description } if req.Icon != "" { iconPtr = &req.Icon } collection := &db.Collection{ URI: result.URI, AuthorDID: session.DID, Name: req.Name, Description: descPtr, Icon: iconPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), } s.db.CreateCollection(collection) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func (s *CollectionService) DeleteCollection(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "URI required", http.StatusBadRequest) return } session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { http.Error(w, "Not authorized to delete this collection", http.StatusForbidden) return } items, _ := s.db.GetCollectionItems(uri) err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { for _, item := range items { client.DeleteRecordByURI(r.Context(), item.URI) } parts := strings.Split(uri, "/") rkey := parts[len(parts)-1] return client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) }) if err != nil { http.Error(w, "Failed to delete collection: "+err.Error(), http.StatusInternalServerError) return } s.db.DeleteCollection(uri) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } func (s *CollectionService) GetCollection(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "URI required", http.StatusBadRequest) return } collection, err := s.db.GetCollectionByURI(uri) if err != nil { if strings.Contains(uri, "at.margin.collection") && strings.HasPrefix(uri, "at://") { uriWithoutScheme := strings.TrimPrefix(uri, "at://") parts := strings.Split(uriWithoutScheme, "/") if len(parts) >= 3 { did := parts[0] rkey := parts[len(parts)-1] sembleURI := fmt.Sprintf("at://%s/network.cosmik.collection/%s", did, rkey) collection, err = s.db.GetCollectionByURI(sembleURI) } } } if err != nil || collection == nil { http.Error(w, "Collection not found", http.StatusNotFound) return } profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) creator := profiles[collection.AuthorDID] icon := "" if collection.Icon != nil { icon = *collection.Icon } desc := "" if collection.Description != nil { desc = *collection.Description } apiCollection := APICollection{ URI: collection.URI, Name: collection.Name, Description: desc, Icon: icon, Creator: creator, CreatedAt: collection.CreatedAt, IndexedAt: collection.IndexedAt, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(apiCollection) }