Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 980 lines 27 kB view raw
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}