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