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}