Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1package api
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "net/http"
8 "regexp"
9 "strings"
10 "time"
11
12 "margin.at/internal/db"
13 "margin.at/internal/xrpc"
14)
15
16type AnnotationService struct {
17 db *db.DB
18 refresher *TokenRefresher
19}
20
21func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *AnnotationService {
22 return &AnnotationService{db: database, refresher: refresher}
23}
24
25type CreateAnnotationRequest struct {
26 URL string `json:"url"`
27 Text string `json:"text"`
28 Selector json.RawMessage `json:"selector,omitempty"`
29 Title string `json:"title,omitempty"`
30 Tags []string `json:"tags,omitempty"`
31}
32
33type CreateAnnotationResponse struct {
34 URI string `json:"uri"`
35 CID string `json:"cid"`
36}
37
38func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) {
39 session, err := s.refresher.GetSessionWithAutoRefresh(r)
40 if err != nil {
41 http.Error(w, err.Error(), http.StatusUnauthorized)
42 return
43 }
44
45 var req CreateAnnotationRequest
46 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
47 http.Error(w, "Invalid request body", http.StatusBadRequest)
48 return
49 }
50
51 if req.URL == "" {
52 http.Error(w, "URL is required", http.StatusBadRequest)
53 return
54 }
55
56 if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 {
57 http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest)
58 return
59 }
60
61 if len(req.Text) > 3000 {
62 http.Error(w, "Text too long (max 3000 chars)", http.StatusBadRequest)
63 return
64 }
65
66 urlHash := db.HashURL(req.URL)
67
68 motivation := "commenting"
69 if req.Selector != nil && req.Text == "" {
70 motivation = "highlighting"
71 } else if len(req.Tags) > 0 {
72 motivation = "tagging"
73 }
74
75 var facets []xrpc.Facet
76 var mentionedDIDs []string
77
78 mentionRegex := regexp.MustCompile(`(^|\s|@)@([a-zA-Z0-9.-]+)(\b)`)
79 matches := mentionRegex.FindAllStringSubmatchIndex(req.Text, -1)
80
81 for _, m := range matches {
82 handle := req.Text[m[4]:m[5]]
83
84 if !strings.Contains(handle, ".") {
85 continue
86 }
87
88 var did string
89 err := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error {
90 var resolveErr error
91 did, resolveErr = client.ResolveHandle(r.Context(), handle)
92 return resolveErr
93 })
94
95 if err == nil && did != "" {
96 start := m[2]
97 end := m[5]
98
99 facets = append(facets, xrpc.Facet{
100 Index: xrpc.FacetIndex{
101 ByteStart: start,
102 ByteEnd: end,
103 },
104 Features: []xrpc.FacetFeature{
105 {
106 Type: "app.bsky.richtext.facet#mention",
107 Did: did,
108 },
109 },
110 })
111 mentionedDIDs = append(mentionedDIDs, did)
112 }
113 }
114
115 urlRegex := regexp.MustCompile(`(https?://[^\s]+)`)
116 urlMatches := urlRegex.FindAllStringIndex(req.Text, -1)
117
118 for _, m := range urlMatches {
119 facets = append(facets, xrpc.Facet{
120 Index: xrpc.FacetIndex{
121 ByteStart: m[0],
122 ByteEnd: m[1],
123 },
124 Features: []xrpc.FacetFeature{
125 {
126 Type: "app.bsky.richtext.facet#link",
127 Uri: req.Text[m[0]:m[1]],
128 },
129 },
130 })
131 }
132
133 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
134 if len(req.Tags) > 0 {
135 record.Tags = req.Tags
136 }
137 if len(facets) > 0 {
138 record.Facets = facets
139 }
140
141 var result *xrpc.CreateRecordOutput
142
143 if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil {
144 w.Header().Set("Content-Type", "application/json")
145 json.NewEncoder(w).Encode(CreateAnnotationResponse{
146 URI: existing.URI,
147 CID: *existing.CID,
148 })
149 return
150 }
151
152 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
153 var createErr error
154 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record)
155 return createErr
156 })
157 if err != nil {
158 http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError)
159 return
160 }
161
162 for _, mentionedDID := range mentionedDIDs {
163 if mentionedDID != session.DID {
164 s.db.CreateNotification(&db.Notification{
165 RecipientDID: mentionedDID,
166 ActorDID: session.DID,
167 Type: "mention",
168 SubjectURI: result.URI,
169 CreatedAt: time.Now(),
170 })
171 }
172 }
173
174 bodyValue := req.Text
175 var bodyValuePtr, targetTitlePtr, selectorJSONPtr *string
176 if bodyValue != "" {
177 bodyValuePtr = &bodyValue
178 }
179 if req.Title != "" {
180 targetTitlePtr = &req.Title
181 }
182 if req.Selector != nil {
183 selectorBytes, _ := json.Marshal(req.Selector)
184 selectorStr := string(selectorBytes)
185 selectorJSONPtr = &selectorStr
186 }
187
188 var tagsJSONPtr *string
189 if len(req.Tags) > 0 {
190 tagsBytes, _ := json.Marshal(req.Tags)
191 tagsStr := string(tagsBytes)
192 tagsJSONPtr = &tagsStr
193 }
194
195 cid := result.CID
196 did := session.DID
197 annotation := &db.Annotation{
198 URI: result.URI,
199 CID: &cid,
200 AuthorDID: did,
201 Motivation: motivation,
202 BodyValue: bodyValuePtr,
203 TargetSource: req.URL,
204 TargetHash: urlHash,
205 TargetTitle: targetTitlePtr,
206 SelectorJSON: selectorJSONPtr,
207 TagsJSON: tagsJSONPtr,
208 CreatedAt: time.Now(),
209 IndexedAt: time.Now(),
210 }
211
212 if err := s.db.CreateAnnotation(annotation); err != nil {
213 log.Printf("Warning: failed to index annotation in local DB: %v", err)
214 }
215
216 w.Header().Set("Content-Type", "application/json")
217 json.NewEncoder(w).Encode(CreateAnnotationResponse{
218 URI: result.URI,
219 CID: result.CID,
220 })
221}
222
223func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) {
224 session, err := s.refresher.GetSessionWithAutoRefresh(r)
225 if err != nil {
226 http.Error(w, err.Error(), http.StatusUnauthorized)
227 return
228 }
229
230 rkey := r.URL.Query().Get("rkey")
231 collectionType := r.URL.Query().Get("type")
232
233 if rkey == "" {
234 http.Error(w, "rkey required", http.StatusBadRequest)
235 return
236 }
237
238 collection := xrpc.CollectionAnnotation
239 if collectionType == "reply" {
240 collection = xrpc.CollectionReply
241 }
242
243 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
244 return client.DeleteRecord(r.Context(), did, collection, rkey)
245 })
246 if err != nil {
247 http.Error(w, "Failed to delete record: "+err.Error(), http.StatusInternalServerError)
248 return
249 }
250
251 did := session.DID
252 if collectionType == "reply" {
253 uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey
254 s.db.DeleteReply(uri)
255 } else {
256 uri := "at://" + did + "/" + xrpc.CollectionAnnotation + "/" + rkey
257 s.db.DeleteAnnotation(uri)
258 }
259
260 w.Header().Set("Content-Type", "application/json")
261 json.NewEncoder(w).Encode(map[string]bool{"success": true})
262}
263
264type UpdateAnnotationRequest struct {
265 Text string `json:"text"`
266 Tags []string `json:"tags"`
267}
268
269func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
270 uri := r.URL.Query().Get("uri")
271 if uri == "" {
272 http.Error(w, "uri query parameter required", http.StatusBadRequest)
273 return
274 }
275
276 session, err := s.refresher.GetSessionWithAutoRefresh(r)
277 if err != nil {
278 http.Error(w, err.Error(), http.StatusUnauthorized)
279 return
280 }
281
282 annotation, err := s.db.GetAnnotationByURI(uri)
283 if err != nil || annotation == nil {
284 http.Error(w, "Annotation not found", http.StatusNotFound)
285 return
286 }
287
288 if annotation.AuthorDID != session.DID {
289 http.Error(w, "Not authorized to edit this annotation", http.StatusForbidden)
290 return
291 }
292
293 var req UpdateAnnotationRequest
294 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
295 http.Error(w, "Invalid request body", http.StatusBadRequest)
296 return
297 }
298
299 parts := parseATURI(uri)
300 if len(parts) < 3 {
301 http.Error(w, "Invalid URI format", http.StatusBadRequest)
302 return
303 }
304 rkey := parts[2]
305
306 tagsJSON := ""
307 if len(req.Tags) > 0 {
308 tagsBytes, _ := json.Marshal(req.Tags)
309 tagsJSON = string(tagsBytes)
310 }
311
312 if annotation.BodyValue != nil {
313 previousContent := *annotation.BodyValue
314 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
315 }
316
317 var result *xrpc.PutRecordOutput
318 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
319 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey)
320 if getErr != nil {
321 return fmt.Errorf("failed to fetch existing record: %w", getErr)
322 }
323
324 var record xrpc.AnnotationRecord
325 if err := json.Unmarshal(existing.Value, &record); err != nil {
326 return fmt.Errorf("failed to parse existing record: %w", err)
327 }
328
329 record.Body = &xrpc.AnnotationBody{
330 Value: req.Text,
331 Format: "text/plain",
332 }
333 if len(req.Tags) > 0 {
334 record.Tags = req.Tags
335 } else {
336 record.Tags = nil
337 }
338
339 if err := record.Validate(); err != nil {
340 return fmt.Errorf("validation failed: %w", err)
341 }
342
343 var updateErr error
344 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
345 if updateErr != nil {
346 log.Printf("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr)
347 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey)
348 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
349 }
350 return updateErr
351 })
352
353 if err != nil {
354 http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError)
355 return
356 }
357
358 s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID)
359
360 w.Header().Set("Content-Type", "application/json")
361 json.NewEncoder(w).Encode(map[string]interface{}{
362 "success": true,
363 "uri": result.URI,
364 "cid": result.CID,
365 })
366}
367
368func parseATURI(uri string) []string {
369
370 if len(uri) < 5 || uri[:5] != "at://" {
371 return nil
372 }
373 return strings.Split(uri[5:], "/")
374}
375
376type CreateLikeRequest struct {
377 SubjectURI string `json:"subjectUri"`
378 SubjectCID string `json:"subjectCid"`
379}
380
381func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) {
382 session, err := s.refresher.GetSessionWithAutoRefresh(r)
383 if err != nil {
384 http.Error(w, err.Error(), http.StatusUnauthorized)
385 return
386 }
387
388 var req CreateLikeRequest
389 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
390 http.Error(w, "Invalid request body", http.StatusBadRequest)
391 return
392 }
393
394 existingLike, _ := s.db.GetLikeByUserAndSubject(session.DID, req.SubjectURI)
395 if existingLike != nil {
396 w.Header().Set("Content-Type", "application/json")
397 json.NewEncoder(w).Encode(map[string]string{"uri": existingLike.URI, "existing": "true"})
398 return
399 }
400
401 record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID)
402
403 if err := record.Validate(); err != nil {
404 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
405 return
406 }
407
408 var result *xrpc.CreateRecordOutput
409 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
410 var createErr error
411 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionLike, record)
412 return createErr
413 })
414 if err != nil {
415 http.Error(w, "Failed to create like: "+err.Error(), http.StatusInternalServerError)
416 return
417 }
418
419 did := session.DID
420 like := &db.Like{
421 URI: result.URI,
422 AuthorDID: did,
423 SubjectURI: req.SubjectURI,
424 CreatedAt: time.Now(),
425 IndexedAt: time.Now(),
426 }
427 s.db.CreateLike(like)
428
429 if authorDID, err := s.db.GetAuthorByURI(req.SubjectURI); err == nil && authorDID != did {
430 s.db.CreateNotification(&db.Notification{
431 RecipientDID: authorDID,
432 ActorDID: did,
433 Type: "like",
434 SubjectURI: req.SubjectURI,
435 CreatedAt: time.Now(),
436 })
437 }
438
439 w.Header().Set("Content-Type", "application/json")
440 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI})
441}
442
443func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) {
444 session, err := s.refresher.GetSessionWithAutoRefresh(r)
445 if err != nil {
446 http.Error(w, err.Error(), http.StatusUnauthorized)
447 return
448 }
449
450 subjectURI := r.URL.Query().Get("uri")
451 if subjectURI == "" {
452 http.Error(w, "uri query parameter required", http.StatusBadRequest)
453 return
454 }
455
456 userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI)
457 if err != nil {
458 http.Error(w, "Like not found", http.StatusNotFound)
459 return
460 }
461
462 parts := strings.Split(userLike.URI, "/")
463 rkey := parts[len(parts)-1]
464
465 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
466 return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey)
467 })
468 if err != nil {
469 http.Error(w, "Failed to delete like: "+err.Error(), http.StatusInternalServerError)
470 return
471 }
472
473 s.db.DeleteLike(userLike.URI)
474
475 w.Header().Set("Content-Type", "application/json")
476 json.NewEncoder(w).Encode(map[string]bool{"success": true})
477}
478
479type CreateReplyRequest struct {
480 ParentURI string `json:"parentUri"`
481 ParentCID string `json:"parentCid"`
482 RootURI string `json:"rootUri"`
483 RootCID string `json:"rootCid"`
484 Text string `json:"text"`
485}
486
487func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) {
488 session, err := s.refresher.GetSessionWithAutoRefresh(r)
489 if err != nil {
490 http.Error(w, err.Error(), http.StatusUnauthorized)
491 return
492 }
493
494 var req CreateReplyRequest
495 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
496 http.Error(w, "Invalid request body", http.StatusBadRequest)
497 return
498 }
499
500 record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text)
501
502 if err := record.Validate(); err != nil {
503 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
504 return
505 }
506
507 var result *xrpc.CreateRecordOutput
508 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
509 var createErr error
510 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionReply, record)
511 return createErr
512 })
513 if err != nil {
514 http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError)
515 return
516 }
517
518 reply := &db.Reply{
519 URI: result.URI,
520 AuthorDID: session.DID,
521 ParentURI: req.ParentURI,
522 RootURI: req.RootURI,
523 Text: req.Text,
524 CreatedAt: time.Now(),
525 IndexedAt: time.Now(),
526 CID: &result.CID,
527 }
528 s.db.CreateReply(reply)
529
530 if authorDID, err := s.db.GetAuthorByURI(req.ParentURI); err == nil && authorDID != session.DID {
531 s.db.CreateNotification(&db.Notification{
532 RecipientDID: authorDID,
533 ActorDID: session.DID,
534 Type: "reply",
535 SubjectURI: result.URI,
536 CreatedAt: time.Now(),
537 })
538 }
539
540 w.Header().Set("Content-Type", "application/json")
541 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI})
542}
543
544func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) {
545 uri := r.URL.Query().Get("uri")
546 if uri == "" {
547 http.Error(w, "uri query parameter required", http.StatusBadRequest)
548 return
549 }
550
551 session, err := s.refresher.GetSessionWithAutoRefresh(r)
552 if err != nil {
553 http.Error(w, err.Error(), http.StatusUnauthorized)
554 return
555 }
556
557 reply, err := s.db.GetReplyByURI(uri)
558 if err != nil || reply == nil {
559 http.Error(w, "reply not found", http.StatusNotFound)
560 return
561 }
562
563 if reply.AuthorDID != session.DID {
564 http.Error(w, "not authorized to delete this reply", http.StatusForbidden)
565 return
566 }
567
568 parts := strings.Split(uri, "/")
569 if len(parts) >= 2 {
570 rkey := parts[len(parts)-1]
571 _ = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
572 return client.DeleteRecord(r.Context(), did, "at.margin.reply", rkey)
573 })
574 }
575
576 s.db.DeleteReply(uri)
577
578 w.Header().Set("Content-Type", "application/json")
579 json.NewEncoder(w).Encode(map[string]bool{"success": true})
580}
581
582type CreateHighlightRequest struct {
583 URL string `json:"url"`
584 Title string `json:"title,omitempty"`
585 Selector json.RawMessage `json:"selector"`
586 Color string `json:"color,omitempty"`
587 Tags []string `json:"tags,omitempty"`
588}
589
590func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
591 session, err := s.refresher.GetSessionWithAutoRefresh(r)
592 if err != nil {
593 http.Error(w, err.Error(), http.StatusUnauthorized)
594 return
595 }
596
597 var req CreateHighlightRequest
598 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
599 http.Error(w, "Invalid request body", http.StatusBadRequest)
600 return
601 }
602
603 if req.URL == "" || req.Selector == nil {
604 http.Error(w, "URL and selector are required", http.StatusBadRequest)
605 return
606 }
607
608 urlHash := db.HashURL(req.URL)
609 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
610
611 if err := record.Validate(); err != nil {
612 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
613 return
614 }
615
616 var result *xrpc.CreateRecordOutput
617
618 if existing, err := s.checkDuplicateHighlight(session.DID, req.URL, req.Selector); err == nil && existing != nil {
619 w.Header().Set("Content-Type", "application/json")
620 json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID})
621 return
622 }
623
624 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
625 var createErr error
626 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record)
627 return createErr
628 })
629 if err != nil {
630 http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError)
631 return
632 }
633
634 var selectorJSONPtr *string
635 if len(record.Target.Selector) > 0 {
636 selectorStr := string(record.Target.Selector)
637 selectorJSONPtr = &selectorStr
638 }
639
640 var titlePtr *string
641 if req.Title != "" {
642 titlePtr = &req.Title
643 }
644
645 var colorPtr *string
646 if req.Color != "" {
647 colorPtr = &req.Color
648 }
649
650 var tagsJSONPtr *string
651 if len(req.Tags) > 0 {
652 tagsBytes, _ := json.Marshal(req.Tags)
653 tagsStr := string(tagsBytes)
654 tagsJSONPtr = &tagsStr
655 }
656
657 cid := result.CID
658 highlight := &db.Highlight{
659 URI: result.URI,
660 AuthorDID: session.DID,
661 TargetSource: req.URL,
662 TargetHash: urlHash,
663 TargetTitle: titlePtr,
664 SelectorJSON: selectorJSONPtr,
665 Color: colorPtr,
666 TagsJSON: tagsJSONPtr,
667 CreatedAt: time.Now(),
668 IndexedAt: time.Now(),
669 CID: &cid,
670 }
671 if err := s.db.CreateHighlight(highlight); err != nil {
672 http.Error(w, "Failed to index highlight", http.StatusInternalServerError)
673 return
674 }
675
676 w.Header().Set("Content-Type", "application/json")
677 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
678}
679
680type CreateBookmarkRequest struct {
681 URL string `json:"url"`
682 Title string `json:"title,omitempty"`
683 Description string `json:"description,omitempty"`
684}
685
686func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) {
687 session, err := s.refresher.GetSessionWithAutoRefresh(r)
688 if err != nil {
689 http.Error(w, err.Error(), http.StatusUnauthorized)
690 return
691 }
692
693 var req CreateBookmarkRequest
694 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
695 http.Error(w, "Invalid request body", http.StatusBadRequest)
696 return
697 }
698
699 if req.URL == "" {
700 http.Error(w, "URL is required", http.StatusBadRequest)
701 return
702 }
703
704 urlHash := db.HashURL(req.URL)
705 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description)
706
707 if err := record.Validate(); err != nil {
708 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
709 return
710 }
711
712 var result *xrpc.CreateRecordOutput
713
714 if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil {
715 http.Error(w, "Bookmark already exists", http.StatusConflict)
716 return
717 }
718
719 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
720 var createErr error
721 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record)
722 return createErr
723 })
724 if err != nil {
725 http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError)
726 return
727 }
728
729 var titlePtr *string
730 if req.Title != "" {
731 titlePtr = &req.Title
732 }
733 var descPtr *string
734 if req.Description != "" {
735 descPtr = &req.Description
736 }
737
738 cid := result.CID
739 bookmark := &db.Bookmark{
740 URI: result.URI,
741 AuthorDID: session.DID,
742 Source: req.URL,
743 SourceHash: urlHash,
744 Title: titlePtr,
745 Description: descPtr,
746 CreatedAt: time.Now(),
747 IndexedAt: time.Now(),
748 CID: &cid,
749 }
750 s.db.CreateBookmark(bookmark)
751
752 w.Header().Set("Content-Type", "application/json")
753 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
754}
755
756func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) {
757 session, err := s.refresher.GetSessionWithAutoRefresh(r)
758 if err != nil {
759 http.Error(w, err.Error(), http.StatusUnauthorized)
760 return
761 }
762
763 rkey := r.URL.Query().Get("rkey")
764 if rkey == "" {
765 http.Error(w, "rkey required", http.StatusBadRequest)
766 return
767 }
768
769 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
770 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
771 })
772 if err != nil {
773 http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError)
774 return
775 }
776
777 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey
778 s.db.DeleteHighlight(uri)
779
780 w.Header().Set("Content-Type", "application/json")
781 json.NewEncoder(w).Encode(map[string]bool{"success": true})
782}
783
784func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) {
785 session, err := s.refresher.GetSessionWithAutoRefresh(r)
786 if err != nil {
787 http.Error(w, err.Error(), http.StatusUnauthorized)
788 return
789 }
790
791 rkey := r.URL.Query().Get("rkey")
792 if rkey == "" {
793 http.Error(w, "rkey required", http.StatusBadRequest)
794 return
795 }
796
797 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
798 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
799 })
800 if err != nil {
801 http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError)
802 return
803 }
804
805 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey
806 s.db.DeleteBookmark(uri)
807
808 w.Header().Set("Content-Type", "application/json")
809 json.NewEncoder(w).Encode(map[string]bool{"success": true})
810}
811
812type UpdateHighlightRequest struct {
813 Color string `json:"color"`
814 Tags []string `json:"tags,omitempty"`
815}
816
817func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) {
818 uri := r.URL.Query().Get("uri")
819 if uri == "" {
820 http.Error(w, "uri query parameter required", http.StatusBadRequest)
821 return
822 }
823
824 session, err := s.refresher.GetSessionWithAutoRefresh(r)
825 if err != nil {
826 http.Error(w, err.Error(), http.StatusUnauthorized)
827 return
828 }
829
830 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
831 http.Error(w, "Not authorized", http.StatusForbidden)
832 return
833 }
834
835 var req UpdateHighlightRequest
836 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
837 http.Error(w, "Invalid request body", http.StatusBadRequest)
838 return
839 }
840
841 parts := parseATURI(uri)
842 if len(parts) < 3 {
843 http.Error(w, "Invalid URI", http.StatusBadRequest)
844 return
845 }
846 rkey := parts[2]
847
848 var result *xrpc.PutRecordOutput
849 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
850 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
851 if getErr != nil {
852 return fmt.Errorf("failed to fetch record: %w", getErr)
853 }
854
855 var record xrpc.HighlightRecord
856 json.Unmarshal(existing.Value, &record)
857
858 if req.Color != "" {
859 record.Color = req.Color
860 }
861 if req.Tags != nil {
862 record.Tags = req.Tags
863 }
864
865 if err := record.Validate(); err != nil {
866 return fmt.Errorf("validation failed: %w", err)
867 }
868
869 var updateErr error
870 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
871 if updateErr != nil {
872 log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr)
873 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
874 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
875 }
876 return updateErr
877 })
878
879 if err != nil {
880 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
881 return
882 }
883
884 tagsJSON := ""
885 if req.Tags != nil {
886 b, _ := json.Marshal(req.Tags)
887 tagsJSON = string(b)
888 }
889 s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID)
890
891 w.Header().Set("Content-Type", "application/json")
892 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
893}
894
895type UpdateBookmarkRequest struct {
896 Title string `json:"title"`
897 Description string `json:"description"`
898 Tags []string `json:"tags,omitempty"`
899}
900
901func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) {
902 uri := r.URL.Query().Get("uri")
903 if uri == "" {
904 http.Error(w, "uri query parameter required", http.StatusBadRequest)
905 return
906 }
907
908 session, err := s.refresher.GetSessionWithAutoRefresh(r)
909 if err != nil {
910 http.Error(w, err.Error(), http.StatusUnauthorized)
911 return
912 }
913
914 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
915 http.Error(w, "Not authorized", http.StatusForbidden)
916 return
917 }
918
919 var req UpdateBookmarkRequest
920 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
921 http.Error(w, "Invalid request body", http.StatusBadRequest)
922 return
923 }
924
925 parts := parseATURI(uri)
926 if len(parts) < 3 {
927 http.Error(w, "Invalid URI", http.StatusBadRequest)
928 return
929 }
930 rkey := parts[2]
931
932 var result *xrpc.PutRecordOutput
933 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
934 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
935 if getErr != nil {
936 return fmt.Errorf("failed to fetch record: %w", getErr)
937 }
938
939 var record xrpc.BookmarkRecord
940 json.Unmarshal(existing.Value, &record)
941
942 if req.Title != "" {
943 record.Title = req.Title
944 }
945 if req.Description != "" {
946 record.Description = req.Description
947 }
948 if req.Tags != nil {
949 record.Tags = req.Tags
950 }
951
952 if err := record.Validate(); err != nil {
953 return fmt.Errorf("validation failed: %w", err)
954 }
955
956 var updateErr error
957 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
958 if updateErr != nil {
959 log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr)
960 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
961 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
962 }
963 return updateErr
964 })
965
966 if err != nil {
967 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
968 return
969 }
970
971 tagsJSON := ""
972 if req.Tags != nil {
973 b, _ := json.Marshal(req.Tags)
974 tagsJSON = string(b)
975 }
976 s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID)
977
978 w.Header().Set("Content-Type", "application/json")
979 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
980}