(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

fixes and improvements

+1025 -1544
-19
backend/cmd/server/main.go
··· 162 162 handler := api.NewHandler(database, annotationSvc, tokenRefresher, syncSvc, recService) 163 163 handler.RegisterRoutes(r) 164 164 165 - r.Post("/api/annotations", annotationSvc.CreateAnnotation) 166 - r.Put("/api/annotations", annotationSvc.UpdateAnnotation) 167 - r.Delete("/api/annotations", annotationSvc.DeleteAnnotation) 168 - r.Post("/api/annotations/like", annotationSvc.LikeAnnotation) 169 - r.Delete("/api/annotations/like", annotationSvc.UnlikeAnnotation) 170 - r.Post("/api/annotations/reply", annotationSvc.CreateReply) 171 - r.Delete("/api/annotations/reply", annotationSvc.DeleteReply) 172 - r.Post("/api/highlights", annotationSvc.CreateHighlight) 173 - r.Put("/api/highlights", annotationSvc.UpdateHighlight) 174 - r.Delete("/api/highlights", annotationSvc.DeleteHighlight) 175 - r.Post("/api/bookmarks", annotationSvc.CreateBookmark) 176 - r.Put("/api/bookmarks", annotationSvc.UpdateBookmark) 177 - r.Delete("/api/bookmarks", annotationSvc.DeleteBookmark) 178 - 179 165 r.Route("/auth", func(r chi.Router) { 180 166 r.Use(middleware.Throttle(10)) 181 167 r.Get("/login", oauthHandler.HandleLogin) ··· 187 173 }) 188 174 r.Get("/client-metadata.json", oauthHandler.HandleClientMetadata) 189 175 r.Get("/jwks.json", oauthHandler.HandleJWKS) 190 - 191 - r.Get("/api/tags/trending", handler.HandleGetTrendingTags) 192 - r.Put("/api/profile", handler.UpdateProfile) 193 - r.Get("/api/profile/{did}", handler.GetProfile) 194 - r.Post("/api/profile/avatar", handler.UploadAvatar) 195 176 196 177 port := getEnv("PORT", "8081") 197 178 server := &http.Server{
+67 -80
backend/internal/api/annotations.go
··· 39 39 func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { 40 40 session, err := s.refresher.GetSessionWithAutoRefresh(r) 41 41 if err != nil { 42 - http.Error(w, err.Error(), http.StatusUnauthorized) 42 + WriteUnauthorized(w, err.Error()) 43 43 return 44 44 } 45 45 46 46 var req CreateAnnotationRequest 47 47 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 48 - http.Error(w, "Invalid request body", http.StatusBadRequest) 48 + WriteBadRequest(w, "Invalid request body") 49 49 return 50 50 } 51 51 52 52 if req.URL == "" { 53 - http.Error(w, "URL is required", http.StatusBadRequest) 53 + WriteBadRequest(w, "URL is required") 54 54 return 55 55 } 56 56 57 57 if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { 58 - http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest) 58 + WriteBadRequest(w, "Must provide text, selector, or tags") 59 59 return 60 60 } 61 61 62 62 if len(req.Text) > 3000 { 63 - http.Error(w, "Text too long (max 3000 chars)", http.StatusBadRequest) 63 + WriteBadRequest(w, "Text too long (max 3000 chars)") 64 64 return 65 65 } 66 66 ··· 233 233 } 234 234 } 235 235 236 - w.Header().Set("Content-Type", "application/json") 237 - json.NewEncoder(w).Encode(CreateAnnotationResponse{ 236 + WriteSuccess(w, CreateAnnotationResponse{ 238 237 URI: result.URI, 239 238 CID: result.CID, 240 239 }) ··· 243 242 func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { 244 243 session, err := s.refresher.GetSessionWithAutoRefresh(r) 245 244 if err != nil { 246 - http.Error(w, err.Error(), http.StatusUnauthorized) 245 + WriteUnauthorized(w, err.Error()) 247 246 return 248 247 } 249 248 ··· 251 250 collectionType := r.URL.Query().Get("type") 252 251 253 252 if rkey == "" { 254 - http.Error(w, "rkey required", http.StatusBadRequest) 253 + WriteBadRequest(w, "rkey required") 255 254 return 256 255 } 257 256 ··· 287 286 s.db.DeleteAnnotation(uri) 288 287 } 289 288 290 - w.Header().Set("Content-Type", "application/json") 291 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 289 + WriteSuccess(w, map[string]bool{"success": true}) 292 290 } 293 291 294 292 type UpdateAnnotationRequest struct { ··· 300 298 func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { 301 299 uri := r.URL.Query().Get("uri") 302 300 if uri == "" { 303 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 301 + WriteBadRequest(w, "uri query parameter required") 304 302 return 305 303 } 306 304 307 305 session, err := s.refresher.GetSessionWithAutoRefresh(r) 308 306 if err != nil { 309 - http.Error(w, err.Error(), http.StatusUnauthorized) 307 + WriteUnauthorized(w, err.Error()) 310 308 return 311 309 } 312 310 313 311 annotation, err := s.db.GetAnnotationByURI(uri) 314 312 if err != nil || annotation == nil { 315 - http.Error(w, "Annotation not found", http.StatusNotFound) 313 + WriteNotFound(w, "Annotation not found") 316 314 return 317 315 } 318 316 319 317 if annotation.AuthorDID != session.DID { 320 - http.Error(w, "Not authorized to edit this annotation", http.StatusForbidden) 318 + WriteForbidden(w, "Not authorized to edit this annotation") 321 319 return 322 320 } 323 321 324 322 var req UpdateAnnotationRequest 325 323 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 326 - http.Error(w, "Invalid request body", http.StatusBadRequest) 324 + WriteBadRequest(w, "Invalid request body") 327 325 return 328 326 } 329 327 330 328 parts := parseATURI(uri) 331 329 if len(parts) < 3 { 332 - http.Error(w, "Invalid URI format", http.StatusBadRequest) 330 + WriteBadRequest(w, "Invalid URI format") 333 331 return 334 332 } 335 333 rkey := parts[2] ··· 420 418 logger.Error("Warning: failed to sync self-labels: %v", err) 421 419 } 422 420 423 - w.Header().Set("Content-Type", "application/json") 424 - json.NewEncoder(w).Encode(map[string]interface{}{ 421 + WriteSuccess(w, map[string]interface{}{ 425 422 "success": true, 426 423 "uri": result.URI, 427 424 "cid": result.CID, ··· 444 441 func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { 445 442 session, err := s.refresher.GetSessionWithAutoRefresh(r) 446 443 if err != nil { 447 - http.Error(w, err.Error(), http.StatusUnauthorized) 444 + WriteUnauthorized(w, err.Error()) 448 445 return 449 446 } 450 447 451 448 var req CreateLikeRequest 452 449 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 453 - http.Error(w, "Invalid request body", http.StatusBadRequest) 450 + WriteBadRequest(w, "Invalid request body") 454 451 return 455 452 } 456 453 457 454 if req.SubjectURI == "" || req.SubjectCID == "" { 458 - http.Error(w, "subjectUri and subjectCid are required", http.StatusBadRequest) 455 + WriteBadRequest(w, "subjectUri and subjectCid are required") 459 456 return 460 457 } 461 458 ··· 469 466 record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID) 470 467 471 468 if err := record.Validate(); err != nil { 472 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 469 + WriteBadRequest(w, "Validation error: "+err.Error()) 473 470 return 474 471 } 475 472 ··· 504 501 }) 505 502 } 506 503 507 - w.Header().Set("Content-Type", "application/json") 508 - json.NewEncoder(w).Encode(map[string]string{"uri": result.URI}) 504 + WriteSuccess(w, map[string]string{"uri": result.URI}) 509 505 } 510 506 511 507 func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { 512 508 session, err := s.refresher.GetSessionWithAutoRefresh(r) 513 509 if err != nil { 514 - http.Error(w, err.Error(), http.StatusUnauthorized) 510 + WriteUnauthorized(w, err.Error()) 515 511 return 516 512 } 517 513 518 514 subjectURI := r.URL.Query().Get("uri") 519 515 if subjectURI == "" { 520 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 516 + WriteBadRequest(w, "uri query parameter required") 521 517 return 522 518 } 523 519 524 520 userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI) 525 521 if err != nil { 526 - http.Error(w, "Like not found", http.StatusNotFound) 522 + WriteNotFound(w, "Like not found") 527 523 return 528 524 } 529 525 ··· 540 536 541 537 s.db.DeleteLike(userLike.URI) 542 538 543 - w.Header().Set("Content-Type", "application/json") 544 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 539 + WriteSuccess(w, map[string]bool{"success": true}) 545 540 } 546 541 547 542 type CreateReplyRequest struct { ··· 555 550 func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) { 556 551 session, err := s.refresher.GetSessionWithAutoRefresh(r) 557 552 if err != nil { 558 - http.Error(w, err.Error(), http.StatusUnauthorized) 553 + WriteUnauthorized(w, err.Error()) 559 554 return 560 555 } 561 556 562 557 var req CreateReplyRequest 563 558 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 564 - http.Error(w, "Invalid request body", http.StatusBadRequest) 559 + WriteBadRequest(w, "Invalid request body") 565 560 return 566 561 } 567 562 568 563 if req.ParentURI == "" || req.ParentCID == "" { 569 - http.Error(w, "parentUri and parentCid are required", http.StatusBadRequest) 564 + WriteBadRequest(w, "parentUri and parentCid are required") 570 565 return 571 566 } 572 567 if req.RootURI == "" || req.RootCID == "" { 573 - http.Error(w, "rootUri and rootCid are required", http.StatusBadRequest) 568 + WriteBadRequest(w, "rootUri and rootCid are required") 574 569 return 575 570 } 576 571 if req.Text == "" { 577 - http.Error(w, "text is required", http.StatusBadRequest) 572 + WriteBadRequest(w, "text is required") 578 573 return 579 574 } 580 575 581 576 record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text) 582 577 583 578 if err := record.Validate(); err != nil { 584 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 579 + WriteBadRequest(w, "Validation error: "+err.Error()) 585 580 return 586 581 } 587 582 ··· 633 628 } 634 629 } 635 630 636 - w.Header().Set("Content-Type", "application/json") 637 - json.NewEncoder(w).Encode(map[string]string{"uri": result.URI}) 631 + WriteSuccess(w, map[string]string{"uri": result.URI}) 638 632 } 639 633 640 634 func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) { 641 635 uri := r.URL.Query().Get("uri") 642 636 if uri == "" { 643 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 637 + WriteBadRequest(w, "uri query parameter required") 644 638 return 645 639 } 646 640 647 641 session, err := s.refresher.GetSessionWithAutoRefresh(r) 648 642 if err != nil { 649 - http.Error(w, err.Error(), http.StatusUnauthorized) 643 + WriteUnauthorized(w, err.Error()) 650 644 return 651 645 } 652 646 653 647 reply, err := s.db.GetReplyByURI(uri) 654 648 if err != nil || reply == nil { 655 - http.Error(w, "reply not found", http.StatusNotFound) 649 + WriteNotFound(w, "reply not found") 656 650 return 657 651 } 658 652 659 653 if reply.AuthorDID != session.DID { 660 - http.Error(w, "not authorized to delete this reply", http.StatusForbidden) 654 + WriteForbidden(w, "not authorized to delete this reply") 661 655 return 662 656 } 663 657 ··· 671 665 672 666 s.db.DeleteReply(uri) 673 667 674 - w.Header().Set("Content-Type", "application/json") 675 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 668 + WriteSuccess(w, map[string]bool{"success": true}) 676 669 } 677 670 678 671 type CreateHighlightRequest struct { ··· 687 680 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { 688 681 session, err := s.refresher.GetSessionWithAutoRefresh(r) 689 682 if err != nil { 690 - http.Error(w, err.Error(), http.StatusUnauthorized) 683 + WriteUnauthorized(w, err.Error()) 691 684 return 692 685 } 693 686 694 687 var req CreateHighlightRequest 695 688 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 696 - http.Error(w, "Invalid request body", http.StatusBadRequest) 689 + WriteBadRequest(w, "Invalid request body") 697 690 return 698 691 } 699 692 700 693 if req.URL == "" || req.Selector == nil { 701 - http.Error(w, "URL and selector are required", http.StatusBadRequest) 694 + WriteBadRequest(w, "URL and selector are required") 702 695 return 703 696 } 704 697 ··· 719 712 record.Labels = xrpc.NewSelfLabels(validLabels) 720 713 721 714 if err := record.Validate(); err != nil { 722 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 715 + WriteBadRequest(w, "Validation error: "+err.Error()) 723 716 return 724 717 } 725 718 ··· 779 772 CID: &cid, 780 773 } 781 774 if err := s.db.CreateHighlight(highlight); err != nil { 782 - http.Error(w, "Failed to index highlight", http.StatusInternalServerError) 775 + WriteInternalError(w, "Failed to index highlight") 783 776 return 784 777 } 785 778 ··· 789 782 } 790 783 } 791 784 792 - w.Header().Set("Content-Type", "application/json") 793 - json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 785 + WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 794 786 } 795 787 796 788 type CreateBookmarkRequest struct { ··· 803 795 func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { 804 796 session, err := s.refresher.GetSessionWithAutoRefresh(r) 805 797 if err != nil { 806 - http.Error(w, err.Error(), http.StatusUnauthorized) 798 + WriteUnauthorized(w, err.Error()) 807 799 return 808 800 } 809 801 810 802 var req CreateBookmarkRequest 811 803 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 812 - http.Error(w, "Invalid request body", http.StatusBadRequest) 804 + WriteBadRequest(w, "Invalid request body") 813 805 return 814 806 } 815 807 816 808 if req.URL == "" { 817 - http.Error(w, "URL is required", http.StatusBadRequest) 809 + WriteBadRequest(w, "URL is required") 818 810 return 819 811 } 820 812 ··· 829 821 } 830 822 831 823 if err := record.Validate(); err != nil { 832 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 824 + WriteBadRequest(w, "Validation error: "+err.Error()) 833 825 return 834 826 } 835 827 836 828 var result *xrpc.CreateRecordOutput 837 829 838 830 if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 839 - http.Error(w, "Bookmark already exists", http.StatusConflict) 831 + WriteConflict(w, "Bookmark already exists") 840 832 return 841 833 } 842 834 ··· 881 873 } 882 874 s.db.CreateBookmark(bookmark) 883 875 884 - w.Header().Set("Content-Type", "application/json") 885 - json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 876 + WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 886 877 } 887 878 888 879 func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { 889 880 session, err := s.refresher.GetSessionWithAutoRefresh(r) 890 881 if err != nil { 891 - http.Error(w, err.Error(), http.StatusUnauthorized) 882 + WriteUnauthorized(w, err.Error()) 892 883 return 893 884 } 894 885 895 886 rkey := r.URL.Query().Get("rkey") 896 887 if rkey == "" { 897 - http.Error(w, "rkey required", http.StatusBadRequest) 888 + WriteBadRequest(w, "rkey required") 898 889 return 899 890 } 900 891 ··· 908 899 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey 909 900 s.db.DeleteHighlight(uri) 910 901 911 - w.Header().Set("Content-Type", "application/json") 912 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 902 + WriteSuccess(w, map[string]bool{"success": true}) 913 903 } 914 904 915 905 func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { 916 906 session, err := s.refresher.GetSessionWithAutoRefresh(r) 917 907 if err != nil { 918 - http.Error(w, err.Error(), http.StatusUnauthorized) 908 + WriteUnauthorized(w, err.Error()) 919 909 return 920 910 } 921 911 922 912 rkey := r.URL.Query().Get("rkey") 923 913 if rkey == "" { 924 - http.Error(w, "rkey required", http.StatusBadRequest) 914 + WriteBadRequest(w, "rkey required") 925 915 return 926 916 } 927 917 ··· 935 925 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey 936 926 s.db.DeleteBookmark(uri) 937 927 938 - w.Header().Set("Content-Type", "application/json") 939 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 928 + WriteSuccess(w, map[string]bool{"success": true}) 940 929 } 941 930 942 931 type UpdateHighlightRequest struct { ··· 948 937 func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { 949 938 uri := r.URL.Query().Get("uri") 950 939 if uri == "" { 951 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 940 + WriteBadRequest(w, "uri query parameter required") 952 941 return 953 942 } 954 943 955 944 session, err := s.refresher.GetSessionWithAutoRefresh(r) 956 945 if err != nil { 957 - http.Error(w, err.Error(), http.StatusUnauthorized) 946 + WriteUnauthorized(w, err.Error()) 958 947 return 959 948 } 960 949 961 950 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 962 - http.Error(w, "Not authorized", http.StatusForbidden) 951 + WriteForbidden(w, "Not authorized") 963 952 return 964 953 } 965 954 966 955 var req UpdateHighlightRequest 967 956 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 968 - http.Error(w, "Invalid request body", http.StatusBadRequest) 957 + WriteBadRequest(w, "Invalid request body") 969 958 return 970 959 } 971 960 972 961 parts := parseATURI(uri) 973 962 if len(parts) < 3 { 974 - http.Error(w, "Invalid URI", http.StatusBadRequest) 963 + WriteBadRequest(w, "Invalid URI") 975 964 return 976 965 } 977 966 rkey := parts[2] ··· 1043 1032 logger.Error("Warning: failed to sync self-labels: %v", err) 1044 1033 } 1045 1034 1046 - w.Header().Set("Content-Type", "application/json") 1047 - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1035 + WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1048 1036 } 1049 1037 1050 1038 type UpdateBookmarkRequest struct { ··· 1057 1045 func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { 1058 1046 uri := r.URL.Query().Get("uri") 1059 1047 if uri == "" { 1060 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 1048 + WriteBadRequest(w, "uri query parameter required") 1061 1049 return 1062 1050 } 1063 1051 1064 1052 session, err := s.refresher.GetSessionWithAutoRefresh(r) 1065 1053 if err != nil { 1066 - http.Error(w, err.Error(), http.StatusUnauthorized) 1054 + WriteUnauthorized(w, err.Error()) 1067 1055 return 1068 1056 } 1069 1057 1070 1058 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 1071 - http.Error(w, "Not authorized", http.StatusForbidden) 1059 + WriteForbidden(w, "Not authorized") 1072 1060 return 1073 1061 } 1074 1062 1075 1063 var req UpdateBookmarkRequest 1076 1064 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1077 - http.Error(w, "Invalid request body", http.StatusBadRequest) 1065 + WriteBadRequest(w, "Invalid request body") 1078 1066 return 1079 1067 } 1080 1068 1081 1069 parts := parseATURI(uri) 1082 1070 if len(parts) < 3 { 1083 - http.Error(w, "Invalid URI", http.StatusBadRequest) 1071 + WriteBadRequest(w, "Invalid URI") 1084 1072 return 1085 1073 } 1086 1074 rkey := parts[2] ··· 1155 1143 logger.Error("Warning: failed to sync self-labels: %v", err) 1156 1144 } 1157 1145 1158 - w.Header().Set("Content-Type", "application/json") 1159 - json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1146 + WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1160 1147 }
+35 -41
backend/internal/api/apikey.go
··· 42 42 func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) { 43 43 session, err := h.refresher.GetSessionWithAutoRefresh(r) 44 44 if err != nil { 45 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 45 + WriteUnauthorized(w, "Unauthorized") 46 46 return 47 47 } 48 48 49 49 var req CreateKeyRequest 50 50 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 51 - http.Error(w, "Invalid request body", http.StatusBadRequest) 51 + WriteBadRequest(w, "Invalid request body") 52 52 return 53 53 } 54 54 ··· 62 62 63 63 record := xrpc.NewAPIKeyRecord(req.Name, keyHash) 64 64 if err := record.Validate(); err != nil { 65 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 65 + WriteBadRequest(w, "Validation error: "+err.Error()) 66 66 return 67 67 } 68 68 ··· 74 74 }) 75 75 if err != nil { 76 76 logger.Error("[ERROR] Failed to create API key record on PDS: %v", err) 77 - http.Error(w, "Failed to create key record: "+err.Error(), http.StatusInternalServerError) 77 + WriteInternalError(w, "Failed to create key record") 78 78 return 79 79 } 80 80 ··· 93 93 94 94 if err := h.db.CreateAPIKey(apiKey); err != nil { 95 95 logger.Error("[ERROR] Failed to insert API key into DB: %v", err) 96 - http.Error(w, "Failed to create key", http.StatusInternalServerError) 96 + WriteInternalError(w, "Failed to create key") 97 97 return 98 98 } 99 99 100 - w.Header().Set("Content-Type", "application/json") 101 - json.NewEncoder(w).Encode(CreateKeyResponse{ 100 + WriteSuccess(w, CreateKeyResponse{ 102 101 ID: keyID, 103 102 Name: req.Name, 104 103 Key: rawKey, ··· 109 108 func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) { 110 109 session, err := h.refresher.GetSessionWithAutoRefresh(r) 111 110 if err != nil { 112 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 111 + WriteUnauthorized(w, "Unauthorized") 113 112 return 114 113 } 115 114 116 115 keys, err := h.db.GetAPIKeysByOwner(session.DID) 117 116 if err != nil { 118 - http.Error(w, "Failed to get keys", http.StatusInternalServerError) 117 + WriteInternalError(w, "Failed to get keys") 119 118 return 120 119 } 121 120 ··· 123 122 keys = []db.APIKey{} 124 123 } 125 124 126 - w.Header().Set("Content-Type", "application/json") 127 - json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) 125 + WriteSuccess(w, map[string]interface{}{"keys": keys}) 128 126 } 129 127 130 128 func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { 131 129 session, err := h.refresher.GetSessionWithAutoRefresh(r) 132 130 if err != nil { 133 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 131 + WriteUnauthorized(w, "Unauthorized") 134 132 return 135 133 } 136 134 137 135 keyID := chi.URLParam(r, "id") 138 136 if keyID == "" { 139 - http.Error(w, "Key ID required", http.StatusBadRequest) 137 + WriteBadRequest(w, "Key ID required") 140 138 return 141 139 } 142 140 143 141 uri, err := h.db.DeleteAPIKey(keyID, session.DID) 144 142 if err != nil { 145 - http.Error(w, "Failed to delete key", http.StatusInternalServerError) 143 + WriteInternalError(w, "Failed to delete key") 146 144 return 147 145 } 148 146 ··· 152 150 }) 153 151 } 154 152 155 - w.Header().Set("Content-Type", "application/json") 156 - json.NewEncoder(w).Encode(map[string]bool{"success": true}) 153 + WriteSuccess(w, map[string]bool{"success": true}) 157 154 } 158 155 159 156 type QuickBookmarkRequest struct { ··· 165 162 func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { 166 163 apiKey, err := h.authenticateAPIKey(r) 167 164 if err != nil { 168 - http.Error(w, err.Error(), http.StatusUnauthorized) 165 + WriteUnauthorized(w, err.Error()) 169 166 return 170 167 } 171 168 172 169 var req QuickBookmarkRequest 173 170 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 174 - http.Error(w, "Invalid request body", http.StatusBadRequest) 171 + WriteBadRequest(w, "Invalid request body") 175 172 return 176 173 } 177 174 178 175 if req.URL == "" { 179 - http.Error(w, "URL is required", http.StatusBadRequest) 176 + WriteBadRequest(w, "URL is required") 180 177 return 181 178 } 182 179 183 180 session, err := h.getSessionByDID(apiKey.OwnerDID) 184 181 if err != nil { 185 - http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 182 + WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 186 183 return 187 184 } 188 185 ··· 190 187 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 191 188 192 189 if err := record.Validate(); err != nil { 193 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 190 + WriteBadRequest(w, "Validation error: "+err.Error()) 194 191 return 195 192 } 196 193 ··· 201 198 return createErr 202 199 }) 203 200 if err != nil { 204 - http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 201 + WriteInternalError(w, "Failed to create bookmark") 205 202 return 206 203 } 207 204 ··· 229 226 } 230 227 h.db.CreateBookmark(bookmark) 231 228 232 - w.Header().Set("Content-Type", "application/json") 233 - json.NewEncoder(w).Encode(map[string]string{ 229 + WriteSuccess(w, map[string]string{ 234 230 "uri": result.URI, 235 231 "cid": result.CID, 236 232 "message": "Bookmark created successfully", ··· 247 243 func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { 248 244 apiKey, err := h.authenticateAPIKey(r) 249 245 if err != nil { 250 - http.Error(w, err.Error(), http.StatusUnauthorized) 246 + WriteUnauthorized(w, err.Error()) 251 247 return 252 248 } 253 249 254 250 var req QuickSaveRequest 255 251 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 256 - http.Error(w, "Invalid request body", http.StatusBadRequest) 252 + WriteBadRequest(w, "Invalid request body") 257 253 return 258 254 } 259 255 260 256 if req.URL == "" { 261 - http.Error(w, "URL is required", http.StatusBadRequest) 257 + WriteBadRequest(w, "URL is required") 262 258 return 263 259 } 264 260 265 261 session, err := h.getSessionByDID(apiKey.OwnerDID) 266 262 if err != nil { 267 - http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 263 + WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 268 264 return 269 265 } 270 266 ··· 286 282 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 287 283 288 284 if err := record.Validate(); err != nil { 289 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 285 + WriteBadRequest(w, "Validation error: "+err.Error()) 290 286 return 291 287 } 292 288 ··· 322 318 record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, req.Selector, "") 323 319 324 320 if err := record.Validate(); err != nil { 325 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 321 + WriteBadRequest(w, "Validation error: "+err.Error()) 326 322 return 327 323 } 328 324 ··· 365 361 } 366 362 367 363 if err != nil { 368 - http.Error(w, "Failed to create record: "+err.Error(), http.StatusInternalServerError) 364 + WriteInternalError(w, "Failed to create record") 369 365 return 370 366 } 371 367 372 - w.Header().Set("Content-Type", "application/json") 373 - json.NewEncoder(w).Encode(map[string]string{ 368 + WriteSuccess(w, map[string]string{ 374 369 "uri": result.URI, 375 370 "cid": result.CID, 376 371 "message": "Saved successfully", ··· 386 381 func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { 387 382 apiKey, err := h.authenticateAPIKey(r) 388 383 if err != nil { 389 - http.Error(w, err.Error(), http.StatusUnauthorized) 384 + WriteUnauthorized(w, err.Error()) 390 385 return 391 386 } 392 387 393 388 var req QuickHighlightRequest 394 389 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 395 - http.Error(w, "Invalid request body", http.StatusBadRequest) 390 + WriteBadRequest(w, "Invalid request body") 396 391 return 397 392 } 398 393 399 394 if req.URL == "" || req.Selector == nil { 400 - http.Error(w, "URL and selector are required", http.StatusBadRequest) 395 + WriteBadRequest(w, "URL and selector are required") 401 396 return 402 397 } 403 398 404 399 session, err := h.getSessionByDID(apiKey.OwnerDID) 405 400 if err != nil { 406 - http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 401 + WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 407 402 return 408 403 } 409 404 ··· 416 411 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 417 412 418 413 if err := record.Validate(); err != nil { 419 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 414 + WriteBadRequest(w, "Validation error: "+err.Error()) 420 415 return 421 416 } 422 417 ··· 427 422 return createErr 428 423 }) 429 424 if err != nil { 430 - http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 425 + WriteInternalError(w, "Failed to create highlight") 431 426 return 432 427 } 433 428 ··· 452 447 fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 453 448 } 454 449 455 - w.Header().Set("Content-Type", "application/json") 456 - json.NewEncoder(w).Encode(map[string]string{ 450 + WriteSuccess(w, map[string]string{ 457 451 "uri": result.URI, 458 452 "cid": result.CID, 459 453 "message": "Highlight created successfully",
+3 -1
backend/internal/api/avatar.go
··· 15 15 func (h *Handler) HandleAvatarProxy(w http.ResponseWriter, r *http.Request) { 16 16 did := chi.URLParam(r, "did") 17 17 if did == "" { 18 - http.Error(w, "DID required", http.StatusBadRequest) 18 + WriteBadRequest(w, "DID required") 19 19 return 20 20 } 21 21 ··· 27 27 if cdnURL == "" { 28 28 cdnURL = "https://avatars.margin.at" 29 29 } 30 + 31 + w.Header().Set("Cache-Control", "public, max-age=86400") 30 32 31 33 secret := os.Getenv("AVATAR_SHARED_SECRET") 32 34 if secret != "" {
+38 -45
backend/internal/api/collections.go
··· 39 39 func (s *CollectionService) CreateCollection(w http.ResponseWriter, r *http.Request) { 40 40 session, err := s.refresher.GetSessionWithAutoRefresh(r) 41 41 if err != nil { 42 - http.Error(w, err.Error(), http.StatusUnauthorized) 42 + WriteUnauthorized(w, err.Error()) 43 43 return 44 44 } 45 45 46 46 var req CreateCollectionRequest 47 47 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 48 - http.Error(w, "Invalid request body", http.StatusBadRequest) 48 + WriteBadRequest(w, "Invalid request body") 49 49 return 50 50 } 51 51 52 52 if req.Name == "" { 53 - http.Error(w, "Name is required", http.StatusBadRequest) 53 + WriteBadRequest(w, "Name is required") 54 54 return 55 55 } 56 56 57 57 record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 58 58 59 59 if err := record.Validate(); err != nil { 60 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 60 + WriteBadRequest(w, "Validation error: "+err.Error()) 61 61 return 62 62 } 63 63 ··· 68 68 return createErr 69 69 }) 70 70 if err != nil { 71 - http.Error(w, "Failed to create collection: "+err.Error(), http.StatusInternalServerError) 71 + WriteInternalError(w, "Failed to create collection") 72 72 return 73 73 } 74 74 ··· 91 91 } 92 92 s.db.CreateCollection(collection) 93 93 94 - w.Header().Set("Content-Type", "application/json") 95 - json.NewEncoder(w).Encode(result) 94 + WriteSuccess(w, result) 96 95 } 97 96 98 97 func (s *CollectionService) AddCollectionItem(w http.ResponseWriter, r *http.Request) { 99 98 collectionURIRaw := chi.URLParam(r, "collection") 100 99 if collectionURIRaw == "" { 101 - http.Error(w, "Collection URI required", http.StatusBadRequest) 100 + WriteBadRequest(w, "Collection URI required") 102 101 return 103 102 } 104 103 ··· 106 105 107 106 session, err := s.refresher.GetSessionWithAutoRefresh(r) 108 107 if err != nil { 109 - http.Error(w, err.Error(), http.StatusUnauthorized) 108 + WriteUnauthorized(w, err.Error()) 110 109 return 111 110 } 112 111 113 112 var req AddCollectionItemRequest 114 113 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 115 - http.Error(w, "Invalid request body", http.StatusBadRequest) 114 + WriteBadRequest(w, "Invalid request body") 116 115 return 117 116 } 118 117 119 118 if req.AnnotationURI == "" { 120 - http.Error(w, "Annotation URI required", http.StatusBadRequest) 119 + WriteBadRequest(w, "Annotation URI required") 121 120 return 122 121 } 123 122 124 123 record := xrpc.NewCollectionItemRecord(collectionURI, req.AnnotationURI, req.Position) 125 124 126 125 if err := record.Validate(); err != nil { 127 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 126 + WriteBadRequest(w, "Validation error: "+err.Error()) 128 127 return 129 128 } 130 129 ··· 135 134 return createErr 136 135 }) 137 136 if err != nil { 138 - http.Error(w, "Failed to add item: "+err.Error(), http.StatusInternalServerError) 137 + WriteInternalError(w, "Failed to add item") 139 138 return 140 139 } 141 140 ··· 153 152 logger.Error("Failed to add to collection in DB: %v", err) 154 153 } 155 154 156 - w.Header().Set("Content-Type", "application/json") 157 - json.NewEncoder(w).Encode(result) 155 + WriteSuccess(w, result) 158 156 } 159 157 160 158 func (s *CollectionService) RemoveCollectionItem(w http.ResponseWriter, r *http.Request) { 161 159 itemURI := r.URL.Query().Get("uri") 162 160 if itemURI == "" { 163 - http.Error(w, "Item URI required", http.StatusBadRequest) 161 + WriteBadRequest(w, "Item URI required") 164 162 return 165 163 } 166 164 167 165 session, err := s.refresher.GetSessionWithAutoRefresh(r) 168 166 if err != nil { 169 - http.Error(w, err.Error(), http.StatusUnauthorized) 167 + WriteUnauthorized(w, err.Error()) 170 168 return 171 169 } 172 170 ··· 187 185 func (s *CollectionService) GetAnnotationCollections(w http.ResponseWriter, r *http.Request) { 188 186 annotationURI := r.URL.Query().Get("uri") 189 187 if annotationURI == "" { 190 - http.Error(w, "uri parameter required", http.StatusBadRequest) 188 + WriteBadRequest(w, "uri parameter required") 191 189 return 192 190 } 193 191 194 192 uris, err := s.db.GetCollectionURIsForAnnotation(annotationURI) 195 193 if err != nil { 196 - http.Error(w, err.Error(), http.StatusInternalServerError) 194 + WriteInternalError(w, "Internal server error") 197 195 return 198 196 } 199 197 ··· 201 199 uris = []string{} 202 200 } 203 201 204 - w.Header().Set("Content-Type", "application/json") 205 - json.NewEncoder(w).Encode(uris) 202 + WriteSuccess(w, uris) 206 203 } 207 204 208 205 func (s *CollectionService) GetCollections(w http.ResponseWriter, r *http.Request) { ··· 215 212 } 216 213 217 214 if authorDID == "" { 218 - http.Error(w, "Author DID required", http.StatusBadRequest) 215 + WriteBadRequest(w, "Author DID required") 219 216 return 220 217 } 221 218 222 219 collections, err := s.db.GetCollectionsByAuthor(authorDID) 223 220 if err != nil { 224 - http.Error(w, err.Error(), http.StatusInternalServerError) 221 + WriteInternalError(w, "Internal server error") 225 222 return 226 223 } 227 224 ··· 256 253 } 257 254 } 258 255 259 - w.Header().Set("Content-Type", "application/json") 260 - json.NewEncoder(w).Encode(map[string]interface{}{ 256 + WriteSuccess(w, map[string]interface{}{ 261 257 "@context": "http://www.w3.org/ns/anno.jsonld", 262 258 "type": "Collection", 263 259 "items": apiCollections, ··· 285 281 } 286 282 287 283 if collectionURI == "" { 288 - http.Error(w, "Collection URI required", http.StatusBadRequest) 284 + WriteBadRequest(w, "Collection URI required") 289 285 return 290 286 } 291 287 292 288 items, err := s.db.GetCollectionItems(collectionURI) 293 289 if err != nil { 294 - http.Error(w, err.Error(), http.StatusInternalServerError) 290 + WriteInternalError(w, "Internal server error") 295 291 return 296 292 } 297 293 ··· 320 316 enrichedItems = []APICollectionItem{} 321 317 } 322 318 323 - w.Header().Set("Content-Type", "application/json") 324 - json.NewEncoder(w).Encode(enrichedItems) 319 + WriteSuccess(w, enrichedItems) 325 320 } 326 321 327 322 type UpdateCollectionRequest struct { ··· 333 328 func (s *CollectionService) UpdateCollection(w http.ResponseWriter, r *http.Request) { 334 329 uri := r.URL.Query().Get("uri") 335 330 if uri == "" { 336 - http.Error(w, "URI required", http.StatusBadRequest) 331 + WriteBadRequest(w, "URI required") 337 332 return 338 333 } 339 334 340 335 session, err := s.refresher.GetSessionWithAutoRefresh(r) 341 336 if err != nil { 342 - http.Error(w, err.Error(), http.StatusUnauthorized) 337 + WriteUnauthorized(w, err.Error()) 343 338 return 344 339 } 345 340 346 341 if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 347 - http.Error(w, "Not authorized to update this collection", http.StatusForbidden) 342 + WriteForbidden(w, "Not authorized to update this collection") 348 343 return 349 344 } 350 345 351 346 var req UpdateCollectionRequest 352 347 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 353 - http.Error(w, "Invalid request body", http.StatusBadRequest) 348 + WriteBadRequest(w, "Invalid request body") 354 349 return 355 350 } 356 351 357 352 if req.Name == "" { 358 - http.Error(w, "Name is required", http.StatusBadRequest) 353 + WriteBadRequest(w, "Name is required") 359 354 return 360 355 } 361 356 362 357 record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 363 358 364 359 if err := record.Validate(); err != nil { 365 - http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 360 + WriteBadRequest(w, "Validation error: "+err.Error()) 366 361 return 367 362 } 368 363 ··· 382 377 }) 383 378 384 379 if err != nil { 385 - http.Error(w, "Failed to update collection: "+err.Error(), http.StatusInternalServerError) 380 + WriteInternalError(w, "Failed to update collection") 386 381 return 387 382 } 388 383 ··· 405 400 } 406 401 s.db.CreateCollection(collection) 407 402 408 - w.Header().Set("Content-Type", "application/json") 409 - json.NewEncoder(w).Encode(result) 403 + WriteSuccess(w, result) 410 404 } 411 405 412 406 func (s *CollectionService) DeleteCollection(w http.ResponseWriter, r *http.Request) { 413 407 uri := r.URL.Query().Get("uri") 414 408 if uri == "" { 415 - http.Error(w, "URI required", http.StatusBadRequest) 409 + WriteBadRequest(w, "URI required") 416 410 return 417 411 } 418 412 419 413 session, err := s.refresher.GetSessionWithAutoRefresh(r) 420 414 if err != nil { 421 - http.Error(w, err.Error(), http.StatusUnauthorized) 415 + WriteUnauthorized(w, err.Error()) 422 416 return 423 417 } 424 418 425 419 if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 426 - http.Error(w, "Not authorized to delete this collection", http.StatusForbidden) 420 + WriteForbidden(w, "Not authorized to delete this collection") 427 421 return 428 422 } 429 423 ··· 452 446 func (s *CollectionService) GetCollection(w http.ResponseWriter, r *http.Request) { 453 447 uri := r.URL.Query().Get("uri") 454 448 if uri == "" { 455 - http.Error(w, "URI required", http.StatusBadRequest) 449 + WriteBadRequest(w, "URI required") 456 450 return 457 451 } 458 452 ··· 472 466 } 473 467 474 468 if err != nil || collection == nil { 475 - http.Error(w, "Collection not found", http.StatusNotFound) 469 + WriteNotFound(w, "Collection not found") 476 470 return 477 471 } 478 472 ··· 501 495 ItemsCount: itemCounts[collection.URI], 502 496 } 503 497 504 - w.Header().Set("Content-Type", "application/json") 505 - json.NewEncoder(w).Encode(apiCollection) 498 + WriteSuccess(w, apiCollection) 506 499 }
+96 -87
backend/internal/api/handler.go
··· 105 105 func (h *Handler) RegisterRoutes(r chi.Router) { 106 106 r.Get("/health", h.Health) 107 107 108 + collectionService := NewCollectionService(h.db, h.refresher) 109 + 108 110 r.Route("/api", func(r chi.Router) { 111 + // Annotations 109 112 r.Get("/annotations", h.GetAnnotations) 110 113 r.Get("/annotations/feed", h.GetFeed) 111 114 r.Get("/annotation", h.GetAnnotation) 112 115 r.Get("/annotations/history", h.GetEditHistory) 116 + r.Post("/annotations", h.annotationService.CreateAnnotation) 113 117 r.Put("/annotations", h.annotationService.UpdateAnnotation) 118 + r.Delete("/annotations", h.annotationService.DeleteAnnotation) 119 + r.Post("/annotations/like", h.annotationService.LikeAnnotation) 120 + r.Delete("/annotations/like", h.annotationService.UnlikeAnnotation) 121 + r.Post("/annotations/reply", h.annotationService.CreateReply) 122 + r.Delete("/annotations/reply", h.annotationService.DeleteReply) 123 + r.Get("/replies", h.GetReplies) 124 + r.Get("/likes", h.GetLikeCount) 114 125 126 + // Highlights 115 127 r.Get("/highlights", h.GetHighlights) 128 + r.Post("/highlights", h.annotationService.CreateHighlight) 116 129 r.Put("/highlights", h.annotationService.UpdateHighlight) 130 + r.Delete("/highlights", h.annotationService.DeleteHighlight) 117 131 132 + // Bookmarks 118 133 r.Get("/bookmarks", h.GetBookmarks) 119 134 r.Post("/bookmarks", h.annotationService.CreateBookmark) 120 135 r.Put("/bookmarks", h.annotationService.UpdateBookmark) 136 + r.Delete("/bookmarks", h.annotationService.DeleteBookmark) 121 137 122 - collectionService := NewCollectionService(h.db, h.refresher) 138 + // Collections 123 139 r.Post("/collections", collectionService.CreateCollection) 124 140 r.Get("/collections", collectionService.GetCollections) 125 141 r.Put("/collections", collectionService.UpdateCollection) ··· 129 145 r.Delete("/collections/items", collectionService.RemoveCollectionItem) 130 146 r.Get("/collections/containing", collectionService.GetAnnotationCollections) 131 147 r.Get("/collection", collectionService.GetCollection) 132 - r.Post("/sync", h.SyncAll) 133 148 149 + // Targets & discovery 134 150 r.Get("/targets", h.GetByTarget) 135 151 r.Get("/discover", h.DiscoverForURL) 152 + r.Get("/url-metadata", h.GetURLMetadata) 136 153 154 + // User content 137 155 r.Get("/users/{did}/annotations", h.GetUserAnnotations) 138 156 r.Get("/users/{did}/highlights", h.GetUserHighlights) 139 157 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 140 158 r.Get("/users/{did}/targets", h.GetUserTargetItems) 141 159 r.Get("/users/{did}/tags", h.HandleGetUserTags) 142 160 143 - r.Get("/trending-tags", h.HandleGetTrendingTags) 161 + // Profile 162 + r.Get("/profile/{did}", h.GetProfile) 163 + r.Put("/profile", h.UpdateProfile) 164 + r.Post("/profile/avatar", h.UploadAvatar) 165 + r.Get("/avatar/{did}", h.HandleAvatarProxy) 166 + 167 + // Tags & search 168 + r.Get("/tags/trending", h.HandleGetTrendingTags) 169 + r.Get("/trending-tags", h.HandleGetTrendingTags) // legacy alias 144 170 r.Get("/search", h.Search) 145 171 r.Get("/recommendations", h.GetRecommendations) 146 172 r.Get("/documents", h.GetDocuments) 147 - r.Post("/admin/backfill", h.AdminBackfill) 148 173 149 - r.Get("/replies", h.GetReplies) 150 - r.Get("/likes", h.GetLikeCount) 151 - r.Get("/url-metadata", h.GetURLMetadata) 174 + // Notifications 152 175 r.Get("/notifications", h.GetNotifications) 153 176 r.Get("/notifications/count", h.GetUnreadNotificationCount) 154 177 r.Post("/notifications/read", h.MarkNotificationsRead) 155 - r.Get("/avatar/{did}", h.HandleAvatarProxy) 178 + 179 + // Preferences & sync 180 + r.Get("/preferences", h.GetPreferences) 181 + r.Put("/preferences", h.UpdatePreferences) 182 + r.Post("/sync", h.SyncAll) 156 183 184 + // API keys 157 185 r.Post("/keys", h.apiKeys.CreateKey) 158 186 r.Get("/keys", h.apiKeys.ListKeys) 159 187 r.Delete("/keys/{id}", h.apiKeys.DeleteKey) 160 - 161 188 r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 162 189 r.Post("/quick/save", h.apiKeys.QuickSave) 163 190 164 - r.Get("/preferences", h.GetPreferences) 165 - r.Put("/preferences", h.UpdatePreferences) 166 - 191 + // Moderation 167 192 r.Post("/moderation/block", h.moderation.BlockUser) 168 193 r.Delete("/moderation/block", h.moderation.UnblockUser) 169 194 r.Get("/moderation/blocks", h.moderation.GetBlocks) ··· 180 205 r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel) 181 206 r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels) 182 207 r.Get("/moderation/labeler", h.moderation.GetLabelerInfo) 208 + 209 + // Admin 210 + r.Post("/admin/backfill", h.AdminBackfill) 183 211 }) 184 212 } 185 213 186 214 func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { 187 - w.Header().Set("Content-Type", "application/json") 188 - json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": "1.0"}) 215 + w.Header().Set("Cache-Control", "no-cache") 216 + WriteSuccess(w, map[string]string{"status": "ok", "version": "1.0"}) 189 217 } 190 218 191 219 func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { ··· 214 242 } 215 243 216 244 if err != nil { 217 - http.Error(w, err.Error(), http.StatusInternalServerError) 245 + WriteInternalError(w, "Internal server error") 218 246 return 219 247 } 220 248 221 249 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 222 250 223 - w.Header().Set("Content-Type", "application/json") 224 - json.NewEncoder(w).Encode(map[string]interface{}{ 251 + WriteSuccess(w, map[string]interface{}{ 225 252 "@context": "http://www.w3.org/ns/anno.jsonld", 226 253 "type": "AnnotationCollection", 227 254 "items": enriched, ··· 587 614 feed = feed[:limit] 588 615 } 589 616 590 - w.Header().Set("Content-Type", "application/json") 591 - json.NewEncoder(w).Encode(map[string]interface{}{ 617 + WriteSuccess(w, map[string]interface{}{ 592 618 "@context": "http://www.w3.org/ns/anno.jsonld", 593 619 "type": "Collection", 594 620 "items": feed, ··· 660 686 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 661 687 uri := r.URL.Query().Get("uri") 662 688 if uri == "" { 663 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 689 + WriteBadRequest(w, "uri query parameter required") 664 690 return 665 691 } 666 692 ··· 828 854 } 829 855 } 830 856 831 - http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 857 + WriteNotFound(w, "Annotation, Highlight, or Bookmark not found") 832 858 833 859 } 834 860 ··· 838 864 source = r.URL.Query().Get("url") 839 865 } 840 866 if source == "" { 841 - http.Error(w, "source or url parameter required", http.StatusBadRequest) 867 + WriteBadRequest(w, "source or url parameter required") 842 868 return 843 869 } 844 870 ··· 874 900 w.Header().Set("Cache-Control", "private, max-age=0, no-store") 875 901 } 876 902 877 - w.Header().Set("Content-Type", "application/json") 878 - json.NewEncoder(w).Encode(map[string]interface{}{ 903 + WriteSuccess(w, map[string]interface{}{ 879 904 "@context": "http://www.w3.org/ns/anno.jsonld", 880 905 "source": source, 881 906 "sourceHash": urlHash, ··· 891 916 source = r.URL.Query().Get("url") 892 917 } 893 918 if source == "" { 894 - http.Error(w, "source or url parameter required", http.StatusBadRequest) 919 + WriteBadRequest(w, "source or url parameter required") 895 920 return 896 921 } 897 922 ··· 978 1003 enrichedHighlights, _ := hydrateHighlights(h.db, mergedHighlights, viewerDID) 979 1004 enrichedBookmarks, _ := hydrateBookmarks(h.db, mergedBookmarks, viewerDID) 980 1005 981 - w.Header().Set("Content-Type", "application/json") 982 - json.NewEncoder(w).Encode(map[string]interface{}{ 1006 + WriteSuccess(w, map[string]interface{}{ 983 1007 "@context": "http://www.w3.org/ns/anno.jsonld", 984 1008 "source": source, 985 1009 "sourceHash": urlHash, ··· 1008 1032 } 1009 1033 1010 1034 if err != nil { 1011 - http.Error(w, err.Error(), http.StatusInternalServerError) 1035 + WriteInternalError(w, "Internal server error") 1012 1036 return 1013 1037 } 1014 1038 1015 1039 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 1016 1040 1017 - w.Header().Set("Content-Type", "application/json") 1018 - json.NewEncoder(w).Encode(map[string]interface{}{ 1041 + WriteSuccess(w, map[string]interface{}{ 1019 1042 "@context": "http://www.w3.org/ns/anno.jsonld", 1020 1043 "type": "HighlightCollection", 1021 1044 "items": enriched, ··· 1029 1052 offset := parseIntParam(r, "offset", 0) 1030 1053 1031 1054 if did == "" { 1032 - http.Error(w, "creator parameter required", http.StatusBadRequest) 1055 + WriteBadRequest(w, "creator parameter required") 1033 1056 return 1034 1057 } 1035 1058 1036 1059 bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) 1037 1060 if err != nil { 1038 - http.Error(w, err.Error(), http.StatusInternalServerError) 1061 + WriteInternalError(w, "Internal server error") 1039 1062 return 1040 1063 } 1041 1064 1042 1065 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 1043 1066 1044 - w.Header().Set("Content-Type", "application/json") 1045 - json.NewEncoder(w).Encode(map[string]interface{}{ 1067 + WriteSuccess(w, map[string]interface{}{ 1046 1068 "@context": "http://www.w3.org/ns/anno.jsonld", 1047 1069 "type": "BookmarkCollection", 1048 1070 "items": enriched, ··· 1074 1096 annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 1075 1097 1076 1098 if err != nil { 1077 - http.Error(w, err.Error(), http.StatusInternalServerError) 1099 + WriteInternalError(w, "Internal server error") 1078 1100 return 1079 1101 } 1080 1102 1081 1103 enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 1082 1104 1083 - w.Header().Set("Content-Type", "application/json") 1084 - json.NewEncoder(w).Encode(map[string]interface{}{ 1105 + WriteSuccess(w, map[string]interface{}{ 1085 1106 "@context": "http://www.w3.org/ns/anno.jsonld", 1086 1107 "type": "AnnotationCollection", 1087 1108 "creator": did, ··· 1114 1135 highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 1115 1136 1116 1137 if err != nil { 1117 - http.Error(w, err.Error(), http.StatusInternalServerError) 1138 + WriteInternalError(w, "Internal server error") 1118 1139 return 1119 1140 } 1120 1141 1121 1142 enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 1122 1143 1123 - w.Header().Set("Content-Type", "application/json") 1124 - json.NewEncoder(w).Encode(map[string]interface{}{ 1144 + WriteSuccess(w, map[string]interface{}{ 1125 1145 "@context": "http://www.w3.org/ns/anno.jsonld", 1126 1146 "type": "HighlightCollection", 1127 1147 "creator": did, ··· 1154 1174 bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 1155 1175 1156 1176 if err != nil { 1157 - http.Error(w, err.Error(), http.StatusInternalServerError) 1177 + WriteInternalError(w, "Internal server error") 1158 1178 return 1159 1179 } 1160 1180 1161 1181 enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 1162 1182 1163 - w.Header().Set("Content-Type", "application/json") 1164 - json.NewEncoder(w).Encode(map[string]interface{}{ 1183 + WriteSuccess(w, map[string]interface{}{ 1165 1184 "@context": "http://www.w3.org/ns/anno.jsonld", 1166 1185 "type": "BookmarkCollection", 1167 1186 "creator": did, ··· 1181 1200 source = r.URL.Query().Get("url") 1182 1201 } 1183 1202 if source == "" { 1184 - http.Error(w, "source or url parameter required", http.StatusBadRequest) 1203 + WriteBadRequest(w, "source or url parameter required") 1185 1204 return 1186 1205 } 1187 1206 ··· 1196 1215 enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 1197 1216 enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 1198 1217 1199 - w.Header().Set("Content-Type", "application/json") 1200 - json.NewEncoder(w).Encode(map[string]interface{}{ 1218 + WriteSuccess(w, map[string]interface{}{ 1201 1219 "@context": "http://www.w3.org/ns/anno.jsonld", 1202 1220 "creator": did, 1203 1221 "source": source, ··· 1210 1228 func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { 1211 1229 uri := r.URL.Query().Get("uri") 1212 1230 if uri == "" { 1213 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 1231 + WriteBadRequest(w, "uri query parameter required") 1214 1232 return 1215 1233 } 1216 1234 1217 1235 replies, err := h.db.GetRepliesByRoot(uri) 1218 1236 if err != nil { 1219 - http.Error(w, err.Error(), http.StatusInternalServerError) 1237 + WriteInternalError(w, "Internal server error") 1220 1238 return 1221 1239 } 1222 1240 1223 1241 enriched, _ := hydrateReplies(h.db, replies) 1224 1242 1225 - w.Header().Set("Content-Type", "application/json") 1226 - json.NewEncoder(w).Encode(map[string]interface{}{ 1243 + WriteSuccess(w, map[string]interface{}{ 1227 1244 "@context": "http://www.w3.org/ns/anno.jsonld", 1228 1245 "type": "ReplyCollection", 1229 1246 "inReplyTo": uri, ··· 1235 1252 func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { 1236 1253 uri := r.URL.Query().Get("uri") 1237 1254 if uri == "" { 1238 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 1255 + WriteBadRequest(w, "uri query parameter required") 1239 1256 return 1240 1257 } 1241 1258 1242 1259 count, err := h.db.GetLikeCount(uri) 1243 1260 if err != nil { 1244 - http.Error(w, err.Error(), http.StatusInternalServerError) 1261 + WriteInternalError(w, "Internal server error") 1245 1262 return 1246 1263 } 1247 1264 ··· 1257 1274 } 1258 1275 } 1259 1276 1260 - w.Header().Set("Content-Type", "application/json") 1261 - json.NewEncoder(w).Encode(map[string]interface{}{ 1277 + WriteSuccess(w, map[string]interface{}{ 1262 1278 "count": count, 1263 1279 "liked": liked, 1264 1280 }) ··· 1267 1283 func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) { 1268 1284 uri := r.URL.Query().Get("uri") 1269 1285 if uri == "" { 1270 - http.Error(w, "uri query parameter required", http.StatusBadRequest) 1286 + WriteBadRequest(w, "uri query parameter required") 1271 1287 return 1272 1288 } 1273 1289 1274 1290 history, err := h.db.GetEditHistory(uri) 1275 1291 if err != nil { 1276 - http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError) 1292 + WriteInternalError(w, "Failed to fetch edit history") 1277 1293 return 1278 1294 } 1279 1295 ··· 1281 1297 history = []db.EditHistory{} 1282 1298 } 1283 1299 1284 - w.Header().Set("Content-Type", "application/json") 1285 - json.NewEncoder(w).Encode(history) 1300 + w.Header().Set("Cache-Control", "public, max-age=3600") 1301 + WriteSuccess(w, history) 1286 1302 } 1287 1303 1288 1304 func parseIntParam(r *http.Request, name string, defaultVal int) int { ··· 1300 1316 func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) { 1301 1317 targetURL := r.URL.Query().Get("url") 1302 1318 if targetURL == "" { 1303 - http.Error(w, "url parameter required", http.StatusBadRequest) 1319 + WriteBadRequest(w, "url parameter required") 1304 1320 return 1305 1321 } 1306 1322 ··· 1353 1369 } 1354 1370 h.metaCache.set(targetURL, data, ttl) 1355 1371 1356 - w.Header().Set("Content-Type", "application/json") 1357 1372 w.Header().Set("Cache-Control", "public, max-age=3600") 1358 - json.NewEncoder(w).Encode(data) 1373 + WriteSuccess(w, data) 1359 1374 } 1360 1375 1361 1376 func (h *Handler) fetchURLMetadata(ctx context.Context, targetURL string) map[string]string { ··· 1501 1516 func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { 1502 1517 session, err := h.refresher.GetSessionWithAutoRefresh(r) 1503 1518 if err != nil { 1504 - http.Error(w, err.Error(), http.StatusUnauthorized) 1519 + WriteUnauthorized(w, "Authentication required") 1505 1520 return 1506 1521 } 1507 1522 ··· 1510 1525 1511 1526 notifications, err := h.db.GetNotifications(session.DID, limit, offset) 1512 1527 if err != nil { 1513 - http.Error(w, "Failed to get notifications", http.StatusInternalServerError) 1528 + WriteInternalError(w, "Failed to get notifications") 1514 1529 return 1515 1530 } 1516 1531 ··· 1530 1545 func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) { 1531 1546 session, err := h.refresher.GetSessionWithAutoRefresh(r) 1532 1547 if err != nil { 1533 - http.Error(w, err.Error(), http.StatusUnauthorized) 1548 + WriteUnauthorized(w, "Authentication required") 1534 1549 return 1535 1550 } 1536 1551 1537 1552 count, err := h.db.GetUnreadNotificationCount(session.DID) 1538 1553 if err != nil { 1539 - http.Error(w, "Failed to get count", http.StatusInternalServerError) 1554 + WriteInternalError(w, "Failed to get count") 1540 1555 return 1541 1556 } 1542 1557 1543 - w.Header().Set("Content-Type", "application/json") 1544 - json.NewEncoder(w).Encode(map[string]int{"count": count}) 1558 + WriteSuccess(w, map[string]int{"count": count}) 1545 1559 } 1546 1560 1547 1561 func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) { 1548 1562 session, err := h.refresher.GetSessionWithAutoRefresh(r) 1549 1563 if err != nil { 1550 - http.Error(w, err.Error(), http.StatusUnauthorized) 1564 + WriteUnauthorized(w, "Authentication required") 1551 1565 return 1552 1566 } 1553 1567 1554 1568 if err := h.db.MarkNotificationsRead(session.DID); err != nil { 1555 - http.Error(w, "Failed to mark as read", http.StatusInternalServerError) 1569 + WriteInternalError(w, "Failed to mark as read") 1556 1570 return 1557 1571 } 1558 1572 1559 - w.Header().Set("Content-Type", "application/json") 1560 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 1573 + WriteSuccess(w, map[string]string{"status": "ok"}) 1561 1574 } 1562 1575 func (h *Handler) getViewerDID(r *http.Request) string { 1563 1576 cookie, err := r.Cookie("margin_session") ··· 1664 1677 func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { 1665 1678 query := r.URL.Query().Get("q") 1666 1679 if query == "" { 1667 - http.Error(w, "q parameter required", http.StatusBadRequest) 1680 + WriteBadRequest(w, "q parameter required") 1668 1681 return 1669 1682 } 1670 1683 ··· 1694 1707 1695 1708 sortFeed(feed) 1696 1709 1697 - w.Header().Set("Content-Type", "application/json") 1698 - json.NewEncoder(w).Encode(map[string]interface{}{ 1710 + WriteSuccess(w, map[string]interface{}{ 1699 1711 "items": feed, 1700 1712 "fetchedCount": len(feed), 1701 1713 }) ··· 1704 1716 func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { 1705 1717 viewerDID := h.getViewerDID(r) 1706 1718 if viewerDID == "" { 1707 - http.Error(w, "authentication required", http.StatusUnauthorized) 1719 + WriteUnauthorized(w, "authentication required") 1708 1720 return 1709 1721 } 1710 1722 1711 1723 if !h.recommendations.IsEnabled() { 1712 - http.Error(w, "recommendations not available", http.StatusServiceUnavailable) 1724 + WriteJSONError(w, http.StatusServiceUnavailable, "recommendations not available") 1713 1725 return 1714 1726 } 1715 1727 ··· 1721 1733 items, err := h.recommendations.GetRecommendations(viewerDID, limit) 1722 1734 if err != nil { 1723 1735 logger.Error("Recommendations error for %s: %v", viewerDID, err) 1724 - http.Error(w, "failed to get recommendations", http.StatusInternalServerError) 1736 + WriteInternalError(w, "failed to get recommendations") 1725 1737 return 1726 1738 } 1727 1739 ··· 1729 1741 items = []recommendations.RecommendedItem{} 1730 1742 } 1731 1743 1732 - w.Header().Set("Content-Type", "application/json") 1733 - json.NewEncoder(w).Encode(map[string]interface{}{ 1744 + WriteSuccess(w, map[string]interface{}{ 1734 1745 "items": items, 1735 1746 "totalItems": len(items), 1736 1747 }) ··· 1756 1767 1757 1768 if err != nil { 1758 1769 logger.Error("GetDocuments error: %v", err) 1759 - http.Error(w, "failed to get documents", http.StatusInternalServerError) 1770 + WriteInternalError(w, "failed to get documents") 1760 1771 return 1761 1772 } 1762 1773 ··· 1797 1808 1798 1809 total, _ := h.db.GetDocumentCount() 1799 1810 1800 - w.Header().Set("Content-Type", "application/json") 1801 - json.NewEncoder(w).Encode(map[string]interface{}{ 1811 + WriteSuccess(w, map[string]interface{}{ 1802 1812 "items": items, 1803 1813 "totalItems": total, 1804 1814 }) ··· 1807 1817 func (h *Handler) AdminBackfill(w http.ResponseWriter, r *http.Request) { 1808 1818 session, err := h.refresher.GetSessionWithAutoRefresh(r) 1809 1819 if err != nil || session == nil { 1810 - http.Error(w, "authentication required", http.StatusUnauthorized) 1820 + WriteUnauthorized(w, "authentication required") 1811 1821 return 1812 1822 } 1813 1823 if !config.Get().IsAdmin(session.DID) { 1814 - http.Error(w, "admin access required", http.StatusForbidden) 1824 + WriteForbidden(w, "admin access required") 1815 1825 return 1816 1826 } 1817 1827 if !h.recommendations.IsEnabled() { 1818 - http.Error(w, "embeddings not enabled (set OPENAI_API_KEY)", http.StatusServiceUnavailable) 1828 + WriteJSONError(w, http.StatusServiceUnavailable, "embeddings not enabled (set OPENAI_API_KEY)") 1819 1829 return 1820 1830 } 1821 1831 ··· 1857 1867 docCount, _ := h.db.GetDocumentCount() 1858 1868 res.Documents = docCount 1859 1869 1860 - w.Header().Set("Content-Type", "application/json") 1861 - json.NewEncoder(w).Encode(res) 1870 + WriteSuccess(w, res) 1862 1871 }
+69 -85
backend/internal/api/moderation.go
··· 22 22 func (m *ModerationHandler) BlockUser(w http.ResponseWriter, r *http.Request) { 23 23 session, err := m.refresher.GetSessionWithAutoRefresh(r) 24 24 if err != nil { 25 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 25 + WriteUnauthorized(w, "Unauthorized") 26 26 return 27 27 } 28 28 ··· 30 30 DID string `json:"did"` 31 31 } 32 32 if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { 33 - http.Error(w, "did is required", http.StatusBadRequest) 33 + WriteBadRequest(w, "did is required") 34 34 return 35 35 } 36 36 37 37 if req.DID == session.DID { 38 - http.Error(w, "Cannot block yourself", http.StatusBadRequest) 38 + WriteBadRequest(w, "Cannot block yourself") 39 39 return 40 40 } 41 41 42 42 if err := m.db.CreateBlock(session.DID, req.DID); err != nil { 43 43 logger.Error("Failed to create block: %v", err) 44 - http.Error(w, "Failed to block user", http.StatusInternalServerError) 44 + WriteInternalError(w, "Failed to block user") 45 45 return 46 46 } 47 47 48 - w.Header().Set("Content-Type", "application/json") 49 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 48 + WriteSuccess(w, map[string]string{"status": "ok"}) 50 49 } 51 50 52 51 func (m *ModerationHandler) UnblockUser(w http.ResponseWriter, r *http.Request) { 53 52 session, err := m.refresher.GetSessionWithAutoRefresh(r) 54 53 if err != nil { 55 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 54 + WriteUnauthorized(w, "Unauthorized") 56 55 return 57 56 } 58 57 59 58 did := r.URL.Query().Get("did") 60 59 if did == "" { 61 - http.Error(w, "did query parameter required", http.StatusBadRequest) 60 + WriteBadRequest(w, "did query parameter required") 62 61 return 63 62 } 64 63 65 64 if err := m.db.DeleteBlock(session.DID, did); err != nil { 66 65 logger.Error("Failed to delete block: %v", err) 67 - http.Error(w, "Failed to unblock user", http.StatusInternalServerError) 66 + WriteInternalError(w, "Failed to unblock user") 68 67 return 69 68 } 70 69 71 - w.Header().Set("Content-Type", "application/json") 72 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 70 + WriteSuccess(w, map[string]string{"status": "ok"}) 73 71 } 74 72 75 73 func (m *ModerationHandler) GetBlocks(w http.ResponseWriter, r *http.Request) { 76 74 session, err := m.refresher.GetSessionWithAutoRefresh(r) 77 75 if err != nil { 78 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 76 + WriteUnauthorized(w, "Unauthorized") 79 77 return 80 78 } 81 79 82 80 blocks, err := m.db.GetBlocks(session.DID) 83 81 if err != nil { 84 - http.Error(w, "Failed to fetch blocks", http.StatusInternalServerError) 82 + WriteInternalError(w, "Failed to fetch blocks") 85 83 return 86 84 } 87 85 ··· 106 104 } 107 105 } 108 106 109 - w.Header().Set("Content-Type", "application/json") 110 - json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 107 + WriteSuccess(w, map[string]interface{}{"items": items}) 111 108 } 112 109 113 110 func (m *ModerationHandler) MuteUser(w http.ResponseWriter, r *http.Request) { 114 111 session, err := m.refresher.GetSessionWithAutoRefresh(r) 115 112 if err != nil { 116 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 113 + WriteUnauthorized(w, "Unauthorized") 117 114 return 118 115 } 119 116 ··· 121 118 DID string `json:"did"` 122 119 } 123 120 if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DID == "" { 124 - http.Error(w, "did is required", http.StatusBadRequest) 121 + WriteBadRequest(w, "did is required") 125 122 return 126 123 } 127 124 128 125 if req.DID == session.DID { 129 - http.Error(w, "Cannot mute yourself", http.StatusBadRequest) 126 + WriteBadRequest(w, "Cannot mute yourself") 130 127 return 131 128 } 132 129 133 130 if err := m.db.CreateMute(session.DID, req.DID); err != nil { 134 131 logger.Error("Failed to create mute: %v", err) 135 - http.Error(w, "Failed to mute user", http.StatusInternalServerError) 132 + WriteInternalError(w, "Failed to mute user") 136 133 return 137 134 } 138 135 139 - w.Header().Set("Content-Type", "application/json") 140 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 136 + WriteSuccess(w, map[string]string{"status": "ok"}) 141 137 } 142 138 143 139 func (m *ModerationHandler) UnmuteUser(w http.ResponseWriter, r *http.Request) { 144 140 session, err := m.refresher.GetSessionWithAutoRefresh(r) 145 141 if err != nil { 146 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 142 + WriteUnauthorized(w, "Unauthorized") 147 143 return 148 144 } 149 145 150 146 did := r.URL.Query().Get("did") 151 147 if did == "" { 152 - http.Error(w, "did query parameter required", http.StatusBadRequest) 148 + WriteBadRequest(w, "did query parameter required") 153 149 return 154 150 } 155 151 156 152 if err := m.db.DeleteMute(session.DID, did); err != nil { 157 153 logger.Error("Failed to delete mute: %v", err) 158 - http.Error(w, "Failed to unmute user", http.StatusInternalServerError) 154 + WriteInternalError(w, "Failed to unmute user") 159 155 return 160 156 } 161 157 162 - w.Header().Set("Content-Type", "application/json") 163 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 158 + WriteSuccess(w, map[string]string{"status": "ok"}) 164 159 } 165 160 166 161 func (m *ModerationHandler) GetMutes(w http.ResponseWriter, r *http.Request) { 167 162 session, err := m.refresher.GetSessionWithAutoRefresh(r) 168 163 if err != nil { 169 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 164 + WriteUnauthorized(w, "Unauthorized") 170 165 return 171 166 } 172 167 173 168 mutes, err := m.db.GetMutes(session.DID) 174 169 if err != nil { 175 - http.Error(w, "Failed to fetch mutes", http.StatusInternalServerError) 170 + WriteInternalError(w, "Failed to fetch mutes") 176 171 return 177 172 } 178 173 ··· 197 192 } 198 193 } 199 194 200 - w.Header().Set("Content-Type", "application/json") 201 - json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 195 + WriteSuccess(w, map[string]interface{}{"items": items}) 202 196 } 203 197 204 198 func (m *ModerationHandler) GetRelationship(w http.ResponseWriter, r *http.Request) { ··· 206 200 subjectDID := r.URL.Query().Get("did") 207 201 208 202 if subjectDID == "" { 209 - http.Error(w, "did query parameter required", http.StatusBadRequest) 203 + WriteBadRequest(w, "did query parameter required") 210 204 return 211 205 } 212 206 213 207 blocked, muted, blockedBy, err := m.db.GetViewerRelationship(viewerDID, subjectDID) 214 208 if err != nil { 215 - http.Error(w, "Failed to get relationship", http.StatusInternalServerError) 209 + WriteInternalError(w, "Failed to get relationship") 216 210 return 217 211 } 218 212 219 - w.Header().Set("Content-Type", "application/json") 220 - json.NewEncoder(w).Encode(map[string]interface{}{ 213 + WriteSuccess(w, map[string]interface{}{ 221 214 "blocking": blocked, 222 215 "muting": muted, 223 216 "blockedBy": blockedBy, ··· 227 220 func (m *ModerationHandler) CreateReport(w http.ResponseWriter, r *http.Request) { 228 221 session, err := m.refresher.GetSessionWithAutoRefresh(r) 229 222 if err != nil { 230 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 223 + WriteUnauthorized(w, "Unauthorized") 231 224 return 232 225 } 233 226 ··· 238 231 ReasonText *string `json:"reasonText,omitempty"` 239 232 } 240 233 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 241 - http.Error(w, "Invalid request body", http.StatusBadRequest) 234 + WriteBadRequest(w, "Invalid request body") 242 235 return 243 236 } 244 237 245 238 if req.SubjectDID == "" || req.ReasonType == "" { 246 - http.Error(w, "subjectDid and reasonType are required", http.StatusBadRequest) 239 + WriteBadRequest(w, "subjectDid and reasonType are required") 247 240 return 248 241 } 249 242 ··· 257 250 } 258 251 259 252 if !validReasons[req.ReasonType] { 260 - http.Error(w, "Invalid reasonType", http.StatusBadRequest) 253 + WriteBadRequest(w, "Invalid reasonType") 261 254 return 262 255 } 263 256 264 257 id, err := m.db.CreateReport(session.DID, req.SubjectDID, req.SubjectURI, req.ReasonType, req.ReasonText) 265 258 if err != nil { 266 259 logger.Error("Failed to create report: %v", err) 267 - http.Error(w, "Failed to submit report", http.StatusInternalServerError) 260 + WriteInternalError(w, "Failed to submit report") 268 261 return 269 262 } 270 263 271 - w.Header().Set("Content-Type", "application/json") 272 - json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "status": "ok"}) 264 + WriteSuccess(w, map[string]interface{}{"id": id, "status": "ok"}) 273 265 } 274 266 275 267 func (m *ModerationHandler) AdminGetReports(w http.ResponseWriter, r *http.Request) { 276 268 session, err := m.refresher.GetSessionWithAutoRefresh(r) 277 269 if err != nil { 278 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 270 + WriteUnauthorized(w, "Unauthorized") 279 271 return 280 272 } 281 273 282 274 if !config.Get().IsAdmin(session.DID) { 283 - http.Error(w, "Forbidden", http.StatusForbidden) 275 + WriteForbidden(w, "Forbidden") 284 276 return 285 277 } 286 278 ··· 290 282 291 283 reports, err := m.db.GetReports(status, limit, offset) 292 284 if err != nil { 293 - http.Error(w, "Failed to fetch reports", http.StatusInternalServerError) 285 + WriteInternalError(w, "Failed to fetch reports") 294 286 return 295 287 } 296 288 ··· 340 332 pendingCount, _ := m.db.GetReportCount("pending") 341 333 totalCount, _ := m.db.GetReportCount("") 342 334 343 - w.Header().Set("Content-Type", "application/json") 344 - json.NewEncoder(w).Encode(map[string]interface{}{ 335 + WriteSuccess(w, map[string]interface{}{ 345 336 "items": items, 346 337 "totalItems": totalCount, 347 338 "pendingCount": pendingCount, ··· 351 342 func (m *ModerationHandler) AdminTakeAction(w http.ResponseWriter, r *http.Request) { 352 343 session, err := m.refresher.GetSessionWithAutoRefresh(r) 353 344 if err != nil { 354 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 345 + WriteUnauthorized(w, "Unauthorized") 355 346 return 356 347 } 357 348 358 349 if !config.Get().IsAdmin(session.DID) { 359 - http.Error(w, "Forbidden", http.StatusForbidden) 350 + WriteForbidden(w, "Forbidden") 360 351 return 361 352 } 362 353 ··· 366 357 Comment *string `json:"comment,omitempty"` 367 358 } 368 359 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 369 - http.Error(w, "Invalid request body", http.StatusBadRequest) 360 + WriteBadRequest(w, "Invalid request body") 370 361 return 371 362 } 372 363 ··· 378 369 } 379 370 380 371 if !validActions[req.Action] { 381 - http.Error(w, "Invalid action", http.StatusBadRequest) 372 + WriteBadRequest(w, "Invalid action") 382 373 return 383 374 } 384 375 385 376 report, err := m.db.GetReport(req.ReportID) 386 377 if err != nil { 387 - http.Error(w, "Report not found", http.StatusNotFound) 378 + WriteNotFound(w, "Report not found") 388 379 return 389 380 } 390 381 391 382 if err := m.db.CreateModerationAction(req.ReportID, session.DID, req.Action, req.Comment); err != nil { 392 383 logger.Error("Failed to create moderation action: %v", err) 393 - http.Error(w, "Failed to take action", http.StatusInternalServerError) 384 + WriteInternalError(w, "Failed to take action") 394 385 return 395 386 } 396 387 ··· 413 404 logger.Error("Failed to resolve report: %v", err) 414 405 } 415 406 416 - w.Header().Set("Content-Type", "application/json") 417 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 407 + WriteSuccess(w, map[string]string{"status": "ok"}) 418 408 } 419 409 420 410 func (m *ModerationHandler) AdminGetReport(w http.ResponseWriter, r *http.Request) { 421 411 session, err := m.refresher.GetSessionWithAutoRefresh(r) 422 412 if err != nil { 423 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 413 + WriteUnauthorized(w, "Unauthorized") 424 414 return 425 415 } 426 416 427 417 if !config.Get().IsAdmin(session.DID) { 428 - http.Error(w, "Forbidden", http.StatusForbidden) 418 + WriteForbidden(w, "Forbidden") 429 419 return 430 420 } 431 421 432 422 idStr := r.URL.Query().Get("id") 433 423 id, err := strconv.Atoi(idStr) 434 424 if err != nil { 435 - http.Error(w, "Invalid report ID", http.StatusBadRequest) 425 + WriteBadRequest(w, "Invalid report ID") 436 426 return 437 427 } 438 428 439 429 report, err := m.db.GetReport(id) 440 430 if err != nil { 441 - http.Error(w, "Report not found", http.StatusNotFound) 431 + WriteNotFound(w, "Report not found") 442 432 return 443 433 } 444 434 ··· 446 436 447 437 profiles := fetchProfilesForDIDs(m.db, []string{report.ReporterDID, report.SubjectDID}) 448 438 449 - w.Header().Set("Content-Type", "application/json") 450 - json.NewEncoder(w).Encode(map[string]interface{}{ 439 + WriteSuccess(w, map[string]interface{}{ 451 440 "report": report, 452 441 "reporter": profiles[report.ReporterDID], 453 442 "subject": profiles[report.SubjectDID], ··· 463 452 return 464 453 } 465 454 466 - w.Header().Set("Content-Type", "application/json") 467 - json.NewEncoder(w).Encode(map[string]bool{"isAdmin": config.Get().IsAdmin(session.DID)}) 455 + WriteSuccess(w, map[string]bool{"isAdmin": config.Get().IsAdmin(session.DID)}) 468 456 } 469 457 470 458 func (m *ModerationHandler) deleteContent(uri string) { ··· 477 465 func (m *ModerationHandler) AdminCreateLabel(w http.ResponseWriter, r *http.Request) { 478 466 session, err := m.refresher.GetSessionWithAutoRefresh(r) 479 467 if err != nil { 480 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 468 + WriteUnauthorized(w, "Unauthorized") 481 469 return 482 470 } 483 471 484 472 if !config.Get().IsAdmin(session.DID) { 485 - http.Error(w, "Forbidden", http.StatusForbidden) 473 + WriteForbidden(w, "Forbidden") 486 474 return 487 475 } 488 476 ··· 492 480 Val string `json:"val"` 493 481 } 494 482 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 495 - http.Error(w, "Invalid request body", http.StatusBadRequest) 483 + WriteBadRequest(w, "Invalid request body") 496 484 return 497 485 } 498 486 499 487 if req.Val == "" { 500 - http.Error(w, "val is required", http.StatusBadRequest) 488 + WriteBadRequest(w, "val is required") 501 489 return 502 490 } 503 491 504 492 labelerDID := config.Get().ServiceDID 505 493 if labelerDID == "" { 506 - http.Error(w, "SERVICE_DID not configured — cannot issue labels", http.StatusInternalServerError) 494 + WriteInternalError(w, "SERVICE_DID not configured — cannot issue labels") 507 495 return 508 496 } 509 497 ··· 512 500 targetURI = req.Src 513 501 } 514 502 if targetURI == "" { 515 - http.Error(w, "src or uri is required", http.StatusBadRequest) 503 + WriteBadRequest(w, "src or uri is required") 516 504 return 517 505 } 518 506 ··· 526 514 } 527 515 528 516 if !validLabels[req.Val] { 529 - http.Error(w, "Invalid label value. Must be one of: sexual, nudity, violence, gore, spam, misleading", http.StatusBadRequest) 517 + WriteBadRequest(w, "Invalid label value. Must be one of: sexual, nudity, violence, gore, spam, misleading") 530 518 return 531 519 } 532 520 533 521 if err := m.db.CreateContentLabel(labelerDID, targetURI, req.Val, session.DID); err != nil { 534 522 logger.Error("Failed to create content label: %v", err) 535 - http.Error(w, "Failed to create label", http.StatusInternalServerError) 523 + WriteInternalError(w, "Failed to create label") 536 524 return 537 525 } 538 526 539 - w.Header().Set("Content-Type", "application/json") 540 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 527 + WriteSuccess(w, map[string]string{"status": "ok"}) 541 528 } 542 529 543 530 func (m *ModerationHandler) AdminDeleteLabel(w http.ResponseWriter, r *http.Request) { 544 531 session, err := m.refresher.GetSessionWithAutoRefresh(r) 545 532 if err != nil { 546 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 533 + WriteUnauthorized(w, "Unauthorized") 547 534 return 548 535 } 549 536 550 537 if !config.Get().IsAdmin(session.DID) { 551 - http.Error(w, "Forbidden", http.StatusForbidden) 538 + WriteForbidden(w, "Forbidden") 552 539 return 553 540 } 554 541 555 542 idStr := r.URL.Query().Get("id") 556 543 id, err := strconv.Atoi(idStr) 557 544 if err != nil { 558 - http.Error(w, "Invalid label ID", http.StatusBadRequest) 545 + WriteBadRequest(w, "Invalid label ID") 559 546 return 560 547 } 561 548 562 549 if err := m.db.DeleteContentLabel(id); err != nil { 563 - http.Error(w, "Failed to delete label", http.StatusInternalServerError) 550 + WriteInternalError(w, "Failed to delete label") 564 551 return 565 552 } 566 553 567 - w.Header().Set("Content-Type", "application/json") 568 - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 554 + WriteSuccess(w, map[string]string{"status": "ok"}) 569 555 } 570 556 571 557 func (m *ModerationHandler) AdminGetLabels(w http.ResponseWriter, r *http.Request) { 572 558 session, err := m.refresher.GetSessionWithAutoRefresh(r) 573 559 if err != nil { 574 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 560 + WriteUnauthorized(w, "Unauthorized") 575 561 return 576 562 } 577 563 578 564 if !config.Get().IsAdmin(session.DID) { 579 - http.Error(w, "Forbidden", http.StatusForbidden) 565 + WriteForbidden(w, "Forbidden") 580 566 return 581 567 } 582 568 ··· 585 571 586 572 labels, err := m.db.GetAllContentLabels(limit, offset) 587 573 if err != nil { 588 - http.Error(w, "Failed to fetch labels", http.StatusInternalServerError) 574 + WriteInternalError(w, "Failed to fetch labels") 589 575 return 590 576 } 591 577 ··· 628 614 } 629 615 } 630 616 631 - w.Header().Set("Content-Type", "application/json") 632 - json.NewEncoder(w).Encode(map[string]interface{}{"items": items}) 617 + WriteSuccess(w, map[string]interface{}{"items": items}) 633 618 } 634 619 635 620 func (m *ModerationHandler) getViewerDID(r *http.Request) string { ··· 663 648 {Identifier: "misleading", Severity: "inform", Blurs: "content", Description: "Misleading information"}, 664 649 } 665 650 666 - w.Header().Set("Content-Type", "application/json") 667 - json.NewEncoder(w).Encode(map[string]interface{}{ 651 + WriteSuccess(w, map[string]interface{}{ 668 652 "did": serviceDID, 669 653 "name": "Margin Moderation", 670 654 "labels": labels,
+6 -7
backend/internal/api/preferences.go
··· 32 32 func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { 33 33 session, err := h.refresher.GetSessionWithAutoRefresh(r) 34 34 if err != nil { 35 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 35 + WriteUnauthorized(w, "Unauthorized") 36 36 return 37 37 } 38 38 39 39 prefs, err := h.db.GetPreferences(session.DID) 40 40 if err != nil { 41 - http.Error(w, "Failed to fetch preferences", http.StatusInternalServerError) 41 + WriteInternalError(w, "Failed to fetch preferences") 42 42 return 43 43 } 44 44 ··· 72 72 disableWarning = *prefs.DisableExternalLinkWarning 73 73 } 74 74 75 - w.Header().Set("Content-Type", "application/json") 76 - json.NewEncoder(w).Encode(PreferencesResponse{ 75 + WriteSuccess(w, PreferencesResponse{ 77 76 ExternalLinkSkippedHostnames: hostnames, 78 77 SubscribedLabelers: labelers, 79 78 LabelPreferences: labelPrefs, ··· 84 83 func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { 85 84 session, err := h.refresher.GetSessionWithAutoRefresh(r) 86 85 if err != nil { 87 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 86 + WriteUnauthorized(w, "Unauthorized") 88 87 return 89 88 } 90 89 91 90 var input PreferencesResponse 92 91 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 93 - http.Error(w, "Invalid input", http.StatusBadRequest) 92 + WriteBadRequest(w, "Invalid input") 94 93 return 95 94 } 96 95 ··· 113 112 114 113 record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning) 115 114 if err := record.Validate(); err != nil { 116 - http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest) 115 + WriteBadRequest(w, fmt.Sprintf("Invalid record: %v", err)) 117 116 return 118 117 } 119 118
+13 -14
backend/internal/api/profile.go
··· 27 27 func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { 28 28 session, err := h.refresher.GetSessionWithAutoRefresh(r) 29 29 if err != nil { 30 - http.Error(w, err.Error(), http.StatusUnauthorized) 30 + WriteUnauthorized(w, err.Error()) 31 31 return 32 32 } 33 33 34 34 var req UpdateProfileRequest 35 35 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 36 - http.Error(w, "Invalid request body", http.StatusBadRequest) 36 + WriteBadRequest(w, "Invalid request body") 37 37 return 38 38 } 39 39 ··· 48 48 } 49 49 50 50 if err := record.Validate(); err != nil { 51 - http.Error(w, err.Error(), http.StatusBadRequest) 51 + WriteBadRequest(w, err.Error()) 52 52 return 53 53 } 54 54 ··· 104 104 } 105 105 106 106 if did == "" { 107 - http.Error(w, "DID required", http.StatusBadRequest) 107 + WriteBadRequest(w, "DID required") 108 108 return 109 109 } 110 110 ··· 123 123 124 124 profile, err := h.db.GetProfile(did) 125 125 if err != nil { 126 - http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) 126 + WriteInternalError(w, "Failed to fetch profile") 127 127 return 128 128 } 129 129 ··· 222 222 } 223 223 } 224 224 225 - w.Header().Set("Content-Type", "application/json") 226 - json.NewEncoder(w).Encode(resp) 225 + w.Header().Set("Cache-Control", "private, max-age=60") 226 + WriteSuccess(w, resp) 227 227 } 228 228 229 229 func (h *Handler) UploadAvatar(w http.ResponseWriter, r *http.Request) { 230 230 session, err := h.refresher.GetSessionWithAutoRefresh(r) 231 231 if err != nil { 232 - http.Error(w, err.Error(), http.StatusUnauthorized) 232 + WriteUnauthorized(w, err.Error()) 233 233 return 234 234 } 235 235 ··· 237 237 238 238 file, header, err := r.FormFile("avatar") 239 239 if err != nil { 240 - http.Error(w, "Failed to read avatar file: "+err.Error(), http.StatusBadRequest) 240 + WriteBadRequest(w, "Failed to read avatar file: "+err.Error()) 241 241 return 242 242 } 243 243 defer file.Close() 244 244 245 245 contentType := header.Header.Get("Content-Type") 246 246 if contentType != "image/jpeg" && contentType != "image/png" { 247 - http.Error(w, "Invalid image type. Must be JPEG or PNG.", http.StatusBadRequest) 247 + WriteBadRequest(w, "Invalid image type. Must be JPEG or PNG.") 248 248 return 249 249 } 250 250 251 251 data, err := io.ReadAll(file) 252 252 if err != nil { 253 - http.Error(w, "Failed to read file", http.StatusInternalServerError) 253 + WriteInternalError(w, "Failed to read file") 254 254 return 255 255 } 256 256 ··· 262 262 }) 263 263 264 264 if err != nil { 265 - http.Error(w, "Failed to upload avatar: "+err.Error(), http.StatusInternalServerError) 265 + WriteInternalError(w, "Failed to upload avatar") 266 266 return 267 267 } 268 268 269 - w.Header().Set("Content-Type", "application/json") 270 - json.NewEncoder(w).Encode(map[string]interface{}{ 269 + WriteSuccess(w, map[string]interface{}{ 271 270 "blob": blobRef, 272 271 }) 273 272 }
+2 -2
backend/internal/api/sync.go
··· 11 11 func (h *Handler) SyncAll(w http.ResponseWriter, r *http.Request) { 12 12 session, err := h.refresher.GetSessionWithAutoRefresh(r) 13 13 if err != nil { 14 - http.Error(w, err.Error(), http.StatusUnauthorized) 14 + WriteUnauthorized(w, err.Error()) 15 15 return 16 16 } 17 17 ··· 25 25 }) 26 26 27 27 if err != nil { 28 - http.Error(w, "Sync failed: "+err.Error(), http.StatusInternalServerError) 28 + WriteInternalError(w, "Sync failed") 29 29 return 30 30 } 31 31
+6 -8
backend/internal/api/tags.go
··· 1 1 package api 2 2 3 3 import ( 4 - "encoding/json" 5 4 "net/http" 6 5 "strconv" 7 6 ··· 18 17 19 18 tags, err := h.db.GetTrendingTags(limit) 20 19 if err != nil { 21 - http.Error(w, `{"error": "Failed to fetch trending tags: `+err.Error()+`"}`, http.StatusInternalServerError) 20 + WriteInternalError(w, "Failed to fetch trending tags") 22 21 return 23 22 } 24 23 25 - w.Header().Set("Content-Type", "application/json") 26 - json.NewEncoder(w).Encode(tags) 24 + w.Header().Set("Cache-Control", "public, max-age=300, s-maxage=600") 25 + WriteSuccess(w, tags) 27 26 } 28 27 29 28 func (h *Handler) HandleGetUserTags(w http.ResponseWriter, r *http.Request) { 30 29 did := chi.URLParam(r, "did") 31 30 if did == "" { 32 - http.Error(w, `{"error": "did is required"}`, http.StatusBadRequest) 31 + WriteBadRequest(w, "did is required") 33 32 return 34 33 } 35 34 ··· 42 41 43 42 tags, err := h.db.GetUserTags(did, limit) 44 43 if err != nil { 45 - http.Error(w, `{"error": "Failed to fetch user tags"}`, http.StatusInternalServerError) 44 + WriteInternalError(w, "Failed to fetch user tags") 46 45 return 47 46 } 48 47 49 - w.Header().Set("Content-Type", "application/json") 50 - json.NewEncoder(w).Encode(tags) 48 + WriteSuccess(w, tags) 51 49 }
+2 -2
backend/internal/api/token_refresh.go
··· 225 225 SameSite: http.SameSiteLaxMode, 226 226 MaxAge: -1, 227 227 }) 228 - http.Error(w, "session expired", http.StatusUnauthorized) 228 + WriteUnauthorized(w, "session expired") 229 229 return 230 230 } 231 - http.Error(w, fallbackMsg+err.Error(), fallbackStatus) 231 + WriteJSONError(w, fallbackStatus, fallbackMsg) 232 232 }
-6
backend/internal/db/queries.go
··· 34 34 return annotations, nil 35 35 } 36 36 37 - func (db *DB) AnnotationExists(uri string) bool { 38 - var exists bool 39 - db.QueryRow(`SELECT EXISTS(SELECT 1 FROM annotations WHERE uri = $1)`, uri).Scan(&exists) 40 - return exists 41 - } 42 - 43 37 func HashURL(rawURL string) string { 44 38 parsed, err := url.Parse(rawURL) 45 39 if err != nil || parsed.Host == "" {
+11 -9
backend/internal/oauth/handler.go
··· 556 556 h.db.DeleteSession(cookie.Value) 557 557 } 558 558 559 - http.SetCookie(w, &http.Cookie{ 560 - Name: "margin_session", 561 - Value: "", 562 - Path: "/", 563 - HttpOnly: true, 564 - Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", 565 - SameSite: http.SameSiteLaxMode, 566 - MaxAge: -1, 567 - }) 559 + for _, secure := range []bool{true, false} { 560 + http.SetCookie(w, &http.Cookie{ 561 + Name: "margin_session", 562 + Value: "", 563 + Path: "/", 564 + HttpOnly: true, 565 + Secure: secure, 566 + SameSite: http.SameSiteLaxMode, 567 + MaxAge: -1, 568 + }) 569 + } 568 570 569 571 w.Header().Set("Content-Type", "application/json") 570 572 json.NewEncoder(w).Encode(map[string]bool{"success": true})
+1 -3
web/astro.config.mjs
··· 11 11 output: "server", 12 12 adapter: node({ mode: "standalone" }), 13 13 integrations: [react(), tailwind()], 14 + security: { checkOrigin: false }, 14 15 prefetch: { 15 16 prefetchAll: true, 16 17 defaultStrategy: "viewport", 17 - }, 18 - security: { 19 - checkOrigin: true, 20 18 }, 21 19 vite: { 22 20 ssr: {
+7
web/bun.lock
··· 20 20 "postcss": "^8.5.6", 21 21 "react": "^19.2.4", 22 22 "react-dom": "^19.2.4", 23 + "react-router-dom": "^7.13.2", 23 24 "satori": "^0.19.2", 24 25 "tailwind-merge": "^3.4.0", 25 26 "tailwindcss": "^3.4.19", ··· 1196 1197 1197 1198 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], 1198 1199 1200 + "react-router": ["react-router@7.13.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA=="], 1201 + 1202 + "react-router-dom": ["react-router-dom@7.13.2", "", { "dependencies": { "react-router": "7.13.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA=="], 1203 + 1199 1204 "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], 1200 1205 1201 1206 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], ··· 1261 1266 "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], 1262 1267 1263 1268 "server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="], 1269 + 1270 + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], 1264 1271 1265 1272 "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], 1266 1273
+1
web/package.json
··· 26 26 "postcss": "^8.5.6", 27 27 "react": "^19.2.4", 28 28 "react-dom": "^19.2.4", 29 + "react-router-dom": "^7.13.2", 29 30 "satori": "^0.19.2", 30 31 "tailwind-merge": "^3.4.0", 31 32 "tailwindcss": "^3.4.19"
+16 -13
web/src/api/client.ts
··· 37 37 postsCount: data.postsCount, 38 38 }; 39 39 40 - try { 41 - const bskyRes = await fetch( 40 + const [bskyResult, marginResult] = await Promise.allSettled([ 41 + fetch( 42 42 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 43 - ); 44 - if (bskyRes.ok) { 45 - const bskyData = await bskyRes.json(); 43 + ), 44 + fetch(`/api/profile/${data.did}`), 45 + ]); 46 + 47 + if (bskyResult.status === "fulfilled" && bskyResult.value.ok) { 48 + try { 49 + const bskyData = await bskyResult.value.json(); 46 50 if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; 47 51 if (bskyData.displayName) 48 52 baseProfile.displayName = bskyData.displayName; 53 + } catch { 54 + /* ignore */ 49 55 } 50 - } catch (e) { 51 - console.warn("Failed to fetch Bsky profile for session", e); 52 56 } 53 57 54 - try { 55 - const res = await fetch(`/api/profile/${data.did}`); 56 - if (res.ok) { 57 - const marginProfile = await res.json(); 58 + if (marginResult.status === "fulfilled" && marginResult.value.ok) { 59 + try { 60 + const marginProfile = await marginResult.value.json(); 58 61 if (marginProfile) { 59 62 if (marginProfile.description) 60 63 baseProfile.description = marginProfile.description; ··· 68 71 baseProfile.website = marginProfile.website; 69 72 if (marginProfile.links) baseProfile.links = marginProfile.links; 70 73 } 74 + } catch { 75 + /* ignore */ 71 76 } 72 - } catch (e) { 73 - console.debug("Failed to fetch Margin profile:", e); 74 77 } 75 78 76 79 sessionAtom.set(baseProfile);
+1 -1
web/src/components/modals/EditHistoryModal.tsx
··· 105 105 106 106 {history.map((edit, index) => ( 107 107 <div 108 - key={edit.cid || index} 108 + key={edit.id || index} 109 109 className="p-4 hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors" 110 110 > 111 111 <div className="flex justify-between items-start mb-2">
+81 -86
web/src/components/navigation/MobileNav.tsx
··· 17 17 import React, { useEffect, useState } from "react"; 18 18 import { getUnreadNotificationCount } from "../../api/client"; 19 19 import { $user, logout } from "../../store/auth"; 20 - import type { UserProfile } from "../../types"; 21 20 import { AppleIcon } from "../common/Icons"; 22 21 23 22 interface MobileNavProps { 24 - initialUser?: UserProfile | null; 25 23 currentPath?: string; 24 + onNavigate?: (path: string) => void; 26 25 } 27 26 28 27 export default function MobileNav({ 29 - initialUser, 30 28 currentPath: initialPath, 29 + onNavigate, 31 30 }: MobileNavProps) { 32 - const storeUser = useStore($user); 33 - const user = storeUser || initialUser || null; 31 + const user = useStore($user); 34 32 const [currentPath, setCurrentPath] = useState(initialPath || "/"); 35 33 const [isMenuOpen, setIsMenuOpen] = useState(false); 36 34 const [unreadCount, setUnreadCount] = useState(0); 37 35 38 36 const isAuthenticated = !!user; 39 - 40 - useEffect(() => { 41 - if (initialUser && !storeUser) { 42 - $user.set(initialUser); 43 - } 44 - }, [initialUser, storeUser]); 45 - 46 - useEffect(() => { 47 - const handler = () => setCurrentPath(window.location.pathname); 48 - document.addEventListener("astro:page-load", handler); 49 - return () => document.removeEventListener("astro:page-load", handler); 50 - }, []); 51 37 52 38 const isActive = (path: string) => { 53 39 if (path === "/") return currentPath === "/"; ··· 81 67 <a 82 68 href={`/profile/${user.did}`} 83 69 className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 84 - onClick={closeMenu} 70 + onClick={(e) => { 71 + if (onNavigate) { 72 + e.preventDefault(); 73 + onNavigate(`/profile/${user.did}`); 74 + } 75 + closeMenu(); 76 + }} 85 77 > 86 78 {user.avatar ? ( 87 79 <img ··· 106 98 107 99 <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 108 100 109 - <a 110 - href="/annotations" 111 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 112 - onClick={closeMenu} 113 - > 114 - <MessageSquareText size={20} /> 115 - <span>Annotations</span> 116 - </a> 117 - 118 - <a 119 - href="/highlights" 120 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 121 - onClick={closeMenu} 122 - > 123 - <Highlighter size={20} /> 124 - <span>Highlights</span> 125 - </a> 126 - 127 - <a 128 - href="/bookmarks" 129 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 130 - onClick={closeMenu} 131 - > 132 - <Bookmark size={20} /> 133 - <span>Bookmarks</span> 134 - </a> 135 - 136 - <a 137 - href="/collections" 138 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 139 - onClick={closeMenu} 140 - > 141 - <Folder size={20} /> 142 - <span>Collections</span> 143 - </a> 144 - 145 - <a 146 - href="/settings" 147 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 148 - onClick={closeMenu} 149 - > 150 - <Settings size={20} /> 151 - <span>Settings</span> 152 - </a> 101 + {[ 102 + { 103 + href: "/annotations", 104 + icon: MessageSquareText, 105 + label: "Annotations", 106 + }, 107 + { 108 + href: "/highlights", 109 + icon: Highlighter, 110 + label: "Highlights", 111 + }, 112 + { href: "/bookmarks", icon: Bookmark, label: "Bookmarks" }, 113 + { href: "/collections", icon: Folder, label: "Collections" }, 114 + { href: "/settings", icon: Settings, label: "Settings" }, 115 + ].map(({ href, icon: Icon, label }) => ( 116 + <a 117 + key={href} 118 + href={href} 119 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 120 + onClick={(e) => { 121 + if (onNavigate) { 122 + e.preventDefault(); 123 + onNavigate(href); 124 + } 125 + closeMenu(); 126 + }} 127 + > 128 + <Icon size={20} /> 129 + <span>{label}</span> 130 + </a> 131 + ))} 153 132 154 133 <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 155 134 ··· 187 166 <User size={20} /> 188 167 <span>Sign In</span> 189 168 </a> 190 - <a 191 - href="/collections" 192 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 193 - onClick={closeMenu} 194 - > 195 - <Folder size={20} /> 196 - <span>Collections</span> 197 - </a> 198 - <a 199 - href="/settings" 200 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 201 - onClick={closeMenu} 202 - > 203 - <Settings size={20} /> 204 - <span>Settings</span> 205 - </a> 169 + {[ 170 + { href: "/collections", icon: Folder, label: "Collections" }, 171 + { href: "/settings", icon: Settings, label: "Settings" }, 172 + ].map(({ href, icon: Icon, label }) => ( 173 + <a 174 + key={href} 175 + href={href} 176 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 177 + onClick={(e) => { 178 + if (onNavigate) { 179 + e.preventDefault(); 180 + onNavigate(href); 181 + } 182 + closeMenu(); 183 + }} 184 + > 185 + <Icon size={20} /> 186 + <span>{label}</span> 187 + </a> 188 + ))} 206 189 207 190 <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 208 191 ··· 225 208 <nav className="fixed bottom-0 left-0 right-0 h-14 bg-white/90 dark:bg-surface-900/90 backdrop-blur-md border-t border-surface-200 dark:border-surface-700 flex items-center justify-around px-2 z-50 md:hidden safe-area-bottom"> 226 209 <a 227 210 href="/home" 228 - data-astro-prefetch="viewport" 229 211 className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 230 212 isActive("/home") 231 213 ? "text-primary-600" 232 214 : "text-surface-500 hover:text-surface-700" 233 215 }`} 234 - onClick={() => { 216 + onClick={(e) => { 217 + if (onNavigate) { 218 + e.preventDefault(); 219 + onNavigate("/home"); 220 + } 235 221 setCurrentPath("/home"); 236 222 closeMenu(); 237 223 }} ··· 241 227 242 228 <a 243 229 href="/search" 244 - data-astro-prefetch="viewport" 245 230 className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 246 231 isActive("/search") 247 232 ? "text-primary-600" 248 233 : "text-surface-500 hover:text-surface-700" 249 234 }`} 250 - onClick={() => { 235 + onClick={(e) => { 236 + if (onNavigate) { 237 + e.preventDefault(); 238 + onNavigate("/search"); 239 + } 251 240 setCurrentPath("/search"); 252 241 closeMenu(); 253 242 }} ··· 259 248 <> 260 249 <a 261 250 href="/new" 262 - data-astro-prefetch="viewport" 263 251 className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 264 - onClick={() => { 252 + onClick={(e) => { 253 + if (onNavigate) { 254 + e.preventDefault(); 255 + onNavigate("/new"); 256 + } 265 257 setCurrentPath("/new"); 266 258 closeMenu(); 267 259 }} ··· 271 263 272 264 <a 273 265 href="/notifications" 274 - data-astro-prefetch="viewport" 275 266 className={`relative flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 276 267 isActive("/notifications") 277 268 ? "text-primary-600" 278 269 : "text-surface-500 hover:text-surface-700" 279 270 }`} 280 - onClick={() => { 271 + onClick={(e) => { 272 + if (onNavigate) { 273 + e.preventDefault(); 274 + onNavigate("/notifications"); 275 + } 281 276 setCurrentPath("/notifications"); 282 277 closeMenu(); 283 278 }}
+41 -48
web/src/components/navigation/RightSidebar.tsx
··· 1 - import React, { useCallback, useEffect, useRef, useState } from "react"; 1 + import React, { useEffect, useRef, useState } from "react"; 2 2 import { Search, Coffee } from "lucide-react"; 3 3 import { 4 4 getTrendingTags, ··· 17 17 ); 18 18 } 19 19 20 - function navigate(path: string) { 21 - window.location.href = path; 20 + interface RightSidebarProps { 21 + onNavigate?: (path: string) => void; 22 22 } 23 23 24 - export default function RightSidebar() { 24 + export default function RightSidebar({ onNavigate }: RightSidebarProps) { 25 + const navigate = (path: string) => { 26 + if (onNavigate) onNavigate(path); 27 + else window.location.href = path; 28 + }; 25 29 const [tags, setTags] = useState<Tag[]>([]); 26 30 const [browser] = useState<"chrome" | "firefox" | "edge" | "other">(() => { 27 31 if (typeof navigator === "undefined") return "other"; ··· 84 88 return () => document.removeEventListener("mousedown", handleClickOutside); 85 89 }, []); 86 90 87 - const selectSuggestion = useCallback((actor: ActorSearchItem) => { 91 + const selectSuggestion = (actor: ActorSearchItem) => { 88 92 isSelectionRef.current = true; 89 93 setSearchQuery(""); 90 94 setSuggestions([]); 91 95 setShowSuggestions(false); 92 96 navigate(`/profile/${encodeURIComponent(actor.handle)}`); 93 - }, []); 97 + }; 94 98 95 - const handleKeyDown = useCallback( 96 - (e: React.KeyboardEvent) => { 97 - if (showSuggestions && suggestions.length > 0) { 98 - if (e.key === "ArrowDown") { 99 - e.preventDefault(); 100 - setSelectedIndex((prev) => 101 - Math.min(prev + 1, suggestions.length - 1), 102 - ); 103 - return; 104 - } else if (e.key === "ArrowUp") { 105 - e.preventDefault(); 106 - setSelectedIndex((prev) => Math.max(prev - 1, -1)); 107 - return; 108 - } else if (e.key === "Enter" && selectedIndex >= 0) { 109 - e.preventDefault(); 110 - selectSuggestion(suggestions[selectedIndex]); 111 - return; 112 - } else if (e.key === "Escape") { 113 - setShowSuggestions(false); 114 - return; 115 - } 99 + const handleKeyDown = (e: React.KeyboardEvent) => { 100 + if (showSuggestions && suggestions.length > 0) { 101 + if (e.key === "ArrowDown") { 102 + e.preventDefault(); 103 + setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 104 + return; 105 + } else if (e.key === "ArrowUp") { 106 + e.preventDefault(); 107 + setSelectedIndex((prev) => Math.max(prev - 1, -1)); 108 + return; 109 + } else if (e.key === "Enter" && selectedIndex >= 0) { 110 + e.preventDefault(); 111 + selectSuggestion(suggestions[selectedIndex]); 112 + return; 113 + } else if (e.key === "Escape") { 114 + setShowSuggestions(false); 115 + return; 116 116 } 117 + } 117 118 118 - if (e.key === "Enter" && searchQuery.trim()) { 119 - const q = searchQuery.trim(); 120 - if (looksLikeUrl(q)) { 121 - navigate(`/url/${encodeURIComponent(q)}`); 122 - } else if (q.includes(".")) { 123 - navigate(`/profile/${encodeURIComponent(q)}`); 124 - } else { 125 - navigate(`/search?q=${encodeURIComponent(q)}`); 126 - } 127 - setSearchQuery(""); 128 - setSuggestions([]); 129 - setShowSuggestions(false); 119 + if (e.key === "Enter" && searchQuery.trim()) { 120 + const q = searchQuery.trim(); 121 + if (looksLikeUrl(q)) { 122 + navigate(`/url/${encodeURIComponent(q)}`); 123 + } else if (q.includes(".")) { 124 + navigate(`/profile/${encodeURIComponent(q)}`); 125 + } else { 126 + navigate(`/search?q=${encodeURIComponent(q)}`); 130 127 } 131 - }, 132 - [ 133 - showSuggestions, 134 - suggestions, 135 - selectedIndex, 136 - searchQuery, 137 - selectSuggestion, 138 - ], 139 - ); 128 + setSearchQuery(""); 129 + setSuggestions([]); 130 + setShowSuggestions(false); 131 + } 132 + }; 140 133 141 134 useEffect(() => { 142 135 getTrendingTags(10).then(setTags);
+45 -20
web/src/components/navigation/Sidebar.tsx
··· 1 - import React, { useEffect, useState } from "react"; 1 + import { useEffect, useState } from "react"; 2 2 import { 3 3 Home, 4 4 Bookmark, ··· 20 20 import { $theme, cycleTheme } from "../../store/theme"; 21 21 import { getUnreadNotificationCount } from "../../api/client"; 22 22 import { Avatar, CountBadge } from "../ui"; 23 - import type { UserProfile } from "../../types"; 24 23 25 24 interface SidebarProps { 26 - initialUser?: UserProfile | null; 27 25 currentPath?: string; 26 + onNavigate?: (path: string) => void; 28 27 } 29 28 30 29 export default function Sidebar({ 31 - initialUser, 32 30 currentPath: initialPath, 31 + onNavigate, 33 32 }: SidebarProps) { 34 - const storeUser = useStore($user); 35 - const user = storeUser || initialUser || null; 33 + const user = useStore($user); 36 34 const theme = useStore($theme); 37 - const [currentPath, setCurrentPath] = useState(initialPath || "/"); 35 + const currentPath = initialPath || "/"; 38 36 const [unreadCount, setUnreadCount] = useState(0); 39 37 40 38 useEffect(() => { 41 - if (initialUser && !storeUser) { 42 - $user.set(initialUser); 43 - } 44 - }, [initialUser, storeUser]); 45 - 46 - useEffect(() => { 47 - const handler = () => setCurrentPath(window.location.pathname); 48 - document.addEventListener("astro:page-load", handler); 49 - return () => document.removeEventListener("astro:page-load", handler); 50 - }, []); 51 - 52 - useEffect(() => { 53 39 if (!user) return; 54 40 55 41 const checkNotifications = async () => { ··· 107 93 <div className="flex flex-col gap-6"> 108 94 <a 109 95 href="/home" 96 + onClick={ 97 + onNavigate 98 + ? (e) => { 99 + e.preventDefault(); 100 + onNavigate("/home"); 101 + } 102 + : undefined 103 + } 110 104 className="px-3 hover:opacity-80 transition-opacity w-fit flex items-center gap-2.5" 111 105 > 112 106 <img src="/logo.svg" alt="Margin" className="w-8 h-8" /> ··· 122 116 key={item.href} 123 117 href={item.href} 124 118 title={item.label} 125 - data-astro-prefetch="viewport" 119 + onClick={ 120 + onNavigate 121 + ? (e) => { 122 + e.preventDefault(); 123 + onNavigate(item.href); 124 + } 125 + : undefined 126 + } 126 127 className={`flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg transition-all duration-150 text-[14px] group ${ 127 128 isActive 128 129 ? "font-semibold text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-950/40" ··· 146 147 <a 147 148 href="/new" 148 149 title="New annotation" 150 + onClick={ 151 + onNavigate 152 + ? (e) => { 153 + e.preventDefault(); 154 + onNavigate("/new"); 155 + } 156 + : undefined 157 + } 149 158 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 mt-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors text-[14px] font-semibold" 150 159 > 151 160 <PenSquare size={20} strokeWidth={1.75} /> ··· 180 189 <a 181 190 href="/settings" 182 191 title="Settings" 192 + onClick={ 193 + onNavigate 194 + ? (e) => { 195 + e.preventDefault(); 196 + onNavigate("/settings"); 197 + } 198 + : undefined 199 + } 183 200 className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition-colors" 184 201 > 185 202 <Settings size={18} /> ··· 191 208 <a 192 209 href={`/profile/${user.did}`} 193 210 title={user.displayName || user.handle} 211 + onClick={ 212 + onNavigate 213 + ? (e) => { 214 + e.preventDefault(); 215 + onNavigate(`/profile/${user.did}`); 216 + } 217 + : undefined 218 + } 194 219 className="flex items-center justify-center lg:justify-start gap-2.5 p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors w-full" 195 220 > 196 221 <Avatar did={user.did} avatar={user.avatar} size="sm" />
-32
web/src/layouts/AppLayout.astro
··· 3 3 import Sidebar from "../components/navigation/Sidebar"; 4 4 import RightSidebar from "../components/navigation/RightSidebar"; 5 5 import MobileNav from "../components/navigation/MobileNav"; 6 - import { BlueskyIcon } from "../components/common/Icons"; 7 6 import type { UserProfile } from "../types"; 8 7 9 8 interface Props { ··· 17 16 --- 18 17 19 18 <BaseLayout title={title} description={description} image={image}> 20 - <div 21 - class="bg-blue-600 dark:bg-blue-500 text-white font-medium text-sm flex items-center justify-center gap-x-3 gap-y-1 flex-wrap py-2 px-4 w-full z-50" 22 - > 23 - <div 24 - class="w-12 h-9 overflow-hidden rounded flex items-start justify-center -my-1" 25 - > 26 - <img 27 - src="https://atmosphereconf.org/_image?href=%2F_astro%2Fgoodstuff-goose.DKPXDrcQ.png&w=792&h=990&f=webp" 28 - alt="Atmosphere Goose" 29 - class="w-12 h-12 object-cover object-top drop-shadow-md" 30 - /> 31 - </div> 32 - <span 33 - >Welcome to <a 34 - href="https://atmosphereconf.org/" 35 - target="_blank" 36 - rel="noopener noreferrer" 37 - class="font-bold underline hover:text-blue-200 transition-colors" 38 - >ATmosphereConf 2026</a 39 - >!</span 40 - > 41 - <a 42 - href="https://bsky.app/profile/atmosphereconf.org/feed/atmosphereconf" 43 - target="_blank" 44 - rel="noopener noreferrer" 45 - class="hover:text-blue-200 transition-colors flex items-center gap-1.5 ml-1 bg-blue-700/50 hover:bg-blue-700 px-2 py-0.5 rounded-full" 46 - > 47 - <BlueskyIcon size={14} color="currentColor" /> 48 - <span>View feed</span> 49 - </a> 50 - </div> 51 19 <div class="min-h-screen bg-surface-100 dark:bg-surface-900 flex"> 52 20 <div transition:persist="sidebar"> 53 21 <Sidebar
-40
web/src/layouts/AppLayout.tsx
··· 1 - import React from "react"; 2 - import { useStore } from "@nanostores/react"; 3 - import Sidebar from "../components/navigation/Sidebar"; 4 - import RightSidebar from "../components/navigation/RightSidebar"; 5 - import MobileNav from "../components/navigation/MobileNav"; 6 - import { $theme } from "../store/theme"; 7 - 8 - interface AppLayoutProps { 9 - children: React.ReactNode; 10 - } 11 - 12 - export default function AppLayout({ children }: AppLayoutProps) { 13 - useStore($theme); 14 - 15 - return ( 16 - <div className="min-h-screen bg-surface-100 dark:bg-surface-900 flex"> 17 - <Sidebar /> 18 - 19 - <div className="flex-1 min-w-0 transition-all duration-200"> 20 - <div className="flex w-full max-w-[1800px] mx-auto"> 21 - <main className="flex-1 w-full min-w-0 py-2 md:py-3"> 22 - <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6"> 23 - {children} 24 - </div> 25 - </main> 26 - 27 - <RightSidebar /> 28 - </div> 29 - </div> 30 - 31 - <MobileNav /> 32 - </div> 33 - ); 34 - } 35 - 36 - export function LandingLayout({ children }: AppLayoutProps) { 37 - return ( 38 - <div className="min-h-screen bg-white dark:bg-surface-950">{children}</div> 39 - ); 40 - }
+4 -1
web/src/layouts/BaseLayout.astro
··· 12 12 --- 13 13 14 14 <!DOCTYPE html> 15 - <html lang="en"> 15 + <html lang="en" style="color-scheme: light dark"> 16 16 <head> 17 17 <meta charset="UTF-8" /> 18 18 <meta name="description" content={description} /> ··· 22 22 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 23 23 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 24 24 <meta name="generator" content={Astro.generator} /> 25 + <meta name="color-scheme" content="light dark" /> 26 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" /> 27 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f172a" /> 25 28 <meta property="og:type" content="website" /> 26 29 <meta property="og:url" content={Astro.url} /> 27 30 <meta property="og:title" content={title} />
+58
web/src/layouts/StaticLayout.astro
··· 1 + --- 2 + import '../styles/global.css'; 3 + 4 + interface Props { 5 + title?: string; 6 + description?: string; 7 + image?: string; 8 + } 9 + 10 + const { title = 'Margin', description = 'Annotate the web', image = 'https://margin.at/og.png' } = Astro.props; 11 + --- 12 + 13 + <!DOCTYPE html> 14 + <html lang="en" style="color-scheme: light dark"> 15 + <head> 16 + <meta charset="UTF-8" /> 17 + <meta name="description" content={description} /> 18 + <meta name="viewport" content="width=device-width" /> 19 + <link rel="icon" type="image/svg+xml" href="/logo.svg" /> 20 + <link rel="preconnect" href="https://fonts.googleapis.com"> 21 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 23 + <meta name="generator" content={Astro.generator} /> 24 + <meta name="color-scheme" content="light dark" /> 25 + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" /> 26 + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f172a" /> 27 + <meta property="og:type" content="website" /> 28 + <meta property="og:url" content="https://margin.at" /> 29 + <meta property="og:title" content={title} /> 30 + <meta property="og:description" content={description} /> 31 + <meta property="og:image" content={image} /> 32 + <meta property="og:site_name" content="Margin" /> 33 + <meta name="twitter:card" content="summary_large_image" /> 34 + <meta name="twitter:title" content={title} /> 35 + <meta name="twitter:description" content={description} /> 36 + <meta name="twitter:image" content={image} /> 37 + 38 + <title>{title}</title> 39 + 40 + <script is:inline> 41 + (function() { 42 + function applyTheme() { 43 + var theme = localStorage.getItem('theme') || 'system'; 44 + var root = document.documentElement; 45 + if (theme === 'system') { 46 + root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 47 + } else { 48 + root.dataset.theme = theme; 49 + } 50 + } 51 + applyTheme(); 52 + })(); 53 + </script> 54 + </head> 55 + <body class="bg-surface-50 dark:bg-surface-950 min-h-screen text-surface-900 dark:text-white"> 56 + <slot /> 57 + </body> 58 + </html>
+51 -431
web/src/lib/api.ts
··· 1 - import type { 2 - AnnotationItem, 3 - UserProfile, 4 - FeedResponse, 5 - Collection, 6 - NotificationItem, 7 - Target, 8 - Selector, 9 - } from "../types"; 1 + import type { UserProfile } from "../types"; 10 2 11 3 const API_URL = 12 4 process.env.API_URL || `http://localhost:${process.env.API_PORT || 8081}`; 13 5 14 - interface RawItem { 15 - type?: string; 16 - collectionUri?: string; 17 - annotation?: RawItem; 18 - highlight?: RawItem; 19 - bookmark?: RawItem; 20 - uri?: string; 21 - id?: string; 22 - cid?: string; 23 - author?: UserProfile; 24 - creator?: UserProfile; 25 - collection?: { uri: string; name: string; icon?: string }; 26 - context?: { uri: string; name: string; icon?: string }[]; 27 - created?: string; 28 - createdAt?: string; 29 - target?: string | { source?: string; title?: string; selector?: Selector }; 30 - url?: string; 31 - targetUrl?: string; 32 - title?: string; 33 - selector?: Selector; 34 - viewer?: { like?: string; [key: string]: unknown }; 35 - viewerHasLiked?: boolean; 36 - motivation?: string; 37 - [key: string]: unknown; 38 - } 39 - 40 - export function normalizeItem(raw: RawItem): AnnotationItem { 41 - if (raw.type === "CollectionItem" || raw.collectionUri) { 42 - const inner = raw.annotation || raw.highlight || raw.bookmark || {}; 43 - const normalizedInner = normalizeItem(inner); 44 - return { 45 - ...normalizedInner, 46 - uri: normalizedInner.uri || raw.uri || "", 47 - cid: normalizedInner.cid || raw.cid || "", 48 - author: (normalizedInner.author || 49 - raw.author || 50 - raw.creator) as UserProfile, 51 - collection: raw.collection 52 - ? { 53 - uri: raw.collection.uri, 54 - name: raw.collection.name, 55 - icon: raw.collection.icon, 56 - } 57 - : undefined, 58 - context: raw.context?.map((c) => ({ 59 - uri: c.uri, 60 - name: c.name, 61 - icon: c.icon, 62 - })), 63 - addedBy: raw.creator || raw.author, 64 - createdAt: 65 - normalizedInner.createdAt || 66 - raw.created || 67 - raw.createdAt || 68 - new Date().toISOString(), 69 - collectionItemUri: raw.id || raw.uri, 70 - }; 71 - } 72 - 73 - let target: Target | undefined; 74 - if (raw.target) { 75 - if (typeof raw.target === "string") { 76 - target = { source: raw.target, title: raw.title, selector: raw.selector }; 77 - } else { 78 - target = { 79 - source: raw.target.source || "", 80 - title: raw.target.title || raw.title, 81 - selector: raw.target.selector || raw.selector, 82 - }; 83 - } 84 - } 85 - if (!target || !target.source) { 86 - const url = 87 - raw.url || 88 - raw.targetUrl || 89 - (typeof raw.target === "string" ? raw.target : raw.target?.source); 90 - if (url) { 91 - target = { 92 - source: url, 93 - title: 94 - raw.title || 95 - (typeof raw.target !== "string" ? raw.target?.title : undefined), 96 - selector: 97 - raw.selector || 98 - (typeof raw.target !== "string" ? raw.target?.selector : undefined), 99 - }; 100 - } 101 - } 102 - 103 - return { 104 - ...raw, 105 - uri: raw.id || raw.uri || "", 106 - cid: raw.cid || "", 107 - author: (raw.creator || raw.author) as UserProfile, 108 - createdAt: raw.created || raw.createdAt || new Date().toISOString(), 109 - target, 110 - viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined }, 111 - motivation: raw.motivation || "highlighting", 112 - parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined, 113 - }; 114 - } 115 - 116 - async function serverFetch(path: string, cookie?: string): Promise<Response> { 6 + function serverFetch(path: string, cookie?: string): Promise<Response> { 117 7 const headers: Record<string, string> = { 118 8 "Content-Type": "application/json", 119 9 }; ··· 122 12 } 123 13 124 14 const sessionCache = new Map<string, { user: UserProfile; expires: number }>(); 15 + 16 + export function clearSessionCacheForCookie(cookie: string) { 17 + const cacheKey = cookie.match(/margin_session=([^;]+)/)?.[1] || ""; 18 + if (cacheKey) sessionCache.delete(cacheKey); 19 + } 125 20 126 21 export async function getSession(cookie: string): Promise<UserProfile | null> { 127 22 try { ··· 149 44 postsCount: data.postsCount, 150 45 }; 151 46 152 - // Fetch bsky profile and margin profile in parallel with a 3s timeout 153 - const controller = new AbortController(); 154 - const timeout = setTimeout(() => controller.abort(), 3000); 155 - 156 - const [bskyRes, marginRes] = await Promise.allSettled([ 157 - fetch( 158 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 159 - { signal: controller.signal }, 160 - ), 161 - serverFetch(`/api/profile/${data.did}`, cookie), 162 - ]); 163 - 164 - clearTimeout(timeout); 165 - 166 - if (bskyRes.status === "fulfilled" && bskyRes.value.ok) { 167 - try { 168 - const bsky = await bskyRes.value.json(); 169 - if (bsky.avatar) profile.avatar = bsky.avatar; 170 - if (bsky.displayName) profile.displayName = bsky.displayName; 171 - } catch { 172 - /* ignore */ 173 - } 174 - } 175 - 176 - if (marginRes.status === "fulfilled" && marginRes.value.ok) { 177 - try { 178 - const mp = await marginRes.value.json(); 179 - if (mp?.description) profile.description = mp.description; 180 - if (mp?.followersCount) profile.followersCount = mp.followersCount; 181 - if (mp?.followsCount) profile.followsCount = mp.followsCount; 182 - if (mp?.postsCount) profile.postsCount = mp.postsCount; 183 - if (mp?.website) profile.website = mp.website; 184 - if (mp?.links) profile.links = mp.links; 185 - } catch { 186 - /* ignore */ 187 - } 188 - } 189 - 190 47 if (cacheKey) { 191 48 sessionCache.set(cacheKey, { 192 49 user: profile, 193 - expires: Date.now() + 30_000, 50 + expires: Date.now() + 5 * 60_000, 194 51 }); 195 - // Evict old entries 196 52 if (sessionCache.size > 100) { 197 53 const now = Date.now(); 198 54 for (const [k, v] of sessionCache) { ··· 201 57 } 202 58 } 203 59 204 - return profile; 205 - } catch { 206 - return null; 207 - } 208 - } 209 - 210 - export interface GetFeedParams { 211 - type?: string; 212 - limit?: number; 213 - offset?: number; 214 - motivation?: string; 215 - tag?: string; 216 - creator?: string; 217 - source?: string; 218 - } 219 - 220 - function groupFeedItems(items: AnnotationItem[]): AnnotationItem[] { 221 - if (items.length === 0) return items; 222 - const grouped: AnnotationItem[] = [items[0]]; 223 - for (let i = 1; i < items.length; i++) { 224 - const prev = grouped[grouped.length - 1]; 225 - const curr = items[i]; 226 - if ( 227 - prev.collection && 228 - curr.collection && 229 - prev.uri === curr.uri && 230 - prev.addedBy?.did === curr.addedBy?.did 231 - ) { 232 - if (!prev.context) prev.context = [prev.collection]; 233 - if (!prev.context.find((c) => c.uri === curr.collection!.uri)) { 234 - prev.context.push(curr.collection); 235 - } 236 - continue; 237 - } 238 - grouped.push(curr); 239 - } 240 - return grouped; 241 - } 242 - 243 - export async function getFeed( 244 - cookie: string, 245 - params: GetFeedParams = {}, 246 - ): Promise<FeedResponse> { 247 - const qs = new URLSearchParams(); 248 - if (params.source) qs.append("source", params.source); 249 - if (params.type) qs.append("type", params.type); 250 - if (params.limit) qs.append("limit", params.limit.toString()); 251 - if (params.offset) qs.append("offset", params.offset.toString()); 252 - if (params.motivation) qs.append("motivation", params.motivation); 253 - if (params.tag) qs.append("tag", params.tag); 254 - if (params.creator) qs.append("creator", params.creator); 255 - 256 - const endpoint = params.source ? "/api/targets" : "/api/annotations/feed"; 257 - try { 258 - const res = await serverFetch(`${endpoint}?${qs.toString()}`, cookie); 259 - if (!res.ok) return { items: [], hasMore: false, fetchedCount: 0 }; 260 - const data = await res.json(); 261 - const items = (data.items || []).map(normalizeItem); 262 - return { 263 - items: groupFeedItems(items), 264 - hasMore: items.length >= (params.limit || 50), 265 - fetchedCount: items.length, 266 - }; 267 - } catch { 268 - return { items: [], hasMore: false, fetchedCount: 0 }; 269 - } 270 - } 271 - 272 - export async function searchItems( 273 - cookie: string, 274 - query: string, 275 - options: { creator?: string; limit?: number; offset?: number } = {}, 276 - ): Promise<FeedResponse> { 277 - const qs = new URLSearchParams(); 278 - qs.append("q", query); 279 - if (options.creator) qs.append("creator", options.creator); 280 - if (options.limit) qs.append("limit", options.limit.toString()); 281 - if (options.offset) qs.append("offset", options.offset.toString()); 282 - 283 - try { 284 - const res = await serverFetch(`/api/search?${qs.toString()}`, cookie); 285 - if (!res.ok) return { items: [], hasMore: false, fetchedCount: 0 }; 286 - const data = await res.json(); 287 - const items = (data.items || []).map(normalizeItem); 288 - return { 289 - items, 290 - hasMore: items.length >= (options.limit || 50), 291 - fetchedCount: items.length, 292 - }; 293 - } catch { 294 - return { items: [], hasMore: false, fetchedCount: 0 }; 295 - } 296 - } 60 + const controller = new AbortController(); 61 + const timeout = setTimeout(() => controller.abort(), 3000); 297 62 298 - export async function getAnnotation( 299 - cookie: string, 300 - uri: string, 301 - ): Promise<AnnotationItem | null> { 302 - try { 303 - const res = await serverFetch( 304 - `/api/annotation?uri=${encodeURIComponent(uri)}`, 305 - cookie, 306 - ); 307 - if (!res.ok) return null; 308 - const data = await res.json(); 309 - return normalizeItem(data); 310 - } catch { 311 - return null; 312 - } 313 - } 63 + Promise.allSettled([ 64 + fetch( 65 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 66 + { signal: controller.signal }, 67 + ), 68 + serverFetch(`/api/profile/${data.did}`, cookie), 69 + ]) 70 + .then(([bskyRes, marginRes]) => { 71 + clearTimeout(timeout); 314 72 315 - export async function getReplies( 316 - cookie: string, 317 - uri: string, 318 - ): Promise<AnnotationItem[]> { 319 - try { 320 - const res = await serverFetch( 321 - `/api/replies?uri=${encodeURIComponent(uri)}`, 322 - cookie, 323 - ); 324 - if (!res.ok) return []; 325 - const data = await res.json(); 326 - return (data.items || []).map(normalizeItem); 327 - } catch { 328 - return []; 329 - } 330 - } 73 + if (bskyRes.status === "fulfilled" && bskyRes.value.ok) { 74 + bskyRes.value 75 + .json() 76 + .then((bsky) => { 77 + if (bsky.avatar) profile.avatar = bsky.avatar; 78 + if (bsky.displayName) profile.displayName = bsky.displayName; 79 + }) 80 + .catch(() => { 81 + /* ignore */ 82 + }); 83 + } 331 84 332 - export async function getProfile( 333 - cookie: string, 334 - did: string, 335 - ): Promise<UserProfile | null> { 336 - try { 337 - const res = await serverFetch( 338 - `/api/profile/${encodeURIComponent(did)}`, 339 - cookie, 340 - ); 341 - if (!res.ok) return null; 342 - return await res.json(); 343 - } catch { 344 - return null; 345 - } 346 - } 347 - 348 - export async function getCollections( 349 - cookie: string, 350 - author?: string, 351 - ): Promise<Collection[]> { 352 - const qs = author ? `?author=${encodeURIComponent(author)}` : ""; 353 - try { 354 - const res = await serverFetch(`/api/collections${qs}`, cookie); 355 - if (!res.ok) return []; 356 - const data = await res.json(); 357 - return data.collections || data || []; 358 - } catch { 359 - return []; 360 - } 361 - } 85 + if (marginRes.status === "fulfilled" && marginRes.value.ok) { 86 + marginRes.value 87 + .json() 88 + .then((mp) => { 89 + if (mp?.description) profile.description = mp.description; 90 + if (mp?.followersCount) 91 + profile.followersCount = mp.followersCount; 92 + if (mp?.followsCount) profile.followsCount = mp.followsCount; 93 + if (mp?.postsCount) profile.postsCount = mp.postsCount; 94 + if (mp?.website) profile.website = mp.website; 95 + if (mp?.links) profile.links = mp.links; 96 + }) 97 + .catch(() => { 98 + /* ignore */ 99 + }); 100 + } 101 + }) 102 + .catch(() => { 103 + clearTimeout(timeout); 104 + }); 362 105 363 - export async function getCollection( 364 - cookie: string, 365 - uri: string, 366 - ): Promise<Collection | null> { 367 - try { 368 - const res = await serverFetch( 369 - `/api/collection?uri=${encodeURIComponent(uri)}`, 370 - cookie, 371 - ); 372 - if (!res.ok) return null; 373 - return await res.json(); 374 - } catch { 375 - return null; 376 - } 377 - } 378 - 379 - export async function getCollectionItems( 380 - cookie: string, 381 - uri: string, 382 - ): Promise<AnnotationItem[]> { 383 - try { 384 - const res = await serverFetch( 385 - `/api/collections/${encodeURIComponent(uri)}/items`, 386 - cookie, 387 - ); 388 - if (!res.ok) return []; 389 - const data = await res.json(); 390 - return (data.items || data || []).map(normalizeItem); 391 - } catch { 392 - return []; 393 - } 394 - } 395 - 396 - export async function getNotifications( 397 - cookie: string, 398 - limit = 50, 399 - offset = 0, 400 - ): Promise<{ items: NotificationItem[]; hasMore: boolean }> { 401 - try { 402 - const res = await serverFetch( 403 - `/api/notifications?limit=${limit}&offset=${offset}`, 404 - cookie, 405 - ); 406 - if (!res.ok) return { items: [], hasMore: false }; 407 - const data = await res.json(); 408 - return { 409 - items: data.notifications || [], 410 - hasMore: (data.notifications || []).length >= limit, 411 - }; 412 - } catch { 413 - return { items: [], hasMore: false }; 414 - } 415 - } 416 - 417 - export async function getTrendingTags( 418 - limit = 10, 419 - ): Promise<{ tag: string; count: number }[]> { 420 - try { 421 - const res = await serverFetch(`/api/tags/trending?limit=${limit}`); 422 - if (!res.ok) return []; 423 - return await res.json(); 424 - } catch { 425 - return []; 426 - } 427 - } 428 - 429 - export async function getRecommendations( 430 - cookie: string, 431 - params: { sort?: string; limit?: number; offset?: number } = {}, 432 - ): Promise<{ items: AnnotationItem[]; hasMore: boolean }> { 433 - const qs = new URLSearchParams(); 434 - if (params.sort) qs.append("sort", params.sort); 435 - if (params.limit) qs.append("limit", params.limit.toString()); 436 - if (params.offset) qs.append("offset", params.offset.toString()); 437 - 438 - try { 439 - const res = await serverFetch(`/api/documents?${qs.toString()}`, cookie); 440 - if (!res.ok) return { items: [], hasMore: false }; 441 - const data = await res.json(); 442 - return { 443 - items: data.items || [], 444 - hasMore: (data.items || []).length >= (params.limit || 20), 445 - }; 446 - } catch { 447 - return { items: [], hasMore: false }; 448 - } 449 - } 450 - 451 - export async function getByTarget( 452 - cookie: string, 453 - url: string, 454 - limit = 50, 455 - offset = 0, 456 - ): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { 457 - try { 458 - const res = await serverFetch( 459 - `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 460 - cookie, 461 - ); 462 - if (!res.ok) return { annotations: [], highlights: [] }; 463 - const data = await res.json(); 464 - const items = (data.items || []).map(normalizeItem); 465 - return { 466 - annotations: items.filter( 467 - (i: AnnotationItem) => i.motivation === "commenting", 468 - ), 469 - highlights: items.filter( 470 - (i: AnnotationItem) => i.motivation === "highlighting", 471 - ), 472 - }; 473 - } catch { 474 - return { annotations: [], highlights: [] }; 475 - } 476 - } 477 - 478 - export async function resolveHandle(handle: string): Promise<string | null> { 479 - if (handle.startsWith("did:")) return handle; 480 - try { 481 - const res = await fetch( 482 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 483 - ); 484 - if (!res.ok) return null; 485 - const data = await res.json(); 486 - return data.did || null; 106 + return profile; 487 107 } catch { 488 108 return null; 489 109 }
+9 -2
web/src/middleware.ts
··· 1 1 import type { APIContext } from "astro"; 2 2 import { readFile } from "node:fs/promises"; 3 3 import { join } from "node:path"; 4 - import { getSession } from "./lib/api"; 4 + import { clearSessionCacheForCookie, getSession } from "./lib/api"; 5 5 6 6 const API_PORT = process.env.API_PORT || 8081; 7 7 const API_URL = process.env.API_URL || `http://localhost:${API_PORT}`; ··· 53 53 ); 54 54 55 55 if (shouldProxy) { 56 - return proxyToBackend(request, url); 56 + const response = await proxyToBackend(request, url); 57 + if (url.pathname === "/auth/logout") { 58 + clearSessionCacheForCookie(request.headers.get("cookie") || ""); 59 + } 60 + return response; 57 61 } 58 62 59 63 const cookie = request.headers.get("cookie") || ""; 64 + 60 65 if (cookie.includes("margin_session")) { 61 66 locals.user = await getSession(cookie); 62 67 } else { ··· 84 89 const headers = new Headers(request.headers); 85 90 const host = headers.get("host"); 86 91 headers.delete("host"); 92 + headers.delete("origin"); 93 + headers.delete("referer"); 87 94 if (host) { 88 95 headers.set("X-Forwarded-Host", host); 89 96 headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
+13
web/src/pages/[...appPath].astro
··· 1 + --- 2 + import BaseLayout from '../layouts/BaseLayout.astro'; 3 + import AppShell from '../views/AppShell'; 4 + 5 + const user = Astro.locals.user ?? null; 6 + --- 7 + 8 + <BaseLayout> 9 + <script is:inline define:vars={{ initialUser: user }}> 10 + window.__MARGIN_USER__ = initialUser; 11 + </script> 12 + <AppShell client:only="react" /> 13 + </BaseLayout>
-46
web/src/pages/[handle]/annotation/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../../layouts/AppLayout.astro'; 4 - import AnnotationDetail from '../../../views/content/AnnotationDetail'; 5 - import { resolveHandle, fetchOGForRoute } from '../../../lib/og'; 6 - import { getAnnotation, getReplies } from '../../../lib/api'; 7 - 8 - const { handle, rkey } = Astro.params; 9 - const user = Astro.locals.user; 10 - const cookie = Astro.request.headers.get('cookie') || ''; 11 - 12 - let title = 'Annotation - Margin'; 13 - let description = 'Annotate the web'; 14 - let image = 'https://margin.at/og.png'; 15 - let initialAnnotation = null; 16 - let initialReplies: any[] = []; 17 - let resolvedUri = ''; 18 - 19 - if (handle && rkey) { 20 - try { 21 - const did = await resolveHandle(handle); 22 - if (did) { 23 - resolvedUri = `at://${did}/at.margin.annotation/${rkey}`; 24 - const [ogData, annData, repData] = await Promise.all([ 25 - fetchOGForRoute(did, rkey, 'at.margin.annotation'), 26 - getAnnotation(cookie, resolvedUri), 27 - getReplies(cookie, resolvedUri), 28 - ]); 29 - if (ogData) { 30 - title = ogData.title; 31 - description = ogData.description; 32 - image = ogData.image; 33 - } 34 - initialAnnotation = annData; 35 - initialReplies = repData; 36 - } 37 - } catch (e) { 38 - console.error('OG/data fetch error (annotation):', e); 39 - } 40 - } 41 - --- 42 - 43 - <AppLayout title={title} description={description} image={image} user={user}> 44 - <AnnotationDetail client:idle handle={handle} rkey={rkey} type="annotation" 45 - initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} /> 46 - </AppLayout>
-46
web/src/pages/[handle]/bookmark/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../../layouts/AppLayout.astro'; 4 - import AnnotationDetail from '../../../views/content/AnnotationDetail'; 5 - import { resolveHandle, fetchOGForRoute } from '../../../lib/og'; 6 - import { getAnnotation, getReplies } from '../../../lib/api'; 7 - 8 - const { handle, rkey } = Astro.params; 9 - const user = Astro.locals.user; 10 - const cookie = Astro.request.headers.get('cookie') || ''; 11 - 12 - let title = 'Bookmark - Margin'; 13 - let description = 'Annotate the web'; 14 - let image = 'https://margin.at/og.png'; 15 - let initialAnnotation = null; 16 - let initialReplies: any[] = []; 17 - let resolvedUri = ''; 18 - 19 - if (handle && rkey) { 20 - try { 21 - const did = await resolveHandle(handle); 22 - if (did) { 23 - resolvedUri = `at://${did}/at.margin.bookmark/${rkey}`; 24 - const [ogData, annData, repData] = await Promise.all([ 25 - fetchOGForRoute(did, rkey, 'at.margin.bookmark'), 26 - getAnnotation(cookie, resolvedUri), 27 - getReplies(cookie, resolvedUri), 28 - ]); 29 - if (ogData) { 30 - title = ogData.title; 31 - description = ogData.description; 32 - image = ogData.image; 33 - } 34 - initialAnnotation = annData; 35 - initialReplies = repData; 36 - } 37 - } catch (e) { 38 - console.error('OG/data fetch error (bookmark):', e); 39 - } 40 - } 41 - --- 42 - 43 - <AppLayout title={title} description={description} image={image} user={user}> 44 - <AnnotationDetail client:idle handle={handle} rkey={rkey} type="bookmark" 45 - initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} /> 46 - </AppLayout>
-47
web/src/pages/[handle]/collection/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../../layouts/AppLayout.astro'; 4 - import CollectionDetail from '../../../views/collections/CollectionDetail'; 5 - import { resolveHandle, fetchCollectionOG } from '../../../lib/og'; 6 - import { getCollection, getCollectionItems } from '../../../lib/api'; 7 - 8 - const { handle, rkey } = Astro.params; 9 - const user = Astro.locals.user; 10 - const cookie = Astro.request.headers.get('cookie') || ''; 11 - 12 - let title = 'Collection - Margin'; 13 - let description = 'Annotate the web'; 14 - let image = 'https://margin.at/og.png'; 15 - let initialCollection = null; 16 - let initialItems: any[] = []; 17 - let resolvedUri = ''; 18 - 19 - if (handle && rkey) { 20 - try { 21 - const did = await resolveHandle(handle); 22 - if (did) { 23 - resolvedUri = `at://${did}/at.margin.collection/${rkey}`; 24 - const [ogData, col] = await Promise.all([ 25 - fetchCollectionOG(resolvedUri), 26 - getCollection(cookie, resolvedUri), 27 - ]); 28 - if (ogData) { 29 - title = ogData.title; 30 - description = ogData.description; 31 - image = ogData.image; 32 - } 33 - if (col) { 34 - initialCollection = col; 35 - initialItems = await getCollectionItems(cookie, col.uri); 36 - } 37 - } 38 - } catch (e) { 39 - console.error('OG/data fetch error (collection):', e); 40 - } 41 - } 42 - --- 43 - 44 - <AppLayout title={title} description={description} image={image} user={user}> 45 - <CollectionDetail client:idle handle={handle} rkey={rkey} 46 - initialCollection={initialCollection} initialItems={initialItems} resolvedUri={resolvedUri} /> 47 - </AppLayout>
-46
web/src/pages/[handle]/highlight/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../../layouts/AppLayout.astro'; 4 - import AnnotationDetail from '../../../views/content/AnnotationDetail'; 5 - import { resolveHandle, fetchOGForRoute } from '../../../lib/og'; 6 - import { getAnnotation, getReplies } from '../../../lib/api'; 7 - 8 - const { handle, rkey } = Astro.params; 9 - const user = Astro.locals.user; 10 - const cookie = Astro.request.headers.get('cookie') || ''; 11 - 12 - let title = 'Highlight - Margin'; 13 - let description = 'Annotate the web'; 14 - let image = 'https://margin.at/og.png'; 15 - let initialAnnotation = null; 16 - let initialReplies: any[] = []; 17 - let resolvedUri = ''; 18 - 19 - if (handle && rkey) { 20 - try { 21 - const did = await resolveHandle(handle); 22 - if (did) { 23 - resolvedUri = `at://${did}/at.margin.highlight/${rkey}`; 24 - const [ogData, annData, repData] = await Promise.all([ 25 - fetchOGForRoute(did, rkey, 'at.margin.highlight'), 26 - getAnnotation(cookie, resolvedUri), 27 - getReplies(cookie, resolvedUri), 28 - ]); 29 - if (ogData) { 30 - title = ogData.title; 31 - description = ogData.description; 32 - image = ogData.image; 33 - } 34 - initialAnnotation = annData; 35 - initialReplies = repData; 36 - } 37 - } catch (e) { 38 - console.error('OG/data fetch error (highlight):', e); 39 - } 40 - } 41 - --- 42 - 43 - <AppLayout title={title} description={description} image={image} user={user}> 44 - <AnnotationDetail client:idle handle={handle} rkey={rkey} type="highlight" 45 - initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} /> 46 - </AppLayout>
+2 -3
web/src/pages/about.astro
··· 1 1 --- 2 - 3 - import BaseLayout from '../layouts/BaseLayout.astro'; 2 + import BaseLayout from '../layouts/StaticLayout.astro'; 4 3 import About from '../views/About'; 5 4 --- 6 5 7 6 <BaseLayout title="About - Margin" description="Annotate the web using the AT Protocol"> 8 - <About client:load /> 7 + <About client:idle /> 9 8 </BaseLayout>
-14
web/src/pages/admin/moderation.astro
··· 1 - --- 2 - 3 - import AppLayout from '../../layouts/AppLayout.astro'; 4 - import AdminModeration from '../../views/core/AdminModeration'; 5 - 6 - const user = Astro.locals.user; 7 - if (!user) { 8 - return Astro.redirect('/login'); 9 - } 10 - --- 11 - 12 - <AppLayout title="Admin - Margin" user={user}> 13 - <AdminModeration client:load /> 14 - </AppLayout>
-12
web/src/pages/annotations.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Feed from '../views/core/Feed'; 5 - 6 - const user = Astro.locals.user; 7 - const tag = Astro.url.searchParams.get('tag') || undefined; 8 - --- 9 - 10 - <AppLayout title="Annotations - Margin" user={user}> 11 - <Feed client:load initialType="all" motivation="commenting" showTabs={false} initialTag={tag} initialUser={user} /> 12 - </AppLayout>
-43
web/src/pages/at/[did]/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../../layouts/AppLayout.astro'; 4 - import AnnotationDetail from '../../../views/content/AnnotationDetail'; 5 - import { fetchOGForRoute } from '../../../lib/og'; 6 - import { getAnnotation, getReplies } from '../../../lib/api'; 7 - 8 - const { did, rkey } = Astro.params; 9 - const user = Astro.locals.user; 10 - const cookie = Astro.request.headers.get('cookie') || ''; 11 - 12 - let title = 'Margin'; 13 - let description = 'Annotate the web'; 14 - let image = 'https://margin.at/og.png'; 15 - let initialAnnotation = null; 16 - let initialReplies: any[] = []; 17 - let resolvedUri = ''; 18 - 19 - if (did && rkey) { 20 - try { 21 - resolvedUri = `at://${did}/at.margin.annotation/${rkey}`; 22 - const [ogData, annData, repData] = await Promise.all([ 23 - fetchOGForRoute(did, rkey), 24 - getAnnotation(cookie, resolvedUri), 25 - getReplies(cookie, resolvedUri), 26 - ]); 27 - if (ogData) { 28 - title = ogData.title; 29 - description = ogData.description; 30 - image = ogData.image; 31 - } 32 - initialAnnotation = annData; 33 - initialReplies = repData; 34 - } catch (e) { 35 - console.error('OG/data fetch error:', e); 36 - } 37 - } 38 - --- 39 - 40 - <AppLayout title={title} description={description} image={image} user={user}> 41 - <AnnotationDetail client:idle did={did} rkey={rkey} 42 - initialAnnotation={initialAnnotation} initialReplies={initialReplies} resolvedUri={resolvedUri} /> 43 - </AppLayout>
-12
web/src/pages/bookmarks.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Feed from '../views/core/Feed'; 5 - 6 - const user = Astro.locals.user; 7 - const tag = Astro.url.searchParams.get('tag') || undefined; 8 - --- 9 - 10 - <AppLayout title="Bookmarks - Margin" user={user}> 11 - <Feed client:load initialType="all" motivation="bookmarking" showTabs={false} initialTag={tag} initialUser={user} /> 12 - </AppLayout>
-31
web/src/pages/collections/[rkey].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../layouts/AppLayout.astro'; 4 - import CollectionDetail from '../../views/collections/CollectionDetail'; 5 - import { getCollection, getCollectionItems } from '../../lib/api'; 6 - 7 - const { rkey } = Astro.params; 8 - const user = Astro.locals.user; 9 - const cookie = Astro.request.headers.get('cookie') || ''; 10 - 11 - let initialCollection = null; 12 - let initialItems: any[] = []; 13 - let resolvedUri = ''; 14 - let pageTitle = 'Collection - Margin'; 15 - 16 - if (user && rkey) { 17 - try { 18 - resolvedUri = `at://${user.did}/at.margin.collection/${rkey}`; 19 - const col = await getCollection(cookie, resolvedUri); 20 - if (col) { 21 - initialCollection = col; 22 - pageTitle = `${col.name || 'Collection'} - Margin`; 23 - initialItems = await getCollectionItems(cookie, col.uri); 24 - } 25 - } catch { /* component will fetch client-side */ } 26 - } 27 - --- 28 - 29 - <AppLayout title={pageTitle} user={user}> 30 - <CollectionDetail client:idle rkey={rkey} initialCollection={initialCollection} initialItems={initialItems} resolvedUri={resolvedUri} /> 31 - </AppLayout>
-11
web/src/pages/collections/index.astro
··· 1 - --- 2 - 3 - import AppLayout from '../../layouts/AppLayout.astro'; 4 - import Collections from '../../views/collections/Collections'; 5 - 6 - const user = Astro.locals.user; 7 - --- 8 - 9 - <AppLayout title="Collections - Margin" user={user}> 10 - <Collections client:load /> 11 - </AppLayout>
-11
web/src/pages/discover.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Discover from '../views/core/Discover'; 5 - 6 - const user = Astro.locals.user; 7 - --- 8 - 9 - <AppLayout title="Discover - Margin" user={user}> 10 - <Discover client:load /> 11 - </AppLayout>
-12
web/src/pages/highlights.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Feed from '../views/core/Feed'; 5 - 6 - const user = Astro.locals.user; 7 - const tag = Astro.url.searchParams.get('tag') || undefined; 8 - --- 9 - 10 - <AppLayout title="Highlights - Margin" user={user}> 11 - <Feed client:load initialType="all" motivation="highlighting" showTabs={false} initialTag={tag} initialUser={user} /> 12 - </AppLayout>
-12
web/src/pages/home.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Feed from '../views/core/Feed'; 5 - 6 - const user = Astro.locals.user; 7 - const tag = Astro.url.searchParams.get('tag') || undefined; 8 - --- 9 - 10 - <AppLayout title="Home - Margin" user={user}> 11 - <Feed client:load initialType="all" initialTag={tag} initialUser={user} /> 12 - </AppLayout>
+1 -1
web/src/pages/index.astro
··· 10 10 --- 11 11 12 12 <BaseLayout title="Margin" description="Annotate the web using the AT Protocol"> 13 - <About client:load /> 13 + <About client:idle /> 14 14 </BaseLayout>
-18
web/src/pages/new.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import NewAnnotationPage from '../views/core/New'; 5 - 6 - const user = Astro.locals.user; 7 - if (!user) { 8 - return Astro.redirect('/login'); 9 - } 10 - 11 - const url = Astro.url.searchParams.get('url') || undefined; 12 - const selector = Astro.url.searchParams.get('selector') || undefined; 13 - const quote = Astro.url.searchParams.get('quote') || undefined; 14 - --- 15 - 16 - <AppLayout title="New Annotation - Margin" user={user}> 17 - <NewAnnotationPage client:load initialUrl={url} initialSelectorJson={selector} initialQuote={quote} /> 18 - </AppLayout>
-14
web/src/pages/notifications.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Notifications from '../views/core/Notifications'; 5 - 6 - const user = Astro.locals.user; 7 - if (!user) { 8 - return Astro.redirect('/login'); 9 - } 10 - --- 11 - 12 - <AppLayout title="Notifications - Margin" user={user}> 13 - <Notifications client:load /> 14 - </AppLayout>
+16 -2
web/src/pages/og-image.ts
··· 41 41 color: string; 42 42 } 43 43 44 + async function fetchAvatarDataUri(url: string): Promise<string> { 45 + if (!url) return ""; 46 + try { 47 + const res = await fetch(url, { headers: { "User-Agent": "margin.at/og" } }); 48 + if (!res.ok) return ""; 49 + const buf = await res.arrayBuffer(); 50 + const mime = res.headers.get("content-type") || "image/jpeg"; 51 + const b64 = Buffer.from(buf).toString("base64"); 52 + return `data:${mime};base64,${b64}`; 53 + } catch { 54 + return ""; 55 + } 56 + } 57 + 44 58 async function fetchRecordData(uri: string): Promise<RecordData | null> { 45 59 try { 46 60 const res = await fetch( ··· 53 67 const did = author.did || ""; 54 68 const authorName = handle ? `@${handle}` : did || "someone"; 55 69 const displayName = author.displayName || handle || did || "someone"; 56 - const avatarURL = author.avatar || ""; 70 + const avatarURL = await fetchAvatarDataUri(author.avatar || ""); 57 71 const targetSource = item.target?.source || item.url || item.source || ""; 58 72 const domain = targetSource 59 73 ? (() => { ··· 135 149 const did = author.did || ""; 136 150 const authorName = handle ? `@${handle}` : did || "someone"; 137 151 const displayName = author.displayName || handle || did || "someone"; 138 - const avatarURL = author.avatar || ""; 152 + const avatarURL = await fetchAvatarDataUri(author.avatar || ""); 139 153 140 154 return { 141 155 type: "collection",
+1 -1
web/src/pages/privacy.astro
··· 1 1 --- 2 - import BaseLayout from '../layouts/BaseLayout.astro'; 2 + import BaseLayout from '../layouts/StaticLayout.astro'; 3 3 --- 4 4 5 5 <BaseLayout title="Privacy Policy - Margin" description="Margin Privacy Policy">
-30
web/src/pages/profile/[did].astro
··· 1 - --- 2 - 3 - import AppLayout from '../../layouts/AppLayout.astro'; 4 - import Profile from '../../views/profile/Profile'; 5 - import { getProfile } from '../../lib/api'; 6 - 7 - const { did } = Astro.params; 8 - const user = Astro.locals.user; 9 - 10 - if (!did) { 11 - return Astro.redirect('/home'); 12 - } 13 - 14 - const cookie = Astro.request.headers.get('cookie') || ''; 15 - let initialProfile = null; 16 - let pageTitle = 'Profile - Margin'; 17 - 18 - try { 19 - initialProfile = await getProfile(cookie, did); 20 - if (initialProfile?.displayName) { 21 - pageTitle = `${initialProfile.displayName} - Margin`; 22 - } else if (initialProfile?.handle) { 23 - pageTitle = `@${initialProfile.handle} - Margin`; 24 - } 25 - } catch { /* component will fetch client-side */ } 26 - --- 27 - 28 - <AppLayout title={pageTitle} user={user}> 29 - <Profile client:load did={did} initialProfile={initialProfile} /> 30 - </AppLayout>
-8
web/src/pages/profile/index.astro
··· 1 - --- 2 - 3 - const user = Astro.locals.user; 4 - if (user) { 5 - return Astro.redirect(`/profile/${user.did}`); 6 - } 7 - return Astro.redirect('/login'); 8 - ---
-12
web/src/pages/search.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import SearchView from '../views/core/Search'; 5 - 6 - const user = Astro.locals.user; 7 - const q = Astro.url.searchParams.get('q') || undefined; 8 - --- 9 - 10 - <AppLayout title={q ? `Search: ${q} - Margin` : 'Search - Margin'} user={user}> 11 - <SearchView client:load initialQuery={q} /> 12 - </AppLayout>
-11
web/src/pages/settings.astro
··· 1 - --- 2 - 3 - import AppLayout from '../layouts/AppLayout.astro'; 4 - import Settings from '../views/core/Settings'; 5 - 6 - const user = Astro.locals.user; 7 - --- 8 - 9 - <AppLayout title="Settings - Margin" user={user}> 10 - <Settings client:load /> 11 - </AppLayout>
+1 -1
web/src/pages/terms.astro
··· 1 1 --- 2 - import BaseLayout from '../layouts/BaseLayout.astro'; 2 + import BaseLayout from '../layouts/StaticLayout.astro'; 3 3 --- 4 4 5 5 <BaseLayout title="Terms of Service - Margin" description="Margin Terms of Service">
+2 -1
web/src/store/auth.ts
··· 11 11 }); 12 12 13 13 export function logout() { 14 - fetch("/auth/logout", { method: "POST" }).then(() => { 14 + $user.set(null); 15 + fetch("/auth/logout", { method: "POST" }).finally(() => { 15 16 window.location.href = "/"; 16 17 }); 17 18 }
-8
web/src/types.ts
··· 137 137 annotation?: AnnotationItem; 138 138 } 139 139 140 - export interface EditHistoryItem { 141 - uri: string; 142 - cid: string; 143 - author: UserProfile; 144 - text: string; 145 - createdAt: string; 146 - } 147 - 148 140 export interface ModerationRelationship { 149 141 blocking: boolean; 150 142 muting: boolean;
+303
web/src/views/AppShell.tsx
··· 1 + import { useStore } from "@nanostores/react"; 2 + import { useEffect, useState } from "react"; 3 + import type { UserProfile } from "../types"; 4 + 5 + declare global { 6 + interface Window { 7 + __MARGIN_USER__?: UserProfile | null; 8 + } 9 + } 10 + 11 + import { 12 + BrowserRouter, 13 + Navigate, 14 + Route, 15 + Routes, 16 + useLocation, 17 + useNavigate, 18 + useParams, 19 + } from "react-router-dom"; 20 + import { checkSession } from "../api/client"; 21 + 22 + import MobileNav from "../components/navigation/MobileNav"; 23 + import RightSidebar from "../components/navigation/RightSidebar"; 24 + import Sidebar from "../components/navigation/Sidebar"; 25 + import { $user } from "../store/auth"; 26 + import AdminModeration from "./core/AdminModeration"; 27 + import Discover from "./core/Discover"; 28 + import Feed from "./core/Feed"; 29 + import New from "./core/New"; 30 + import Notifications from "./core/Notifications"; 31 + import Search from "./core/Search"; 32 + import Settings from "./core/Settings"; 33 + import Collections from "./collections/Collections"; 34 + import CollectionDetail from "./collections/CollectionDetail"; 35 + import AnnotationDetail from "./content/AnnotationDetail"; 36 + import Profile from "./profile/Profile"; 37 + 38 + const PAGE_TITLES: Record<string, string> = { 39 + "/home": "Home — Margin", 40 + "/bookmarks": "Bookmarks — Margin", 41 + "/highlights": "Highlights — Margin", 42 + "/annotations": "Annotations — Margin", 43 + "/discover": "Discover — Margin", 44 + "/search": "Search — Margin", 45 + "/notifications": "Notifications — Margin", 46 + "/new": "New Annotation — Margin", 47 + "/settings": "Settings — Margin", 48 + "/collections": "Collections — Margin", 49 + "/admin/moderation": "Admin — Margin", 50 + }; 51 + 52 + function AuthGuard({ children }: { children: React.ReactNode }) { 53 + const user = useStore($user); 54 + const [checked, setChecked] = useState(() => "__MARGIN_USER__" in window); 55 + 56 + useEffect(() => { 57 + if (!checked) { 58 + const unsub = $user.subscribe(() => setChecked(true)); 59 + const t = setTimeout(() => setChecked(true), 3000); 60 + return () => { 61 + unsub(); 62 + clearTimeout(t); 63 + }; 64 + } 65 + }, [checked]); 66 + 67 + useEffect(() => { 68 + if (checked && !user) { 69 + window.location.href = "/login"; 70 + } 71 + }, [checked, user]); 72 + 73 + if (!checked || !user) return null; 74 + return <>{children}</>; 75 + } 76 + 77 + function CollectionDetailRoute() { 78 + const { handle, rkey } = useParams<{ handle: string; rkey: string }>(); 79 + return <CollectionDetail handle={handle} rkey={rkey} />; 80 + } 81 + 82 + function AnnotationDetailRoute() { 83 + const { handle, rkey, type } = useParams<{ 84 + handle: string; 85 + rkey: string; 86 + type: string; 87 + }>(); 88 + return <AnnotationDetail handle={handle} rkey={rkey} type={type} />; 89 + } 90 + 91 + function AtAnnotationRoute() { 92 + const { did, rkey } = useParams<{ did: string; rkey: string }>(); 93 + return <AnnotationDetail did={did} rkey={rkey} />; 94 + } 95 + 96 + function ProfileRoute() { 97 + const { did } = useParams<{ did: string }>(); 98 + if (!did) return <Navigate to="/home" replace />; 99 + return <Profile did={did} />; 100 + } 101 + 102 + function AppLayout() { 103 + const location = useLocation(); 104 + const navigate = useNavigate(); 105 + const searchParams = new URLSearchParams(location.search); 106 + 107 + useEffect(() => { 108 + document.title = PAGE_TITLES[location.pathname] ?? "Margin"; 109 + }, [location.pathname]); 110 + 111 + useEffect(() => { 112 + const handleClick = (e: MouseEvent) => { 113 + const a = (e.target as Element).closest("a"); 114 + if (!a) return; 115 + if (a.hasAttribute("target") || a.hasAttribute("download")) return; 116 + const href = a.getAttribute("href"); 117 + if (!href || !href.startsWith("/")) return; 118 + if ( 119 + href.startsWith("/auth/") || 120 + href.startsWith("/api/") || 121 + href.startsWith("/og-image") 122 + ) 123 + return; 124 + e.preventDefault(); 125 + navigate(href); 126 + }; 127 + document.addEventListener("click", handleClick); 128 + return () => document.removeEventListener("click", handleClick); 129 + }, [navigate]); 130 + 131 + return ( 132 + <div className="min-h-screen bg-surface-100 dark:bg-surface-900 flex"> 133 + <Sidebar currentPath={location.pathname} onNavigate={navigate} /> 134 + 135 + <div className="flex-1 min-w-0 transition-all duration-200"> 136 + <div className="flex w-full max-w-[1800px] mx-auto"> 137 + <main className="flex-1 w-full min-w-0 py-2 md:py-3"> 138 + <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6"> 139 + <Routes> 140 + <Route 141 + path="/home" 142 + element={ 143 + <Feed 144 + key="home" 145 + initialType="all" 146 + initialTag={searchParams.get("tag") ?? undefined} 147 + /> 148 + } 149 + /> 150 + <Route 151 + path="/bookmarks" 152 + element={ 153 + <Feed 154 + key="bookmarks" 155 + initialType="all" 156 + motivation="bookmarking" 157 + showTabs={false} 158 + /> 159 + } 160 + /> 161 + <Route 162 + path="/highlights" 163 + element={ 164 + <Feed 165 + key="highlights" 166 + initialType="all" 167 + motivation="highlighting" 168 + showTabs={false} 169 + /> 170 + } 171 + /> 172 + <Route 173 + path="/annotations" 174 + element={ 175 + <Feed 176 + key="annotations" 177 + initialType="all" 178 + motivation="commenting" 179 + showTabs={false} 180 + /> 181 + } 182 + /> 183 + <Route path="/discover" element={<Discover />} /> 184 + <Route 185 + path="/search" 186 + element={ 187 + <Search 188 + key={searchParams.get("q") ?? ""} 189 + initialQuery={searchParams.get("q") ?? undefined} 190 + /> 191 + } 192 + /> 193 + <Route 194 + path="/notifications" 195 + element={ 196 + <AuthGuard> 197 + <Notifications /> 198 + </AuthGuard> 199 + } 200 + /> 201 + <Route 202 + path="/new" 203 + element={ 204 + <AuthGuard> 205 + <New 206 + initialUrl={searchParams.get("url") ?? undefined} 207 + initialSelectorJson={ 208 + searchParams.get("selector") ?? undefined 209 + } 210 + initialQuote={searchParams.get("quote") ?? undefined} 211 + /> 212 + </AuthGuard> 213 + } 214 + /> 215 + <Route path="/settings" element={<Settings />} /> 216 + <Route 217 + path="/admin/moderation" 218 + element={ 219 + <AuthGuard> 220 + <AdminModeration /> 221 + </AuthGuard> 222 + } 223 + /> 224 + <Route path="/collections" element={<Collections />} /> 225 + <Route 226 + path="/collections/:rkey" 227 + element={<CollectionDetail />} 228 + /> 229 + <Route 230 + path="/:handle/collection/:rkey" 231 + element={<CollectionDetailRoute />} 232 + /> 233 + <Route 234 + path="/:handle/annotation/:rkey" 235 + element={<AnnotationDetailRoute />} 236 + /> 237 + <Route 238 + path="/:handle/highlight/:rkey" 239 + element={<AnnotationDetailRoute />} 240 + /> 241 + <Route 242 + path="/:handle/bookmark/:rkey" 243 + element={<AnnotationDetailRoute />} 244 + /> 245 + <Route path="/at/:did/:rkey" element={<AtAnnotationRoute />} /> 246 + <Route path="/profile/:did" element={<ProfileRoute />} /> 247 + <Route 248 + path="/profile" 249 + element={ 250 + <AuthGuard> 251 + <ProfileSelfRedirect /> 252 + </AuthGuard> 253 + } 254 + /> 255 + <Route path="*" element={<Navigate to="/home" replace />} /> 256 + </Routes> 257 + </div> 258 + </main> 259 + 260 + <RightSidebar onNavigate={navigate} /> 261 + </div> 262 + </div> 263 + 264 + <MobileNav currentPath={location.pathname} onNavigate={navigate} /> 265 + </div> 266 + ); 267 + } 268 + 269 + function ProfileSelfRedirect() { 270 + const user = useStore($user); 271 + if (!user) return null; 272 + return <Navigate to={`/profile/${user.did}`} replace />; 273 + } 274 + 275 + export default function AppShell() { 276 + useState(() => { 277 + const ssrUser = window.__MARGIN_USER__; 278 + if (ssrUser !== undefined) { 279 + $user.set(ssrUser); 280 + } 281 + }); 282 + 283 + useEffect(() => { 284 + const ssrUser = window.__MARGIN_USER__; 285 + if ($user.get() === null && ssrUser === null) return; 286 + 287 + if (ssrUser) { 288 + checkSession().then((user) => { 289 + if (user) $user.set(user); 290 + }); 291 + } else if (ssrUser === undefined) { 292 + checkSession().then((user) => { 293 + $user.set(user); 294 + }); 295 + } 296 + }, []); 297 + 298 + return ( 299 + <BrowserRouter> 300 + <AppLayout /> 301 + </BrowserRouter> 302 + ); 303 + }
+7 -1
web/src/views/collections/CollectionDetail.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 2 3 import { 3 4 getCollection, 4 5 getCollectionItems, ··· 34 35 resolvedUri, 35 36 }: CollectionDetailProps) { 36 37 const user = useStore($user); 38 + const navigate = useNavigate(); 37 39 const [collection, setCollection] = useState<Collection | null>( 38 40 initialCollection || null, 39 41 ); ··· 88 90 if (!collection) return; 89 91 if (window.confirm("Delete this collection?")) { 90 92 await deleteCollection(collection.id); 91 - window.location.href = "/collections"; 93 + navigate("/collections"); 92 94 } 93 95 }; 94 96 ··· 137 139 <div className="animate-fade-in max-w-2xl mx-auto"> 138 140 <a 139 141 href="/collections" 142 + onClick={(e) => { 143 + e.preventDefault(); 144 + navigate(-1); 145 + }} 140 146 className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white mb-4 transition-colors" 141 147 > 142 148 <ArrowLeft size={16} />
+11 -1
web/src/views/content/AnnotationDetail.tsx
··· 1 1 import React, { useEffect, useRef, useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 2 3 import { useStore } from "@nanostores/react"; 3 4 import { $user } from "../../store/auth"; 4 5 import { ··· 42 43 resolvedUri, 43 44 }: AnnotationDetailProps) { 44 45 const user = useStore($user); 46 + const navigate = useNavigate(); 45 47 46 48 const [annotation, setAnnotation] = useState<AnnotationItem | null>( 47 49 initialAnnotation || null, ··· 208 210 </p> 209 211 <a 210 212 href="/home" 213 + onClick={(e) => { 214 + e.preventDefault(); 215 + navigate("/home"); 216 + }} 211 217 className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 212 218 > 213 219 Back to Feed ··· 221 227 <div className="mb-4"> 222 228 <a 223 229 href="/home" 230 + onClick={(e) => { 231 + e.preventDefault(); 232 + navigate(-1); 233 + }} 224 234 className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors" 225 235 > 226 236 <ArrowLeft size={16} /> ··· 231 241 <Card 232 242 item={annotation} 233 243 onDelete={() => { 234 - window.location.href = "/home"; 244 + navigate("/home"); 235 245 }} 236 246 /> 237 247
+2 -5
web/src/views/core/Feed.tsx
··· 76 76 const tabs = [ 77 77 { id: "all", label: "Recent" }, 78 78 { id: "popular", label: "Popular" }, 79 - { id: "atmosphereconf", label: "ATmosphereConf" }, 80 79 { id: "shelved", label: "Shelved" }, 81 80 { id: "margin", label: "Margin" }, 82 81 { id: "semble", label: "Semble" }, ··· 193 192 194 193 <FeedItems 195 194 key={`${activeTab}-${activeFilter || "all"}-${tag || ""}-${mineOnly ? "mine" : "all"}`} 196 - type={activeTab === "atmosphereconf" ? "all" : activeTab} 195 + type={activeTab} 197 196 motivation={activeFilter} 198 197 creator={mineOnly && user ? user.did : undefined} 199 198 emptyMessage={emptyMessage} 200 199 layout={layout} 201 - tag={ 202 - activeTab === "atmosphereconf" ? "atmosphereconf" : tag?.toLowerCase() 203 - } 200 + tag={tag?.toLowerCase()} 204 201 initialItems={ 205 202 activeTab === initialType && activeFilter === motivation && !mineOnly 206 203 ? initialItems
+3 -1
web/src/views/core/New.tsx
··· 1 1 import React, { useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 2 3 import { useStore } from "@nanostores/react"; 3 4 import { $user } from "../../store/auth"; 4 5 import Composer from "../../components/feed/Composer"; ··· 16 17 initialQuote, 17 18 }: NewAnnotationProps) { 18 19 const user = useStore($user); 20 + const navigate = useNavigate(); 19 21 20 22 const initialUrl = propUrl || ""; 21 23 ··· 59 61 } 60 62 61 63 const handleSuccess = () => { 62 - window.location.href = "/home"; 64 + navigate("/home"); 63 65 }; 64 66 65 67 return (