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