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