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