diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1258b20..640c3ae 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -41,6 +41,9 @@ func main() { } ingester := firehose.NewIngester(database) + firehose.RelayURL = getEnv("BLOCK_RELAY_URL", "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos") + log.Printf("Firehose URL: %s", firehose.RelayURL) + go func() { if err := ingester.Start(context.Background()); err != nil { log.Printf("Firehose ingester error: %v", err) diff --git a/backend/internal/api/annotations.go b/backend/internal/api/annotations.go index 7be9214..cb36791 100644 --- a/backend/internal/api/annotations.go +++ b/backend/internal/api/annotations.go @@ -22,11 +22,11 @@ func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *Annotatio } type CreateAnnotationRequest struct { - URL string `json:"url"` - Text string `json:"text"` - Selector interface{} `json:"selector,omitempty"` - Title string `json:"title,omitempty"` - Tags []string `json:"tags,omitempty"` + URL string `json:"url"` + Text string `json:"text"` + Selector json.RawMessage `json:"selector,omitempty"` + Title string `json:"title,omitempty"` + Tags []string `json:"tags,omitempty"` } type CreateAnnotationResponse struct { @@ -77,6 +77,16 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ } var result *xrpc.CreateRecordOutput + + if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CreateAnnotationResponse{ + URI: existing.URI, + CID: *existing.CID, + }) + return + } + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) @@ -237,16 +247,23 @@ func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Requ return fmt.Errorf("failed to fetch existing record: %w", getErr) } - var record map[string]interface{} + var record xrpc.AnnotationRecord if err := json.Unmarshal(existing.Value, &record); err != nil { return fmt.Errorf("failed to parse existing record: %w", err) } - record["text"] = req.Text - if req.Tags != nil { - record["tags"] = req.Tags + record.Body = &xrpc.AnnotationBody{ + Value: req.Text, + Format: "text/plain", + } + if len(req.Tags) > 0 { + record.Tags = req.Tags } else { - delete(record, "tags") + record.Tags = nil + } + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) } var updateErr error @@ -309,6 +326,11 @@ func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Reques record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error @@ -403,6 +425,11 @@ func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error @@ -509,11 +536,11 @@ func resolveDIDToPDS(did string) (string, error) { } type CreateHighlightRequest struct { - URL string `json:"url"` - Title string `json:"title,omitempty"` - Selector interface{} `json:"selector"` - Color string `json:"color,omitempty"` - Tags []string `json:"tags,omitempty"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + Selector json.RawMessage `json:"selector"` + Color string `json:"color,omitempty"` + Tags []string `json:"tags,omitempty"` } func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { @@ -537,7 +564,19 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque urlHash := db.HashURL(req.URL) record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput + + if existing, err := s.checkDuplicateHighlight(session.DID, req.URL, req.Selector); err == nil && existing != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID}) + return + } + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) @@ -549,9 +588,8 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque } var selectorJSONPtr *string - if req.Selector != nil { - selectorBytes, _ := json.Marshal(req.Selector) - selectorStr := string(selectorBytes) + if len(record.Target.Selector) > 0 { + selectorStr := string(record.Target.Selector) selectorJSONPtr = &selectorStr } @@ -622,7 +660,19 @@ func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Reques urlHash := db.HashURL(req.URL) record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput + + if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID}) + return + } + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) @@ -759,14 +809,18 @@ func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Reque return fmt.Errorf("failed to fetch record: %w", getErr) } - var record map[string]interface{} + var record xrpc.HighlightRecord json.Unmarshal(existing.Value, &record) if req.Color != "" { - record["color"] = req.Color + record.Color = req.Color } if req.Tags != nil { - record["tags"] = req.Tags + record.Tags = req.Tags + } + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) } var updateErr error @@ -839,17 +893,21 @@ func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Reques return fmt.Errorf("failed to fetch record: %w", getErr) } - var record map[string]interface{} + var record xrpc.BookmarkRecord json.Unmarshal(existing.Value, &record) if req.Title != "" { - record["title"] = req.Title + record.Title = req.Title } if req.Description != "" { - record["description"] = req.Description + record.Description = req.Description } if req.Tags != nil { - record["tags"] = req.Tags + record.Tags = req.Tags + } + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) } var updateErr error diff --git a/backend/internal/api/annotations_helpers.go b/backend/internal/api/annotations_helpers.go new file mode 100644 index 0000000..f92697f --- /dev/null +++ b/backend/internal/api/annotations_helpers.go @@ -0,0 +1,59 @@ +package api + +import ( + "encoding/json" + "time" + + "margin.at/internal/db" +) + +func (s *AnnotationService) checkDuplicateAnnotation(did, url, text string) (*db.Annotation, error) { + recentAnnos, err := s.db.GetAnnotationsByAuthor(did, 5, 0) + if err != nil { + return nil, err + } + for _, a := range recentAnnos { + if a.TargetSource == url && + ((a.BodyValue == nil && text == "") || (a.BodyValue != nil && *a.BodyValue == text)) && + time.Since(a.CreatedAt) < 10*time.Second { + return &a, nil + } + } + return nil, nil +} + +func (s *AnnotationService) checkDuplicateHighlight(did, url string, selector json.RawMessage) (*db.Highlight, error) { + recentHighs, err := s.db.GetHighlightsByAuthor(did, 5, 0) + if err != nil { + return nil, err + } + for _, h := range recentHighs { + matchSelector := false + if h.SelectorJSON == nil && selector == nil { + matchSelector = true + } else if h.SelectorJSON != nil && selector != nil { + selectorBytes, _ := json.Marshal(selector) + if *h.SelectorJSON == string(selectorBytes) { + matchSelector = true + } + } + + if h.TargetSource == url && matchSelector && time.Since(h.CreatedAt) < 10*time.Second { + return &h, nil + } + } + return nil, nil +} + +func (s *AnnotationService) checkDuplicateBookmark(did, url string) (*db.Bookmark, error) { + recentBooks, err := s.db.GetBookmarksByAuthor(did, 5, 0) + if err != nil { + return nil, err + } + for _, b := range recentBooks { + if b.Source == url && time.Since(b.CreatedAt) < 10*time.Second { + return &b, nil + } + } + return nil, nil +} diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go index 35077a3..f597e01 100644 --- a/backend/internal/api/apikey.go +++ b/backend/internal/api/apikey.go @@ -157,6 +157,11 @@ func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { urlHash := db.HashURL(req.URL) record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error @@ -200,26 +205,28 @@ func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { }) } -type QuickAnnotationRequest struct { - URL string `json:"url"` - Text string `json:"text"` +type QuickSaveRequest struct { + URL string `json:"url"` + Text string `json:"text,omitempty"` + Selector json.RawMessage `json:"selector,omitempty"` + Color string `json:"color,omitempty"` } -func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) { +func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { apiKey, err := h.authenticateAPIKey(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } - var req QuickAnnotationRequest + var req QuickSaveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } - if req.URL == "" || req.Text == "" { - http.Error(w, "URL and text are required", http.StatusBadRequest) + if req.URL == "" { + http.Error(w, "URL is required", http.StatusBadRequest) return } @@ -230,40 +237,111 @@ func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) } urlHash := db.HashURL(req.URL) - record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, nil, "") - var result *xrpc.CreateRecordOutput - err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) - return createErr - }) - if err != nil { - http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) - return + var isHighlight bool + if req.Selector != nil && req.Text == "" { + isHighlight = true } - h.db.UpdateAPIKeyLastUsed(apiKey.ID) + var result *xrpc.CreateRecordOutput + var createErr error + + if isHighlight { + color := req.Color + if color == "" { + color = "yellow" + } + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) + + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) + return createErr + }) + if err == nil { + h.db.UpdateAPIKeyLastUsed(apiKey.ID) + selectorJSON, _ := json.Marshal(req.Selector) + selectorStr := string(selectorJSON) + colorPtr := &color + + highlight := &db.Highlight{ + URI: result.URI, + AuthorDID: apiKey.OwnerDID, + TargetSource: req.URL, + TargetHash: urlHash, + SelectorJSON: &selectorStr, + Color: colorPtr, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &result.CID, + } + go func() { + if err := h.db.CreateHighlight(highlight); err != nil { + fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) + } + }() + } + + } else { + record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, req.Selector, "") + + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) + return createErr + }) + if err == nil { + h.db.UpdateAPIKeyLastUsed(apiKey.ID) + + var selectorStrPtr *string + if req.Selector != nil { + b, _ := json.Marshal(req.Selector) + s := string(b) + selectorStrPtr = &s + } + + bodyValue := req.Text + var bodyValuePtr *string + if bodyValue != "" { + bodyValuePtr = &bodyValue + } + + annotation := &db.Annotation{ + URI: result.URI, + AuthorDID: apiKey.OwnerDID, + Motivation: "commenting", + BodyValue: bodyValuePtr, + TargetSource: req.URL, + TargetHash: urlHash, + SelectorJSON: selectorStrPtr, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &result.CID, + } + go func() { + h.db.CreateAnnotation(annotation) + }() + } + } - bodyValue := req.Text - annotation := &db.Annotation{ - URI: result.URI, - AuthorDID: apiKey.OwnerDID, - Motivation: "commenting", - BodyValue: &bodyValue, - TargetSource: req.URL, - TargetHash: urlHash, - CreatedAt: time.Now(), - IndexedAt: time.Now(), - CID: &result.CID, + if err != nil { + http.Error(w, "Failed to create record: "+err.Error(), http.StatusInternalServerError) + return } - h.db.CreateAnnotation(annotation) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "uri": result.URI, "cid": result.CID, - "message": "Annotation created successfully", + "message": "Saved successfully", }) } @@ -305,6 +383,11 @@ func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error diff --git a/backend/internal/api/collections.go b/backend/internal/api/collections.go index 72b51a3..8eb7c60 100644 --- a/backend/internal/api/collections.go +++ b/backend/internal/api/collections.go @@ -54,6 +54,11 @@ func (s *CollectionService) CreateCollection(w http.ResponseWriter, r *http.Requ record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error @@ -116,6 +121,11 @@ func (s *CollectionService) AddCollectionItem(w http.ResponseWriter, r *http.Req record := xrpc.NewCollectionItemRecord(collectionURI, req.AnnotationURI, req.Position) + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + var result *xrpc.CreateRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error @@ -368,6 +378,12 @@ func (s *CollectionService) UpdateCollection(w http.ResponseWriter, r *http.Requ } record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) + + if err := record.Validate(); err != nil { + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) + return + } + parts := strings.Split(uri, "/") rkey := parts[len(parts)-1] diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 0ad74ea..f4594f7 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -8,11 +8,13 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/go-chi/chi/v5" "margin.at/internal/db" + "margin.at/internal/xrpc" ) type Handler struct { @@ -57,6 +59,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) r.Delete("/collections/items", collectionService.RemoveCollectionItem) r.Get("/collections/containing", collectionService.GetAnnotationCollections) + r.Post("/sync", h.SyncAll) r.Get("/targets", h.GetByTarget) @@ -77,8 +80,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Delete("/keys/{id}", h.apiKeys.DeleteKey) r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) - r.Post("/quick/annotation", h.apiKeys.QuickAnnotation) - r.Post("/quick/highlight", h.apiKeys.QuickHighlight) + r.Post("/quick/save", h.apiKeys.QuickSave) }) } @@ -133,6 +135,15 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { tag := r.URL.Query().Get("tag") creator := r.URL.Query().Get("creator") + viewerDID := h.getViewerDID(r) + + if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "")) { + if creator == viewerDID { + h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit) + return + } + } + var annotations []db.Annotation var highlights []db.Highlight var bookmarks []db.Bookmark @@ -166,7 +177,6 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { } } - viewerDID := h.getViewerDID(r) authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) @@ -187,16 +197,111 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { feed = append(feed, ci) } - for i := 0; i < len(feed); i++ { - for j := i + 1; j < len(feed); j++ { - t1 := getCreatedAt(feed[i]) - t2 := getCreatedAt(feed[j]) - if t1.Before(t2) { - feed[i], feed[j] = feed[j], feed[i] + sortFeed(feed) + + if len(feed) > limit { + feed = feed[:limit] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "@context": "http://www.w3.org/ns/anno.jsonld", + "type": "Collection", + "items": feed, + "totalItems": len(feed), + }) +} + +func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit int) { + var wg sync.WaitGroup + var rawAnnos, rawHighs, rawBooks []interface{} + var errAnnos, errHighs, errBooks error + + fetchLimit := limit * 2 + if fetchLimit < 50 { + fetchLimit = 50 + } + + wg.Add(3) + go func() { + defer wg.Done() + rawAnnos, errAnnos = h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, fetchLimit) + }() + go func() { + defer wg.Done() + rawHighs, errHighs = h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, fetchLimit) + }() + go func() { + defer wg.Done() + rawBooks, errBooks = h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, fetchLimit) + }() + wg.Wait() + + if errAnnos != nil { + log.Printf("PDS Fetch Error (Annos): %v", errAnnos) + } + if errHighs != nil { + log.Printf("PDS Fetch Error (Highs): %v", errHighs) + } + if errBooks != nil { + log.Printf("PDS Fetch Error (Books): %v", errBooks) + } + + var annotations []db.Annotation + var highlights []db.Highlight + var bookmarks []db.Bookmark + + for _, r := range rawAnnos { + if a, ok := r.(*db.Annotation); ok { + if tag == "" || containsTag(a.TagsJSON, tag) { + annotations = append(annotations, *a) + } + } + } + for _, r := range rawHighs { + if h, ok := r.(*db.Highlight); ok { + if tag == "" || containsTag(h.TagsJSON, tag) { + highlights = append(highlights, *h) + } + } + } + for _, r := range rawBooks { + if b, ok := r.(*db.Bookmark); ok { + if tag == "" || containsTag(b.TagsJSON, tag) { + bookmarks = append(bookmarks, *b) } } } + go func() { + for _, a := range annotations { + h.db.CreateAnnotation(&a) + } + for _, hi := range highlights { + h.db.CreateHighlight(&hi) + } + for _, b := range bookmarks { + h.db.CreateBookmark(&b) + } + }() + + authAnnos, _ := hydrateAnnotations(h.db, annotations, did) + authHighs, _ := hydrateHighlights(h.db, highlights, did) + authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) + + var feed []interface{} + for _, a := range authAnnos { + feed = append(feed, a) + } + for _, h := range authHighs { + feed = append(feed, h) + } + for _, b := range authBooks { + feed = append(feed, b) + } + + sortFeed(feed) + if len(feed) > limit { feed = feed[:limit] } @@ -208,6 +313,35 @@ func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { "items": feed, "totalItems": len(feed), }) + +} + +func containsTag(tagsJSON *string, tag string) bool { + if tagsJSON == nil || *tagsJSON == "" { + return false + } + var tags []string + if err := json.Unmarshal([]byte(*tagsJSON), &tags); err != nil { + return false + } + for _, t := range tags { + if t == tag { + return true + } + } + return false +} + +func sortFeed(feed []interface{}) { + for i := 0; i < len(feed); i++ { + for j := i + 1; j < len(feed); j++ { + t1 := getCreatedAt(feed[i]) + t2 := getCreatedAt(feed[j]) + if t1.Before(t2) { + feed[i], feed[j] = feed[j], feed[i] + } + } + } } func getCreatedAt(item interface{}) time.Time { @@ -386,7 +520,32 @@ func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - annotations, err := h.db.GetAnnotationsByAuthor(did, limit, offset) + var annotations []db.Annotation + var err error + + viewerDID := h.getViewerDID(r) + + if offset == 0 && viewerDID != "" && did == viewerDID { + raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit) + if err == nil { + for _, r := range raw { + if a, ok := r.(*db.Annotation); ok { + annotations = append(annotations, *a) + } + } + go func() { + for _, a := range annotations { + h.db.CreateAnnotation(&a) + } + }() + } else { + log.Printf("PDS Fetch Error (User Annos): %v", err) + annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) + } + } else { + annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) + } + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -412,7 +571,32 @@ func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) + var highlights []db.Highlight + var err error + + viewerDID := h.getViewerDID(r) + + if offset == 0 && viewerDID != "" && did == viewerDID { + raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit) + if err == nil { + for _, r := range raw { + if hi, ok := r.(*db.Highlight); ok { + highlights = append(highlights, *hi) + } + } + go func() { + for _, hi := range highlights { + h.db.CreateHighlight(&hi) + } + }() + } else { + log.Printf("PDS Fetch Error (User Highs): %v", err) + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) + } + } else { + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) + } + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -438,7 +622,32 @@ func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) + var bookmarks []db.Bookmark + var err error + + viewerDID := h.getViewerDID(r) + + if offset == 0 && viewerDID != "" && did == viewerDID { + raw, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit) + if err == nil { + for _, r := range raw { + if b, ok := r.(*db.Bookmark); ok { + bookmarks = append(bookmarks, *b) + } + } + go func() { + for _, b := range bookmarks { + h.db.CreateBookmark(&b) + } + }() + } else { + log.Printf("PDS Fetch Error (User Books): %v", err) + bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) + } + } else { + bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) + } + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/api/pds.go b/backend/internal/api/pds.go new file mode 100644 index 0000000..05a0e3d --- /dev/null +++ b/backend/internal/api/pds.go @@ -0,0 +1,226 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "margin.at/internal/db" + "margin.at/internal/xrpc" +) + +func (h *Handler) FetchLatestUserRecords(r *http.Request, did string, collection string, limit int) ([]interface{}, error) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + return nil, err + } + + var results []interface{} + + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", client.PDS, did, collection, limit) + + req, _ := http.NewRequestWithContext(r.Context(), "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+client.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch %s: %w", collection, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("XRPC error %d", resp.StatusCode) + } + + var output struct { + Records []struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value json.RawMessage `json:"value"` + } `json:"records"` + Cursor string `json:"cursor"` + } + + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return err + } + + for _, rec := range output.Records { + parsed, err := parseRecord(did, collection, rec.URI, rec.CID, rec.Value) + if err == nil && parsed != nil { + results = append(results, parsed) + } + } + return nil + }) + + if err != nil { + return nil, err + } + + return results, nil +} + +func parseRecord(did, collection, uri, cid string, value json.RawMessage) (interface{}, error) { + cidPtr := &cid + + switch collection { + case xrpc.CollectionAnnotation: + var record xrpc.AnnotationRecord + if err := json.Unmarshal(value, &record); err != nil { + return nil, err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + targetSource := record.Target.Source + + targetHash := record.Target.SourceHash + if targetHash == "" && targetSource != "" { + targetHash = db.HashURL(targetSource) + } + + motivation := record.Motivation + if motivation == "" { + motivation = "commenting" + } + + var bodyValuePtr, bodyFormatPtr, bodyURIPtr *string + if record.Body != nil { + if record.Body.Value != "" { + val := record.Body.Value + bodyValuePtr = &val + } + if record.Body.Format != "" { + fmt := record.Body.Format + bodyFormatPtr = &fmt + } + } + + var targetTitlePtr, selectorJSONPtr, tagsJSONPtr *string + if record.Target.Title != "" { + t := record.Target.Title + targetTitlePtr = &t + } + if len(record.Target.Selector) > 0 { + selectorStr := string(record.Target.Selector) + selectorJSONPtr = &selectorStr + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return &db.Annotation{ + URI: uri, + AuthorDID: did, + Motivation: motivation, + BodyValue: bodyValuePtr, + BodyFormat: bodyFormatPtr, + BodyURI: bodyURIPtr, + TargetSource: targetSource, + TargetHash: targetHash, + TargetTitle: targetTitlePtr, + SelectorJSON: selectorJSONPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }, nil + + case xrpc.CollectionHighlight: + var record xrpc.HighlightRecord + if err := json.Unmarshal(value, &record); err != nil { + return nil, err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + if createdAt.IsZero() { + createdAt = time.Now() + } + + targetHash := record.Target.SourceHash + if targetHash == "" && record.Target.Source != "" { + targetHash = db.HashURL(record.Target.Source) + } + + var titlePtr, selectorJSONPtr, colorPtr, tagsJSONPtr *string + if record.Target.Title != "" { + t := record.Target.Title + titlePtr = &t + } + if len(record.Target.Selector) > 0 { + selectorStr := string(record.Target.Selector) + selectorJSONPtr = &selectorStr + } + if record.Color != "" { + c := record.Color + colorPtr = &c + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return &db.Highlight{ + URI: uri, + AuthorDID: did, + TargetSource: record.Target.Source, + TargetHash: targetHash, + TargetTitle: titlePtr, + SelectorJSON: selectorJSONPtr, + Color: colorPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }, nil + + case xrpc.CollectionBookmark: + var record xrpc.BookmarkRecord + if err := json.Unmarshal(value, &record); err != nil { + return nil, err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + sourceHash := record.SourceHash + if sourceHash == "" && record.Source != "" { + sourceHash = db.HashURL(record.Source) + } + + var titlePtr, descPtr, tagsJSONPtr *string + if record.Title != "" { + t := record.Title + titlePtr = &t + } + if record.Description != "" { + d := record.Description + descPtr = &d + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return &db.Bookmark{ + URI: uri, + AuthorDID: did, + Source: record.Source, + SourceHash: sourceHash, + Title: titlePtr, + Description: descPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }, nil + } + + return nil, nil +} diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go new file mode 100644 index 0000000..53f23fa --- /dev/null +++ b/backend/internal/api/sync.go @@ -0,0 +1,351 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "margin.at/internal/db" + "margin.at/internal/xrpc" +) + +func (h *Handler) SyncAll(w http.ResponseWriter, r *http.Request) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + collections := []string{ + xrpc.CollectionAnnotation, + xrpc.CollectionHighlight, + xrpc.CollectionBookmark, + xrpc.CollectionReply, + xrpc.CollectionLike, + xrpc.CollectionCollection, + xrpc.CollectionCollectionItem, + } + + results := make(map[string]string) + + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { + for _, collectionNSID := range collections { + count := 0 + cursor := "" + fetchedURIs := make(map[string]bool) + + for { + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100", client.PDS, did, collectionNSID) + if cursor != "" { + url += "&cursor=" + cursor + } + + req, _ := http.NewRequestWithContext(r.Context(), "GET", url, nil) + req.Header.Set("Authorization", "Bearer "+client.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch %s: %w", collectionNSID, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + results[collectionNSID] = fmt.Sprintf("error: %s", string(body)) + break + } + + var output struct { + Records []struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value json.RawMessage `json:"value"` + } `json:"records"` + Cursor string `json:"cursor"` + } + + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return err + } + + for _, rec := range output.Records { + err := h.upsertRecord(did, collectionNSID, rec.URI, rec.CID, rec.Value) + if err != nil { + fmt.Printf("Error upserting %s: %v\n", rec.URI, err) + } else { + count++ + fetchedURIs[rec.URI] = true + } + } + + if output.Cursor == "" { + break + } + cursor = output.Cursor + } + + deletedCount := 0 + if results[collectionNSID] == "" { + var localURIs []string + var err error + + switch collectionNSID { + case xrpc.CollectionAnnotation: + localURIs, err = h.db.GetAnnotationURIs(did) + case xrpc.CollectionHighlight: + localURIs, err = h.db.GetHighlightURIs(did) + case xrpc.CollectionBookmark: + localURIs, err = h.db.GetBookmarkURIs(did) + } + + if err == nil { + for _, uri := range localURIs { + if !fetchedURIs[uri] { + switch collectionNSID { + case xrpc.CollectionAnnotation: + _ = h.db.DeleteAnnotation(uri) + case xrpc.CollectionHighlight: + _ = h.db.DeleteHighlight(uri) + case xrpc.CollectionBookmark: + _ = h.db.DeleteBookmark(uri) + } + deletedCount++ + } + } + } + } + + if results[collectionNSID] == "" { + results[collectionNSID] = fmt.Sprintf("synced %d records, deleted %d stale", count, deletedCount) + } + } + return nil + }) + + if err != nil { + http.Error(w, "Sync failed: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(results) +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func (h *Handler) upsertRecord(did, collection, uri, cid string, value json.RawMessage) error { + cidPtr := strPtr(cid) + switch collection { + case xrpc.CollectionAnnotation: + var record xrpc.AnnotationRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + targetSource := record.Target.Source + if targetSource == "" { + + } + + targetHash := record.Target.SourceHash + if targetHash == "" && targetSource != "" { + targetHash = db.HashURL(targetSource) + } + + motivation := record.Motivation + if motivation == "" { + motivation = "commenting" + } + + var bodyValuePtr, bodyFormatPtr, bodyURIPtr, targetTitlePtr, selectorJSONPtr, tagsJSONPtr *string + if record.Body != nil { + if record.Body.Value != "" { + val := record.Body.Value + bodyValuePtr = &val + } + if record.Body.Format != "" { + fmt := record.Body.Format + bodyFormatPtr = &fmt + } + } + if record.Target.Title != "" { + t := record.Target.Title + targetTitlePtr = &t + } + if len(record.Target.Selector) > 0 { + selectorStr := string(record.Target.Selector) + selectorJSONPtr = &selectorStr + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return h.db.CreateAnnotation(&db.Annotation{ + URI: uri, + AuthorDID: did, + Motivation: motivation, + BodyValue: bodyValuePtr, + BodyFormat: bodyFormatPtr, + BodyURI: bodyURIPtr, + TargetSource: targetSource, + TargetHash: targetHash, + TargetTitle: targetTitlePtr, + SelectorJSON: selectorJSONPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }) + + case xrpc.CollectionHighlight: + var record xrpc.HighlightRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + if createdAt.IsZero() { + createdAt = time.Now() + } + + targetHash := record.Target.SourceHash + if targetHash == "" && record.Target.Source != "" { + targetHash = db.HashURL(record.Target.Source) + } + + var titlePtr, selectorJSONPtr, colorPtr, tagsJSONPtr *string + if record.Target.Title != "" { + t := record.Target.Title + titlePtr = &t + } + if len(record.Target.Selector) > 0 { + selectorStr := string(record.Target.Selector) + selectorJSONPtr = &selectorStr + } + if record.Color != "" { + c := record.Color + colorPtr = &c + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return h.db.CreateHighlight(&db.Highlight{ + URI: uri, + AuthorDID: did, + TargetSource: record.Target.Source, + TargetHash: targetHash, + TargetTitle: titlePtr, + SelectorJSON: selectorJSONPtr, + Color: colorPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }) + + case xrpc.CollectionBookmark: + var record xrpc.BookmarkRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + sourceHash := record.SourceHash + if sourceHash == "" && record.Source != "" { + sourceHash = db.HashURL(record.Source) + } + + var titlePtr, descPtr, tagsJSONPtr *string + if record.Title != "" { + t := record.Title + titlePtr = &t + } + if record.Description != "" { + d := record.Description + descPtr = &d + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + return h.db.CreateBookmark(&db.Bookmark{ + URI: uri, + AuthorDID: did, + Source: record.Source, + SourceHash: sourceHash, + Title: titlePtr, + Description: descPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + CID: cidPtr, + }) + + case xrpc.CollectionCollection: + var record xrpc.CollectionRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + var descPtr, iconPtr *string + if record.Description != "" { + d := record.Description + descPtr = &d + } + if record.Icon != "" { + i := record.Icon + iconPtr = &i + } + + return h.db.CreateCollection(&db.Collection{ + URI: uri, + AuthorDID: did, + Name: record.Name, + Description: descPtr, + Icon: iconPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + }) + + case xrpc.CollectionCollectionItem: + var record xrpc.CollectionItemRecord + if err := json.Unmarshal(value, &record); err != nil { + return err + } + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) + + return h.db.AddToCollection(&db.CollectionItem{ + URI: uri, + AuthorDID: did, + CollectionURI: record.Collection, + AnnotationURI: record.Annotation, + Position: record.Position, + CreatedAt: createdAt, + IndexedAt: time.Now(), + }) + + case xrpc.CollectionReply: + return nil + case xrpc.CollectionLike: + return nil + } + return nil +} diff --git a/backend/internal/db/queries_annotations.go b/backend/internal/db/queries_annotations.go index 0aac7cf..c075c8b 100644 --- a/backend/internal/db/queries_annotations.go +++ b/backend/internal/db/queries_annotations.go @@ -170,3 +170,23 @@ func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { return scanAnnotations(rows) } + +func (db *DB) GetAnnotationURIs(authorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri FROM annotations WHERE author_did = ? + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} diff --git a/backend/internal/db/queries_bookmarks.go b/backend/internal/db/queries_bookmarks.go index 9863387..bfcbd11 100644 --- a/backend/internal/db/queries_bookmarks.go +++ b/backend/internal/db/queries_bookmarks.go @@ -174,3 +174,23 @@ func (db *DB) GetBookmarksByURIs(uris []string) ([]Bookmark, error) { } return bookmarks, nil } + +func (db *DB) GetBookmarkURIs(authorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri FROM bookmarks WHERE author_did = ? + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} diff --git a/backend/internal/db/queries_collections.go b/backend/internal/db/queries_collections.go index 9f79511..d0a3aac 100644 --- a/backend/internal/db/queries_collections.go +++ b/backend/internal/db/queries_collections.go @@ -118,6 +118,29 @@ func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, err return items, nil } +func (db *DB) GetCollectionItemsByAuthor(authorDID string) ([]CollectionItem, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at + FROM collection_items + WHERE author_did = ? + ORDER BY created_at DESC + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []CollectionItem + for rows.Next() { + var item CollectionItem + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { rows, err := db.Query(db.Rebind(` SELECT collection_uri FROM collection_items WHERE annotation_uri = ? diff --git a/backend/internal/db/queries_highlights.go b/backend/internal/db/queries_highlights.go index 354a425..91435a8 100644 --- a/backend/internal/db/queries_highlights.go +++ b/backend/internal/db/queries_highlights.go @@ -199,3 +199,23 @@ func (db *DB) GetHighlightsByURIs(uris []string) ([]Highlight, error) { } return highlights, nil } + +func (db *DB) GetHighlightURIs(authorDID string) ([]string, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri FROM highlights WHERE author_did = ? + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, nil +} diff --git a/backend/internal/db/queries_likes.go b/backend/internal/db/queries_likes.go index c3cafcd..d38be4b 100644 --- a/backend/internal/db/queries_likes.go +++ b/backend/internal/db/queries_likes.go @@ -14,6 +14,29 @@ func (db *DB) DeleteLike(uri string) error { return err } +func (db *DB) GetLikesByAuthor(authorDID string) ([]Like, error) { + rows, err := db.Query(db.Rebind(` + SELECT uri, author_did, subject_uri, created_at, indexed_at + FROM likes + WHERE author_did = ? + ORDER BY created_at DESC + `), authorDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var likes []Like + for rows.Next() { + var l Like + if err := rows.Scan(&l.URI, &l.AuthorDID, &l.SubjectURI, &l.CreatedAt, &l.IndexedAt); err != nil { + return nil, err + } + likes = append(likes, l) + } + return likes, nil +} + func (db *DB) GetLikeCount(subjectURI string) (int, error) { var count int err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index 4fe1d9a..8e3f260 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -1,6 +1,11 @@ package xrpc -import "time" +import ( + "encoding/json" + "fmt" + "time" + "unicode/utf8" +) const ( CollectionAnnotation = "at.margin.annotation" @@ -12,6 +17,57 @@ const ( CollectionCollectionItem = "at.margin.collectionItem" ) +const ( + SelectorTypeQuote = "TextQuoteSelector" + SelectorTypePosition = "TextPositionSelector" +) + +type Selector struct { + Type string `json:"type"` +} + +type TextQuoteSelector struct { + Type string `json:"type"` + Exact string `json:"exact"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` +} + +func (s *TextQuoteSelector) Validate() error { + if s.Type != SelectorTypeQuote { + return fmt.Errorf("invalid selector type: %s", s.Type) + } + if len(s.Exact) > 5000 { + return fmt.Errorf("exact text too long: %d > 5000", len(s.Exact)) + } + if len(s.Prefix) > 500 { + return fmt.Errorf("prefix too long: %d > 500", len(s.Prefix)) + } + if len(s.Suffix) > 500 { + return fmt.Errorf("suffix too long: %d > 500", len(s.Suffix)) + } + return nil +} + +type TextPositionSelector struct { + Type string `json:"type"` + Start int `json:"start"` + End int `json:"end"` +} + +func (s *TextPositionSelector) Validate() error { + if s.Type != SelectorTypePosition { + return fmt.Errorf("invalid selector type: %s", s.Type) + } + if s.Start < 0 { + return fmt.Errorf("start position cannot be negative") + } + if s.End < s.Start { + return fmt.Errorf("end position cannot be before start") + } + return nil +} + type AnnotationRecord struct { Type string `json:"$type"` Motivation string `json:"motivation,omitempty"` @@ -27,17 +83,56 @@ type AnnotationBody struct { } type AnnotationTarget struct { - Source string `json:"source"` - SourceHash string `json:"sourceHash"` - Title string `json:"title,omitempty"` - Selector interface{} `json:"selector,omitempty"` + Source string `json:"source"` + SourceHash string `json:"sourceHash"` + Title string `json:"title,omitempty"` + Selector json.RawMessage `json:"selector,omitempty"` } -type TextQuoteSelector struct { - Type string `json:"type"` - Exact string `json:"exact"` - Prefix string `json:"prefix,omitempty"` - Suffix string `json:"suffix,omitempty"` +func (r *AnnotationRecord) Validate() error { + if r.Target.Source == "" { + return fmt.Errorf("target source is required") + } + if r.Body != nil { + if len(r.Body.Value) > 10000 { + return fmt.Errorf("body too long: %d > 10000", len(r.Body.Value)) + } + if utf8.RuneCountInString(r.Body.Value) > 3000 { + return fmt.Errorf("body too long (graphemes): %d > 3000", utf8.RuneCountInString(r.Body.Value)) + } + } + if len(r.Tags) > 10 { + return fmt.Errorf("too many tags: %d > 10", len(r.Tags)) + } + for _, tag := range r.Tags { + if len(tag) > 64 { + return fmt.Errorf("tag too long: %s", tag) + } + } + + if len(r.Target.Selector) > 0 { + var typeCheck Selector + if err := json.Unmarshal(r.Target.Selector, &typeCheck); err != nil { + return fmt.Errorf("invalid selector format") + } + + switch typeCheck.Type { + case SelectorTypeQuote: + var s TextQuoteSelector + if err := json.Unmarshal(r.Target.Selector, &s); err != nil { + return err + } + return s.Validate() + case SelectorTypePosition: + var s TextPositionSelector + if err := json.Unmarshal(r.Target.Selector, &s); err != nil { + return err + } + return s.Validate() + } + } + + return nil } func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title string) *AnnotationRecord { @@ -45,6 +140,12 @@ func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title } func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector interface{}, title string, motivation string) *AnnotationRecord { + var selectorJSON json.RawMessage + if selector != nil { + b, _ := json.Marshal(selector) + selectorJSON = b + } + record := &AnnotationRecord{ Type: CollectionAnnotation, Motivation: motivation, @@ -52,6 +153,7 @@ func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector inter Source: url, SourceHash: urlHash, Title: title, + Selector: selectorJSON, }, CreatedAt: time.Now().UTC().Format(time.RFC3339), } @@ -63,10 +165,6 @@ func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector inter } } - if selector != nil { - record.Target.Selector = selector - } - return record } @@ -78,13 +176,32 @@ type HighlightRecord struct { CreatedAt string `json:"createdAt"` } +func (r *HighlightRecord) Validate() error { + if r.Target.Source == "" { + return fmt.Errorf("target source is required") + } + if len(r.Tags) > 10 { + return fmt.Errorf("too many tags: %d", len(r.Tags)) + } + if len(r.Color) > 20 { + return fmt.Errorf("color too long") + } + return nil +} + func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { + var selectorJSON json.RawMessage + if selector != nil { + b, _ := json.Marshal(selector) + selectorJSON = b + } + return &HighlightRecord{ Type: CollectionHighlight, Target: AnnotationTarget{ Source: url, SourceHash: urlHash, - Selector: selector, + Selector: selectorJSON, }, Color: color, Tags: tags, @@ -106,6 +223,16 @@ type ReplyRecord struct { CreatedAt string `json:"createdAt"` } +func (r *ReplyRecord) Validate() error { + if r.Text == "" { + return fmt.Errorf("text is required") + } + if len(r.Text) > 2000 { + return fmt.Errorf("reply text too long") + } + return nil +} + func NewReplyRecord(parentURI, parentCID, rootURI, rootCID, text string) *ReplyRecord { return &ReplyRecord{ Type: CollectionReply, @@ -128,6 +255,13 @@ type LikeRecord struct { CreatedAt string `json:"createdAt"` } +func (r *LikeRecord) Validate() error { + if r.Subject.URI == "" || r.Subject.CID == "" { + return fmt.Errorf("invalid subject") + } + return nil +} + func NewLikeRecord(subjectURI, subjectCID string) *LikeRecord { return &LikeRecord{ Type: CollectionLike, @@ -146,6 +280,22 @@ type BookmarkRecord struct { CreatedAt string `json:"createdAt"` } +func (r *BookmarkRecord) Validate() error { + if r.Source == "" { + return fmt.Errorf("source is required") + } + if len(r.Title) > 500 { + return fmt.Errorf("title too long") + } + if len(r.Description) > 1000 { + return fmt.Errorf("description too long") + } + if len(r.Tags) > 10 { + return fmt.Errorf("too many tags") + } + return nil +} + func NewBookmarkRecord(url, urlHash, title, description string) *BookmarkRecord { return &BookmarkRecord{ Type: CollectionBookmark, @@ -165,6 +315,19 @@ type CollectionRecord struct { CreatedAt string `json:"createdAt"` } +func (r *CollectionRecord) Validate() error { + if r.Name == "" { + return fmt.Errorf("name is required") + } + if len(r.Name) > 100 { + return fmt.Errorf("name too long") + } + if len(r.Description) > 500 { + return fmt.Errorf("description too long") + } + return nil +} + func NewCollectionRecord(name, description, icon string) *CollectionRecord { return &CollectionRecord{ Type: CollectionCollection, @@ -183,6 +346,13 @@ type CollectionItemRecord struct { CreatedAt string `json:"createdAt"` } +func (r *CollectionItemRecord) Validate() error { + if r.Collection == "" || r.Annotation == "" { + return fmt.Errorf("collection and annotation URIs required") + } + return nil +} + func NewCollectionItemRecord(collection, annotation string, position int) *CollectionItemRecord { return &CollectionItemRecord{ Type: CollectionCollectionItem, diff --git a/web/src/App.jsx b/web/src/App.jsx index ce63fe0..6db20ad 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,5 +1,6 @@ import { Routes, Route } from "react-router-dom"; -import { AuthProvider } from "./context/AuthContext"; +import { useEffect } from "react"; +import { AuthProvider, useAuth } from "./context/AuthContext"; import Sidebar from "./components/Sidebar"; import RightSidebar from "./components/RightSidebar"; import MobileNav from "./components/MobileNav"; @@ -19,6 +20,14 @@ import Terms from "./pages/Terms"; import ScrollToTop from "./components/ScrollToTop"; function AppContent() { + const { user } = useAuth(); + + useEffect(() => { + if (user) { + fetch("/api/sync", { method: "POST" }).catch(console.error); + } + }, [user]); + return (