(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
Go
at ui-refactor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170package api
import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strconv" "strings" "sync" "time"
"github.com/go-chi/chi/v5"
"margin.at/internal/db" internal_sync "margin.at/internal/sync" "margin.at/internal/xrpc")
type Handler struct { db *db.DB annotationService *AnnotationService refresher *TokenRefresher apiKeys *APIKeyHandler syncService *internal_sync.Service}
func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service) *Handler { return &Handler{ db: database, annotationService: annotationService, refresher: refresher, apiKeys: NewAPIKeyHandler(database, refresher), syncService: syncService, }}
func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/health", h.Health)
r.Route("/api", func(r chi.Router) { r.Get("/annotations", h.GetAnnotations) r.Get("/annotations/feed", h.GetFeed) r.Get("/annotation", h.GetAnnotation) r.Get("/annotations/history", h.GetEditHistory) r.Put("/annotations", h.annotationService.UpdateAnnotation)
r.Get("/highlights", h.GetHighlights) r.Put("/highlights", h.annotationService.UpdateHighlight)
r.Get("/bookmarks", h.GetBookmarks) r.Post("/bookmarks", h.annotationService.CreateBookmark) r.Put("/bookmarks", h.annotationService.UpdateBookmark)
collectionService := NewCollectionService(h.db, h.refresher) r.Post("/collections", collectionService.CreateCollection) r.Get("/collections", collectionService.GetCollections) r.Put("/collections", collectionService.UpdateCollection) r.Delete("/collections", collectionService.DeleteCollection) r.Post("/collections/{collection}/items", collectionService.AddCollectionItem) r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) r.Delete("/collections/items", collectionService.RemoveCollectionItem) r.Get("/collections/containing", collectionService.GetAnnotationCollections) r.Get("/collection", collectionService.GetCollection) r.Post("/sync", h.SyncAll)
r.Get("/targets", h.GetByTarget) r.Get("/discover", h.DiscoverForURL)
r.Get("/users/{did}/annotations", h.GetUserAnnotations) r.Get("/users/{did}/highlights", h.GetUserHighlights) r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) r.Get("/users/{did}/targets", h.GetUserTargetItems)
r.Get("/replies", h.GetReplies) r.Get("/likes", h.GetLikeCount) r.Get("/url-metadata", h.GetURLMetadata) r.Get("/notifications", h.GetNotifications) r.Get("/notifications/count", h.GetUnreadNotificationCount) r.Post("/notifications/read", h.MarkNotificationsRead) r.Get("/avatar/{did}", h.HandleAvatarProxy)
r.Post("/keys", h.apiKeys.CreateKey) r.Get("/keys", h.apiKeys.ListKeys) r.Delete("/keys/{id}", h.apiKeys.DeleteKey)
r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) r.Post("/quick/save", h.apiKeys.QuickSave) })}
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": "1.0"})}
func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if source == "" { source = r.URL.Query().Get("url") }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) motivation := r.URL.Query().Get("motivation") tag := r.URL.Query().Get("tag")
var annotations []db.Annotation var err error
if source != "" { urlHash := db.HashURL(source) annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) } else if motivation != "" { annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) } else if tag != "" { annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) } else { annotations, err = h.db.GetRecentAnnotations(limit, offset) }
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) tag := r.URL.Query().Get("tag") creator := r.URL.Query().Get("creator") feedType := r.URL.Query().Get("type")
viewerDID := h.getViewerDID(r)
if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { if creator == viewerDID { h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit, offset) return } }
var annotations []db.Annotation var highlights []db.Highlight var bookmarks []db.Bookmark var collectionItems []db.CollectionItem var err error
motivation := r.URL.Query().Get("motivation")
fetchLimit := limit + offset
if tag != "" { if creator != "" { if motivation == "" || motivation == "commenting" { annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) } if motivation == "" || motivation == "highlighting" { highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) } if motivation == "" || motivation == "bookmarking" { bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) } collectionItems = []db.CollectionItem{} } else { if motivation == "" || motivation == "commenting" { annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) } if motivation == "" || motivation == "highlighting" { highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) } if motivation == "" || motivation == "bookmarking" { bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) } collectionItems = []db.CollectionItem{} } } else if creator != "" { if motivation == "" || motivation == "commenting" { annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) } if motivation == "" || motivation == "highlighting" { highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) } if motivation == "" || motivation == "bookmarking" { bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) } collectionItems = []db.CollectionItem{} } else { if motivation == "" || motivation == "commenting" { annotations, _ = h.db.GetRecentAnnotations(fetchLimit, 0) } if motivation == "" || motivation == "highlighting" { highlights, _ = h.db.GetRecentHighlights(fetchLimit, 0) } if motivation == "" || motivation == "bookmarking" { bookmarks, _ = h.db.GetRecentBookmarks(fetchLimit, 0) } if motivation == "" { collectionItems, err = h.db.GetRecentCollectionItems(fetchLimit, 0) if err != nil { log.Printf("Error fetching collection items: %v\n", err) } } }
authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
if len(collectionItems) > 0 { var sembleURIs []string for _, item := range collectionItems { if strings.Contains(item.AnnotationURI, "network.cosmik.card") { sembleURIs = append(sembleURIs, item.AnnotationURI) } } if len(sembleURIs) > 0 { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() ensureSembleCardsIndexed(ctx, h.db, sembleURIs) } }
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID)
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) } for _, ci := range authCollectionItems { feed = append(feed, ci) }
if feedType != "" && feedType != "all" && feedType != "my-feed" { var filtered []interface{} for _, item := range feed { isSemble := false var uri string switch v := item.(type) { case APIAnnotation: uri = v.ID case APIHighlight: uri = v.ID case APIBookmark: uri = v.ID case APICollectionItem: uri = v.ID } if strings.Contains(uri, "network.cosmik") { isSemble = true }
if feedType == "semble" && isSemble { filtered = append(filtered, item) } else if feedType == "margin" && !isSemble { filtered = append(filtered, item) } else if feedType == "popular" { filtered = append(filtered, item) } } feed = filtered }
if feedType == "popular" { sortFeedByPopularity(feed) } else { sortFeed(feed) }
if offset < len(feed) { feed = feed[offset:] } else { feed = []interface{}{} }
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, offset int) { var wg sync.WaitGroup var rawAnnos, rawHighs, rawBooks []interface{} var errAnnos, errHighs, errBooks error
fetchLimit := limit + offset 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) } }()
collectionItems := []db.CollectionItem{} if tag == "" { items, err := h.db.GetCollectionItemsByAuthor(did) if err != nil { log.Printf("Error fetching collection items for user feed: %v", err) } else { collectionItems = items } }
if len(collectionItems) > 0 { var sembleURIs []string for _, item := range collectionItems { if strings.Contains(item.AnnotationURI, "network.cosmik.card") { sembleURIs = append(sembleURIs, item.AnnotationURI) } } if len(sembleURIs) > 0 { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() ensureSembleCardsIndexed(ctx, h.db, sembleURIs) } }
authAnnos, _ := hydrateAnnotations(h.db, annotations, did) authHighs, _ := hydrateHighlights(h.db, highlights, did) authBooks, _ := hydrateBookmarks(h.db, bookmarks, did) authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, 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) } for _, ci := range authCollectionItems { feed = append(feed, ci) }
sortFeed(feed)
if offset < len(feed) { feed = feed[offset:] } else { feed = []interface{}{} }
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 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 { switch v := item.(type) { case APIAnnotation: return v.CreatedAt case APIHighlight: return v.CreatedAt case APIBookmark: return v.CreatedAt case APICollectionItem: return v.CreatedAt default: return time.Time{} }}
func sortFeedByPopularity(feed []interface{}) { for i := 0; i < len(feed); i++ { for j := i + 1; j < len(feed); j++ { p1 := getPopularity(feed[i]) p2 := getPopularity(feed[j]) if p1 < p2 { feed[i], feed[j] = feed[j], feed[i] } } }}
func getPopularity(item interface{}) int { switch v := item.(type) { case APIAnnotation: return v.LikeCount + v.ReplyCount case APIHighlight: return v.LikeCount + v.ReplyCount case APIBookmark: return v.LikeCount + v.ReplyCount case APICollectionItem: pop := 0 if v.Annotation != nil { pop += v.Annotation.LikeCount + v.Annotation.ReplyCount } if v.Highlight != nil { pop += v.Highlight.LikeCount + v.Highlight.ReplyCount } if v.Bookmark != nil { pop += v.Bookmark.LikeCount + v.Bookmark.ReplyCount } return pop default: return 0 }}
func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "uri query parameter required", http.StatusBadRequest) return }
serveResponse := func(data interface{}, context string) { w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{ "@context": context, } jsonData, _ := json.Marshal(data) json.Unmarshal(jsonData, &response) json.NewEncoder(w).Encode(response) }
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } }
if highlight, err := h.db.GetHighlightByURI(uri); err == nil { if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } }
if strings.Contains(uri, "at.margin.annotation") { highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } } }
if strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark") { if strings.HasPrefix(uri, "at://") { uriWithoutScheme := strings.TrimPrefix(uri, "at://") parts := strings.Split(uriWithoutScheme, "/") if len(parts) >= 3 { did := parts[0] rkey := parts[len(parts)-1]
sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", did, rkey)
if annotation, err := h.db.GetAnnotationByURI(sembleURI); err == nil { if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } }
if bookmark, err := h.db.GetBookmarkByURI(sembleURI); err == nil { if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } } } } }
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } }
if strings.Contains(uri, "at.margin.annotation") { bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") return } } }
http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound)
}
func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if source == "" { source = r.URL.Query().Get("url") } if source == "" { http.Error(w, "source or url parameter required", http.StatusBadRequest) return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
urlHash := db.HashURL(source)
annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) bookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, limit, offset)
enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) enrichedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "source": source, "sourceHash": urlHash, "annotations": enrichedAnnotations, "highlights": enrichedHighlights, "bookmarks": enrichedBookmarks, })}
func (h *Handler) DiscoverForURL(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if source == "" { source = r.URL.Query().Get("url") } if source == "" { http.Error(w, "source or url parameter required", http.StatusBadRequest) return }
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel()
annotations, highlights, bookmarks, err := ConstellationClient.GetAllItemsForURL(ctx, source) if err != nil { log.Printf("Constellation discover error, falling back to local: %v", err) h.GetByTarget(w, r) return }
var annotationURIs, highlightURIs, bookmarkURIs []string seenURIs := make(map[string]bool)
for _, link := range annotations { if !seenURIs[link.URI] { annotationURIs = append(annotationURIs, link.URI) seenURIs[link.URI] = true } } for _, link := range highlights { if !seenURIs[link.URI] { highlightURIs = append(highlightURIs, link.URI) seenURIs[link.URI] = true } } for _, link := range bookmarks { if !seenURIs[link.URI] { bookmarkURIs = append(bookmarkURIs, link.URI) seenURIs[link.URI] = true } }
localAnnotations, _ := h.db.GetAnnotationsByURIs(annotationURIs) localHighlights, _ := h.db.GetHighlightsByURIs(highlightURIs) localBookmarks, _ := h.db.GetBookmarksByURIs(bookmarkURIs)
urlHash := db.HashURL(source) dbAnnotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, 100, 0) dbHighlights, _ := h.db.GetHighlightsByTargetHash(urlHash, 100, 0) dbBookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, 100, 0)
annoMap := make(map[string]db.Annotation) for _, a := range localAnnotations { annoMap[a.URI] = a } for _, a := range dbAnnotations { annoMap[a.URI] = a }
highMap := make(map[string]db.Highlight) for _, h := range localHighlights { highMap[h.URI] = h } for _, h := range dbHighlights { highMap[h.URI] = h }
bookMap := make(map[string]db.Bookmark) for _, b := range localBookmarks { bookMap[b.URI] = b } for _, b := range dbBookmarks { bookMap[b.URI] = b }
var mergedAnnotations []db.Annotation for _, a := range annoMap { mergedAnnotations = append(mergedAnnotations, a) } var mergedHighlights []db.Highlight for _, h := range highMap { mergedHighlights = append(mergedHighlights, h) } var mergedBookmarks []db.Bookmark for _, b := range bookMap { mergedBookmarks = append(mergedBookmarks, b) }
viewerDID := h.getViewerDID(r) enrichedAnnotations, _ := hydrateAnnotations(h.db, mergedAnnotations, viewerDID) enrichedHighlights, _ := hydrateHighlights(h.db, mergedHighlights, viewerDID) enrichedBookmarks, _ := hydrateBookmarks(h.db, mergedBookmarks, viewerDID)
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "source": source, "sourceHash": urlHash, "annotations": enrichedAnnotations, "highlights": enrichedHighlights, "bookmarks": enrichedBookmarks, "networkDiscovered": len(annotations) + len(highlights) + len(bookmarks), })}
func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { did := r.URL.Query().Get("creator") tag := r.URL.Query().Get("tag") limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
var highlights []db.Highlight var err error
if did != "" { highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) } else if tag != "" { highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) } else { highlights, err = h.db.GetRecentHighlights(limit, offset) }
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) { did := r.URL.Query().Get("creator") limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
if did == "" { http.Error(w, "creator parameter required", http.StatusBadRequest) return }
bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { did := chi.URLParam(r, "did") if decoded, err := url.QueryUnescape(did); err == nil { did = decoded } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
var annotations []db.Annotation var err error
viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit); err != nil { log.Printf("Background sync error (annotations): %v", err) } }() }
annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset)
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", "creator": did, "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { did := chi.URLParam(r, "did") if decoded, err := url.QueryUnescape(did); err == nil { did = decoded } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
var highlights []db.Highlight var err error
viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit); err != nil { log.Printf("Background sync error (highlights): %v", err) } }() }
highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset)
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", "creator": did, "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { did := chi.URLParam(r, "did") if decoded, err := url.QueryUnescape(did); err == nil { did = decoded } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
var bookmarks []db.Bookmark var err error
viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit); err != nil { log.Printf("Background sync error (bookmarks): %v", err) } }() }
bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset)
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", "creator": did, "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetUserTargetItems(w http.ResponseWriter, r *http.Request) { did := chi.URLParam(r, "did") if decoded, err := url.QueryUnescape(did); err == nil { did = decoded }
source := r.URL.Query().Get("source") if source == "" { source = r.URL.Query().Get("url") } if source == "" { http.Error(w, "source or url parameter required", http.StatusBadRequest) return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
urlHash := db.HashURL(source)
annotations, _ := h.db.GetAnnotationsByAuthorAndTargetHash(did, urlHash, limit, offset) highlights, _ := h.db.GetHighlightsByAuthorAndTargetHash(did, urlHash, limit, offset)
enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "creator": did, "source": source, "sourceHash": urlHash, "annotations": enrichedAnnotations, "highlights": enrichedHighlights, })}
func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "uri query parameter required", http.StatusBadRequest) return }
replies, err := h.db.GetRepliesByRoot(uri) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
enriched, _ := hydrateReplies(replies)
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "ReplyCollection", "inReplyTo": uri, "items": enriched, "totalItems": len(enriched), })}
func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "uri query parameter required", http.StatusBadRequest) return }
count, err := h.db.GetLikeCount(uri) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
liked := false cookie, err := r.Cookie("margin_session") if err == nil && cookie != nil { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err == nil { userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri) if err == nil && userLike != nil { liked = true } } }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "count": count, "liked": liked, })}
func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { http.Error(w, "uri query parameter required", http.StatusBadRequest) return }
history, err := h.db.GetEditHistory(uri) if err != nil { http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError) return }
if history == nil { history = []db.EditHistory{} }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(history)}
func parseIntParam(r *http.Request, name string, defaultVal int) int { val := r.URL.Query().Get(name) if val == "" { return defaultVal } i, err := strconv.Atoi(val) if err != nil { return defaultVal } return i}
func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("url") if url == "" { http.Error(w, "url parameter required", http.StatusBadRequest) return }
client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(url) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"}) return } defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"title": ""}) return }
title := "" htmlStr := string(body) if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 { start := idx + 7 if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 { title = strings.TrimSpace(htmlStr[start : start+endIdx]) } }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url})}
func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
notifications, err := h.db.GetNotifications(session.DID, limit, offset) if err != nil { http.Error(w, "Failed to get notifications", http.StatusInternalServerError) return }
enriched, err := hydrateNotifications(h.db, notifications) if err != nil { log.Printf("Failed to hydrate notifications: %v\n", err) }
w.Header().Set("Content-Type", "application/json") if enriched != nil { json.NewEncoder(w).Encode(map[string]interface{}{"items": enriched}) } else { json.NewEncoder(w).Encode(map[string]interface{}{"items": notifications}) }}
func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return }
count, err := h.db.GetUnreadNotificationCount(session.DID) if err != nil { http.Error(w, "Failed to get count", http.StatusInternalServerError) return }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]int{"count": count})}
func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return }
if err := h.db.MarkNotificationsRead(session.DID); err != nil { http.Error(w, "Failed to mark as read", http.StatusInternalServerError) return }
w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"})}func (h *Handler) getViewerDID(r *http.Request) string { cookie, err := r.Cookie("margin_session") if err != nil { return "" } did, _, _, _, _, err := h.db.GetSession(cookie.Value) if err != nil { return "" } return did}