Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at v0.1.13 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 resp, err := http.Get("https://plc.directory/" + did) 484 if err != nil { 485 return "", err 486 } 487 defer resp.Body.Close() 488 489 var doc struct { 490 Service []struct { 491 Type string `json:"type"` 492 ServiceEndpoint string `json:"serviceEndpoint"` 493 } `json:"service"` 494 } 495 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 496 return "", err 497 } 498 499 for _, svc := range doc.Service { 500 if svc.Type == "AtprotoPersonalDataServer" { 501 return svc.ServiceEndpoint, nil 502 } 503 } 504 } 505 return "", nil 506} 507 508type CreateHighlightRequest struct { 509 URL string `json:"url"` 510 Title string `json:"title,omitempty"` 511 Selector interface{} `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 var result *xrpc.CreateRecordOutput 538 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 539 var createErr error 540 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 541 return createErr 542 }) 543 if err != nil { 544 http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 545 return 546 } 547 548 var selectorJSONPtr *string 549 if req.Selector != nil { 550 selectorBytes, _ := json.Marshal(req.Selector) 551 selectorStr := string(selectorBytes) 552 selectorJSONPtr = &selectorStr 553 } 554 555 var titlePtr *string 556 if req.Title != "" { 557 titlePtr = &req.Title 558 } 559 560 var colorPtr *string 561 if req.Color != "" { 562 colorPtr = &req.Color 563 } 564 565 var tagsJSONPtr *string 566 if len(req.Tags) > 0 { 567 tagsBytes, _ := json.Marshal(req.Tags) 568 tagsStr := string(tagsBytes) 569 tagsJSONPtr = &tagsStr 570 } 571 572 cid := result.CID 573 highlight := &db.Highlight{ 574 URI: result.URI, 575 AuthorDID: session.DID, 576 TargetSource: req.URL, 577 TargetHash: urlHash, 578 TargetTitle: titlePtr, 579 SelectorJSON: selectorJSONPtr, 580 Color: colorPtr, 581 TagsJSON: tagsJSONPtr, 582 CreatedAt: time.Now(), 583 IndexedAt: time.Now(), 584 CID: &cid, 585 } 586 if err := s.db.CreateHighlight(highlight); err != nil { 587 http.Error(w, "Failed to index highlight", http.StatusInternalServerError) 588 return 589 } 590 591 w.Header().Set("Content-Type", "application/json") 592 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 593} 594 595type CreateBookmarkRequest struct { 596 URL string `json:"url"` 597 Title string `json:"title,omitempty"` 598 Description string `json:"description,omitempty"` 599} 600 601func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { 602 session, err := s.refresher.GetSessionWithAutoRefresh(r) 603 if err != nil { 604 http.Error(w, err.Error(), http.StatusUnauthorized) 605 return 606 } 607 608 var req CreateBookmarkRequest 609 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 610 http.Error(w, "Invalid request body", http.StatusBadRequest) 611 return 612 } 613 614 if req.URL == "" { 615 http.Error(w, "URL is required", http.StatusBadRequest) 616 return 617 } 618 619 urlHash := db.HashURL(req.URL) 620 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 621 622 var result *xrpc.CreateRecordOutput 623 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 624 var createErr error 625 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 626 return createErr 627 }) 628 if err != nil { 629 http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 630 return 631 } 632 633 var titlePtr *string 634 if req.Title != "" { 635 titlePtr = &req.Title 636 } 637 var descPtr *string 638 if req.Description != "" { 639 descPtr = &req.Description 640 } 641 642 cid := result.CID 643 bookmark := &db.Bookmark{ 644 URI: result.URI, 645 AuthorDID: session.DID, 646 Source: req.URL, 647 SourceHash: urlHash, 648 Title: titlePtr, 649 Description: descPtr, 650 CreatedAt: time.Now(), 651 IndexedAt: time.Now(), 652 CID: &cid, 653 } 654 s.db.CreateBookmark(bookmark) 655 656 w.Header().Set("Content-Type", "application/json") 657 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 658} 659 660func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { 661 session, err := s.refresher.GetSessionWithAutoRefresh(r) 662 if err != nil { 663 http.Error(w, err.Error(), http.StatusUnauthorized) 664 return 665 } 666 667 rkey := r.URL.Query().Get("rkey") 668 if rkey == "" { 669 http.Error(w, "rkey required", http.StatusBadRequest) 670 return 671 } 672 673 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 674 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 675 }) 676 if err != nil { 677 http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError) 678 return 679 } 680 681 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey 682 s.db.DeleteHighlight(uri) 683 684 w.Header().Set("Content-Type", "application/json") 685 json.NewEncoder(w).Encode(map[string]bool{"success": true}) 686} 687 688func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { 689 session, err := s.refresher.GetSessionWithAutoRefresh(r) 690 if err != nil { 691 http.Error(w, err.Error(), http.StatusUnauthorized) 692 return 693 } 694 695 rkey := r.URL.Query().Get("rkey") 696 if rkey == "" { 697 http.Error(w, "rkey required", http.StatusBadRequest) 698 return 699 } 700 701 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 702 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 703 }) 704 if err != nil { 705 http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError) 706 return 707 } 708 709 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey 710 s.db.DeleteBookmark(uri) 711 712 w.Header().Set("Content-Type", "application/json") 713 json.NewEncoder(w).Encode(map[string]bool{"success": true}) 714} 715 716type UpdateHighlightRequest struct { 717 Color string `json:"color"` 718 Tags []string `json:"tags,omitempty"` 719} 720 721func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { 722 uri := r.URL.Query().Get("uri") 723 if uri == "" { 724 http.Error(w, "uri query parameter required", http.StatusBadRequest) 725 return 726 } 727 728 session, err := s.refresher.GetSessionWithAutoRefresh(r) 729 if err != nil { 730 http.Error(w, err.Error(), http.StatusUnauthorized) 731 return 732 } 733 734 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 735 http.Error(w, "Not authorized", http.StatusForbidden) 736 return 737 } 738 739 var req UpdateHighlightRequest 740 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 741 http.Error(w, "Invalid request body", http.StatusBadRequest) 742 return 743 } 744 745 parts := parseATURI(uri) 746 if len(parts) < 3 { 747 http.Error(w, "Invalid URI", http.StatusBadRequest) 748 return 749 } 750 rkey := parts[2] 751 752 var result *xrpc.PutRecordOutput 753 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 754 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 755 if getErr != nil { 756 return fmt.Errorf("failed to fetch record: %w", getErr) 757 } 758 759 var record map[string]interface{} 760 json.Unmarshal(existing.Value, &record) 761 762 if req.Color != "" { 763 record["color"] = req.Color 764 } 765 if req.Tags != nil { 766 record["tags"] = req.Tags 767 } 768 769 var updateErr error 770 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 771 if updateErr != nil { 772 log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) 773 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 774 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 775 } 776 return updateErr 777 }) 778 779 if err != nil { 780 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 781 return 782 } 783 784 tagsJSON := "" 785 if req.Tags != nil { 786 b, _ := json.Marshal(req.Tags) 787 tagsJSON = string(b) 788 } 789 s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) 790 791 w.Header().Set("Content-Type", "application/json") 792 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 793} 794 795type UpdateBookmarkRequest struct { 796 Title string `json:"title"` 797 Description string `json:"description"` 798 Tags []string `json:"tags,omitempty"` 799} 800 801func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { 802 uri := r.URL.Query().Get("uri") 803 if uri == "" { 804 http.Error(w, "uri query parameter required", http.StatusBadRequest) 805 return 806 } 807 808 session, err := s.refresher.GetSessionWithAutoRefresh(r) 809 if err != nil { 810 http.Error(w, err.Error(), http.StatusUnauthorized) 811 return 812 } 813 814 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 815 http.Error(w, "Not authorized", http.StatusForbidden) 816 return 817 } 818 819 var req UpdateBookmarkRequest 820 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 821 http.Error(w, "Invalid request body", http.StatusBadRequest) 822 return 823 } 824 825 parts := parseATURI(uri) 826 if len(parts) < 3 { 827 http.Error(w, "Invalid URI", http.StatusBadRequest) 828 return 829 } 830 rkey := parts[2] 831 832 var result *xrpc.PutRecordOutput 833 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 834 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 835 if getErr != nil { 836 return fmt.Errorf("failed to fetch record: %w", getErr) 837 } 838 839 var record map[string]interface{} 840 json.Unmarshal(existing.Value, &record) 841 842 if req.Title != "" { 843 record["title"] = req.Title 844 } 845 if req.Description != "" { 846 record["description"] = req.Description 847 } 848 if req.Tags != nil { 849 record["tags"] = req.Tags 850 } 851 852 var updateErr error 853 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 854 if updateErr != nil { 855 log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) 856 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 857 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 858 } 859 return updateErr 860 }) 861 862 if err != nil { 863 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 864 return 865 } 866 867 tagsJSON := "" 868 if req.Tags != nil { 869 b, _ := json.Marshal(req.Tags) 870 tagsJSON = string(b) 871 } 872 s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) 873 874 w.Header().Set("Content-Type", "application/json") 875 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 876}