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