Something went wrong. Try again.
(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension web atproto comments
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811package api
import ( "context" "encoding/json" "fmt" "html" "io" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time"
"github.com/go-chi/chi/v5" xcharset "golang.org/x/net/html/charset"
"margin.at/internal/analytics" "margin.at/internal/cloudflare" "margin.at/internal/config" "margin.at/internal/db" "margin.at/internal/domain" "margin.at/internal/logger" "margin.at/internal/recommendations" "margin.at/internal/repository/postgres" "margin.at/internal/service" stripepkg "margin.at/internal/stripe" internal_sync "margin.at/internal/sync" "margin.at/internal/xrpc")
type urlMetaCacheEntry struct { data map[string]string expiresAt time.Time}
type urlMetaCache struct { mu sync.RWMutex entries map[string]urlMetaCacheEntry inflight sync.Map}
type singleflight struct { wg sync.WaitGroup data map[string]string err error}
func newURLMetaCache() *urlMetaCache { c := &urlMetaCache{entries: make(map[string]urlMetaCacheEntry)} go c.evictLoop() return c}
func (c *urlMetaCache) get(key string) (map[string]string, bool) { c.mu.RLock() defer c.mu.RUnlock() e, ok := c.entries[key] if !ok || time.Now().After(e.expiresAt) { return nil, false } return e.data, true}
func (c *urlMetaCache) set(key string, data map[string]string, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.entries[key] = urlMetaCacheEntry{data: data, expiresAt: time.Now().Add(ttl)}}
func (c *urlMetaCache) evictLoop() { ticker := time.NewTicker(5 * time.Minute) for range ticker.C { c.mu.Lock() now := time.Now() for k, e := range c.entries { if now.After(e.expiresAt) { delete(c.entries, k) } } c.mu.Unlock() }}
type Handler struct { db *db.DB noteRepo domain.NoteRepository engagementRepo domain.EngagementRepository notificationRepo domain.NotificationRepository noteWriter *NoteWriteService refresher *TokenRefresher apiKeys *APIKeyHandler syncService *internal_sync.Service moderation *ModerationHandler recommendations *recommendations.Service feedSvc *service.FeedService hydration *service.HydrationService metaCache *urlMetaCache metaSem chan struct{} analytics *analytics.Client stripe *stripepkg.Client cloudflare *cloudflare.Client}
func NewHandler(database *db.DB, noteWriter *NoteWriteService, refresher *TokenRefresher, syncService *internal_sync.Service, recService *recommendations.Service, ac *analytics.Client, sc *stripepkg.Client, cfc *cloudflare.Client) *Handler { noteRepo := postgres.NewNoteRepository(database.Pool()) engagementRepo := postgres.NewEngagementRepository(database.Pool()) notificationRepo := postgres.NewNotificationRepository(database.Pool()) profileRepo := &fullProfileRepository{db: database} // rich resolution: cache → DB → bsky.social profileSvc := service.NewProfileService(profileRepo) // service-lifetime TTL cache on top collectionRepo := postgres.NewCollectionRepository(database.Pool()) hydration := service.NewHydrationService(engagementRepo, profileSvc, collectionRepo) feedSvc := service.NewFeedService(noteRepo, hydration, database)
return &Handler{ db: database, noteRepo: noteRepo, engagementRepo: engagementRepo, notificationRepo: notificationRepo, noteWriter: noteWriter, refresher: refresher, apiKeys: NewAPIKeyHandler(database, refresher), syncService: syncService, moderation: NewModerationHandler(database, refresher), recommendations: recService, feedSvc: feedSvc, hydration: hydration, metaCache: newURLMetaCache(), metaSem: make(chan struct{}, 5), analytics: ac, stripe: sc, cloudflare: cfc, }}
func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/health", h.Health) r.Get("/.well-known/site.standard.publication/reading-room/{handle}", h.GetPublicationVerification)
collectionService := NewCollectionService(h.db, h.refresher)
r.Route("/api", func(r chi.Router) { // Notes r.Get("/notes", h.GetAnnotations) r.Get("/notes/feed", h.GetFeed) r.Get("/note", h.GetAnnotation) r.Get("/notes/history", h.GetEditHistory) r.Post("/notes", h.noteWriter.CreateAnnotation) r.Put("/notes", h.noteWriter.UpdateAnnotation) r.Delete("/notes", h.noteWriter.DeleteAnnotation) r.Post("/notes/like", h.noteWriter.LikeAnnotation) r.Delete("/notes/like", h.noteWriter.UnlikeAnnotation) r.Post("/notes/reply", h.noteWriter.CreateReply) r.Delete("/notes/reply", h.noteWriter.DeleteReply) r.Get("/replies", h.GetReplies) r.Get("/likes", h.GetLikeCount)
// Annotations (legacy) r.Get("/annotations", h.GetAnnotations) r.Get("/annotations/feed", h.GetFeed) r.Get("/annotation", h.GetAnnotation) r.Get("/annotations/history", h.GetEditHistory) r.Post("/annotations", h.noteWriter.CreateAnnotation) r.Put("/annotations", h.noteWriter.UpdateAnnotation) r.Delete("/annotations", h.noteWriter.DeleteAnnotation) r.Post("/annotations/like", h.noteWriter.LikeAnnotation) r.Delete("/annotations/like", h.noteWriter.UnlikeAnnotation) r.Post("/annotations/reply", h.noteWriter.CreateReply) r.Delete("/annotations/reply", h.noteWriter.DeleteReply)
// Highlights r.Get("/highlights", h.GetHighlights) r.Post("/highlights", h.noteWriter.CreateHighlight) r.Put("/highlights", h.noteWriter.UpdateHighlight) r.Delete("/highlights", h.noteWriter.DeleteAnnotation)
// Bookmarks r.Get("/bookmarks", h.GetBookmarks) r.Post("/bookmarks", h.noteWriter.CreateBookmark) r.Put("/bookmarks", h.noteWriter.UpdateBookmark) r.Delete("/bookmarks", h.noteWriter.DeleteAnnotation)
// Collections 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)
// Targets & discovery r.Get("/targets", h.GetByTarget) r.Get("/targets/hash", h.GetByTargetHash) r.Get("/discover", h.DiscoverForURL) r.Get("/url-metadata", h.GetURLMetadata)
// User content r.Get("/users/{did}/notes", h.GetUserAnnotations) r.Get("/users/{did}/annotations", h.GetUserAnnotations) // legacy r.Get("/users/{did}/highlights", h.GetUserHighlights) r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) r.Get("/users/{did}/targets", h.GetUserTargetItems) r.Get("/users/{did}/tags", h.HandleGetUserTags)
// Profile r.Get("/profile/{did}", h.GetProfile) r.Put("/profile", h.UpdateProfile) r.Post("/profile/avatar", h.UploadAvatar) r.Get("/avatar/{did}", h.HandleAvatarProxy)
// Tags & search r.Get("/tags/trending", h.HandleGetTrendingTags) r.Get("/trending-tags", h.HandleGetTrendingTags) // legacy alias r.Get("/search", h.Search) r.Get("/recommendations", h.GetRecommendations) r.Get("/documents", h.GetDocuments)
// Notifications r.Get("/notifications", h.GetNotifications) r.Get("/notifications/count", h.GetUnreadNotificationCount) r.Post("/notifications/read", h.MarkNotificationsRead)
// Preferences & sync r.Get("/preferences", h.GetPreferences) r.Put("/preferences", h.UpdatePreferences) r.Post("/sync", h.SyncAll)
// API keys r.Get("/me", h.apiKeys.GetMe) 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) r.Post("/quick/highlight", h.apiKeys.QuickHighlight)
// Moderation r.Post("/moderation/block", h.moderation.BlockUser) r.Delete("/moderation/block", h.moderation.UnblockUser) r.Get("/moderation/blocks", h.moderation.GetBlocks) r.Post("/moderation/mute", h.moderation.MuteUser) r.Delete("/moderation/mute", h.moderation.UnmuteUser) r.Get("/moderation/mutes", h.moderation.GetMutes) r.Get("/moderation/relationship", h.moderation.GetRelationship) r.Post("/moderation/report", h.moderation.CreateReport) r.Get("/moderation/admin/check", h.moderation.AdminCheckAccess) r.Get("/moderation/admin/reports", h.moderation.AdminGetReports) r.Get("/moderation/admin/report", h.moderation.AdminGetReport) r.Post("/moderation/admin/action", h.moderation.AdminTakeAction) r.Post("/moderation/admin/label", h.moderation.AdminCreateLabel) r.Delete("/moderation/admin/label", h.moderation.AdminDeleteLabel) r.Get("/moderation/admin/labels", h.moderation.AdminGetLabels) r.Post("/moderation/admin/ban", h.moderation.AdminBanAccount) r.Delete("/moderation/admin/ban", h.moderation.AdminUnbanAccount) r.Get("/moderation/admin/bans", h.moderation.AdminGetBannedAccounts) r.Get("/moderation/labeler", h.moderation.GetLabelerInfo)
// Admin r.Post("/admin/backfill", h.AdminBackfill)
// Analytics proxy r.Post("/analytics/capture", h.CaptureEvent)
r.Get("/reading-room/rss/{handle}", h.GetRSSFeed) r.Get("/reading-room/{handle}/note", h.GetPublicReadingRoomNote) r.Get("/reading-room/{handle}/notes", h.GetReadingRoomNotes) r.Get("/reading-room/{handle}", h.GetPublicReadingRoom) r.Get("/reading-room/config", h.GetReadingRoomConfig) r.Put("/reading-room/config", h.UpdateReadingRoomConfig) r.Put("/reading-room/featured", h.UpdateFeaturedItems) r.Get("/reading-room/domain", h.GetCustomDomainStatus) r.Post("/reading-room/domain", h.AddCustomDomain) r.Post("/reading-room/domain/poll", h.PollCustomDomain) r.Delete("/reading-room/domain", h.RemoveCustomDomain)
r.Post("/billing/checkout", h.CreateCheckout) r.Post("/billing/portal", h.CreatePortal) r.Get("/billing/status", h.GetBillingStatus) r.Post("/billing/webhook", h.HandleStripeWebhook) })}
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := h.db.Pool().Ping(ctx); err != nil { WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable", "reason": "database"}) return }
WriteSuccess(w, map[string]string{"status": "ok", "version": "1.0"})}
func (h *Handler) CaptureEvent(w http.ResponseWriter, r *http.Request) { var body struct { Event string `json:"event"` DistinctID string `json:"distinct_id"` Properties map[string]interface{} `json:"properties"` } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if body.Event == "" { http.Error(w, "event is required", http.StatusBadRequest) return } if body.DistinctID == "" { body.DistinctID = "anonymous_extension" } if h.analytics != nil { if body.Properties == nil { body.Properties = map[string]interface{}{} } body.Properties["$lib"] = "margin-extension" h.analytics.Capture(body.DistinctID, body.Event, body.Properties) } w.WriteHeader(http.StatusNoContent)}
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")
filter := db.NoteFilter{Limit: limit, Offset: offset} if source != "" { filter.TargetHash = db.HashURL(source) } filter.Motivations = parseMotivations(motivation) if tag != "" { filter.Tag = tag }
notes, err := h.noteRepo.List(r.Context(), filter) if err != nil { WriteInternalError(w, "Internal server error") return }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", "items": items, "totalItems": len(items), })}
func parseFeedType(s string) db.FeedType { switch s { case "popular": return db.FeedTypePopular case "shelved": return db.FeedTypeShelved case "margin": return db.FeedTypeMargin case "semble": return db.FeedTypeSemble default: return db.FeedTypeRecent }}
func parseMotivations(motivation string) []string { switch motivation { case "": return nil case "commenting": return []string{"commenting", "tagging"} default: return []string{motivation} }}
func notesByMotivation(notes []service.APINote) (annotations, highlights, bookmarks []service.APINote) { annotations = []service.APINote{} highlights = []service.APINote{} bookmarks = []service.APINote{} for _, n := range notes { switch n.Motivation { case "highlighting": highlights = append(highlights, n) case "bookmarking": bookmarks = append(bookmarks, n) default: annotations = append(annotations, n) } } return}
func mergeNotes(a, b []db.Note) []db.Note { seen := make(map[string]bool, len(a)) result := make([]db.Note, 0, len(a)+len(b)) for _, n := range a { if !seen[n.URI] { seen[n.URI] = true result = append(result, n) } } for _, n := range b { if !seen[n.URI] { seen[n.URI] = true result = append(result, n) } } return result}
func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) if limit > 100 { limit = 100 } offset := parseIntParam(r, "offset", 0) tag := strings.ToLower(r.URL.Query().Get("tag")) creator := r.URL.Query().Get("creator") motivation := r.URL.Query().Get("motivation") feedTypeStr := r.URL.Query().Get("type") feedType := parseFeedType(feedTypeStr)
fetchLimit := limit + offset req := service.FeedRequest{ ViewerDID: h.getViewerDID(r), Motivations: parseMotivations(motivation), AuthorDID: creator, Tag: tag, FeedType: feedType, Limit: fetchLimit, Offset: 0, }
resp, err := h.feedSvc.GetFeed(r.Context(), req) if err != nil { WriteInternalError(w, "Internal server error") return }
allItems := make([]interface{}, len(resp.Items)) for i, n := range resp.Items { allItems[i] = n }
if feedType == db.FeedTypeRecent && motivation == "" && tag == "" { var collItems []db.CollectionItem if creator != "" { collItems, _ = h.db.GetCollectionItemsByAuthor(r.Context(), creator) if len(collItems) > fetchLimit { collItems = collItems[:fetchLimit] } } else { collItems, _ = h.db.GetRecentCollectionItems(r.Context(), fetchLimit, 0) }
if len(collItems) > 0 { viewerDID := h.getViewerDID(r) collItems = h.filterHiddenCollectionItems(r.Context(), viewerDID, collItems) hydrated, hydrateErr := hydrateCollectionItems(h.db, collItems, viewerDID) if hydrateErr == nil { noteURIsInFeed := make(map[string]bool, len(resp.Items)) for _, n := range resp.Items { noteURIsInFeed[n.ID] = true } for _, ci := range hydrated { innerURI := collectionItemInnerURI(ci) if innerURI != "" && noteURIsInFeed[innerURI] { delete(noteURIsInFeed, innerURI) for idx, item := range allItems { if n, ok := item.(service.APINote); ok && n.ID == innerURI { allItems = append(allItems[:idx], allItems[idx+1:]...) break } } } allItems = append(allItems, ci) } } }
sort.Slice(allItems, func(i, j int) bool { return feedItemCreatedAt(allItems[i]).After(feedItemCreatedAt(allItems[j])) }) }
if offset < len(allItems) { allItems = allItems[offset:] } else { allItems = nil } if len(allItems) > limit { allItems = allItems[:limit] } if allItems == nil { allItems = []interface{}{} }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "Collection", "items": allItems, "totalItems": len(allItems), })}
func collectionItemInnerURI(ci APICollectionItem) string { if ci.Annotation != nil { return ci.Annotation.ID } if ci.Highlight != nil { return ci.Highlight.ID } if ci.Bookmark != nil { return ci.Bookmark.ID } return ""}
func feedItemCreatedAt(item interface{}) time.Time { switch v := item.(type) { case service.APINote: return v.CreatedAt case APICollectionItem: return v.CreatedAt } return time.Time{}}func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") return }
note, err := h.noteRepo.GetByURI(r.Context(), uri) if err != nil { WriteInternalError(w, "Internal server error") return }
if note == nil && strings.Contains(uri, "at.margin.annotation") { altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) note, _ = h.noteRepo.GetByURI(r.Context(), altURI) }
if note == nil && strings.HasPrefix(uri, "at://") && (strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark")) { parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") if len(parts) >= 3 { sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", parts[0], parts[len(parts)-1]) note, _ = h.noteRepo.GetByURI(r.Context(), sembleURI) } }
if note == nil && strings.Contains(uri, "at.margin.annotation") { altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) note, _ = h.noteRepo.GetByURI(r.Context(), altURI) }
if note == nil { WriteNotFound(w, "Note not found") return }
lc, _ := h.hydration.Load(r.Context(), []db.Note{*note}, h.getViewerDID(r)) apiNote := h.hydration.ToAPINote(*note, lc)
w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{"@context": "http://www.w3.org/ns/anno.jsonld"} jsonData, _ := json.Marshal(apiNote) json.Unmarshal(jsonData, &response) json.NewEncoder(w).Encode(response)}
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 == "" { WriteBadRequest(w, "source or url parameter required") return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
urlHash := db.HashURL(source) rawHash := db.HashString(source)
notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: urlHash, Limit: limit, Offset: offset}) if err != nil { WriteInternalError(w, "Internal server error") return } if rawHash != urlHash { rawNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: rawHash, Limit: limit, Offset: offset}) notes = mergeNotes(notes, rawNotes) }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) } annotations, highlights, bookmarks := notesByMotivation(items)
if viewerDID != "" { w.Header().Set("Cache-Control", "private, max-age=0, no-store") } else if len(items) == 0 { w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") } else { w.Header().Set("Cache-Control", "private, max-age=0, no-store") }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "source": source, "sourceHash": urlHash, "annotations": annotations, "highlights": highlights, "bookmarks": bookmarks, })}
func (h *Handler) GetByTargetHash(w http.ResponseWriter, r *http.Request) { hashes := r.URL.Query()["h"] if len(hashes) == 0 { WriteBadRequest(w, "at least one hash parameter (h) required") return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
var notes []db.Note for _, hash := range hashes { if len(hash) != 64 { continue } hashNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: hash, Limit: limit, Offset: offset}) notes = mergeNotes(notes, hashNotes) }
if bannedDIDs, err := h.db.GetBannedDIDs(r.Context()); err == nil && len(bannedDIDs) > 0 { banned := make(map[string]bool, len(bannedDIDs)) for _, did := range bannedDIDs { banned[did] = true } filtered := notes[:0] for _, n := range notes { if !banned[n.AuthorDID] { filtered = append(filtered, n) } } notes = filtered }
viewerDID := h.getViewerDID(r) if viewerDID != "" { if hidden, err := h.db.GetAllHiddenDIDs(r.Context(), viewerDID); err == nil && len(hidden) > 0 { filtered := notes[:0] for _, n := range notes { if !hidden[n.AuthorDID] { filtered = append(filtered, n) } } notes = filtered } }
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) } annotations, highlights, bookmarks := notesByMotivation(items)
if viewerDID != "" { w.Header().Set("Cache-Control", "private, max-age=0, no-store") } else if len(items) == 0 { w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") } else { w.Header().Set("Cache-Control", "private, max-age=0, no-store") }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "annotations": annotations, "highlights": highlights, "bookmarks": bookmarks, })}
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 == "" { WriteBadRequest(w, "source or url parameter required") return }
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel()
annotations, highlights, bookmarks, err := ConstellationClient.GetAllItemsForURL(ctx, source) if err != nil { logger.Error("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(r.Context(), annotationURIs) localHighlights, _ := h.db.GetHighlightsByURIs(r.Context(), highlightURIs) localBookmarks, _ := h.db.GetBookmarksByURIs(r.Context(), bookmarkURIs)
urlHash := db.HashURL(source) dbAnnotations, _ := h.db.GetAnnotationsByTargetHash(r.Context(), urlHash, 100, 0) dbHighlights, _ := h.db.GetHighlightsByTargetHash(r.Context(), urlHash, 100, 0) dbBookmarks, _ := h.db.GetBookmarksByTargetHash(r.Context(), 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) hidden := h.hiddenDIDSet(r.Context(), viewerDID) if len(hidden) > 0 { filteredAnnos := mergedAnnotations[:0] for _, a := range mergedAnnotations { if !hidden[a.AuthorDID] { filteredAnnos = append(filteredAnnos, a) } } mergedAnnotations = filteredAnnos
filteredHighs := mergedHighlights[:0] for _, hi := range mergedHighlights { if !hidden[hi.AuthorDID] { filteredHighs = append(filteredHighs, hi) } } mergedHighlights = filteredHighs
filteredBooks := mergedBookmarks[:0] for _, b := range mergedBookmarks { if !hidden[b.AuthorDID] { filteredBooks = append(filteredBooks, b) } } mergedBookmarks = filteredBooks }
enrichedAnnotations, _ := hydrateAnnotations(h.db, mergedAnnotations, viewerDID) enrichedHighlights, _ := hydrateHighlights(h.db, mergedHighlights, viewerDID) enrichedBookmarks, _ := hydrateBookmarks(h.db, mergedBookmarks, viewerDID)
WriteSuccess(w, 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)
filter := db.NoteFilter{Motivations: []string{"highlighting"}, Limit: limit, Offset: offset} if did != "" { filter.AuthorDID = did } if tag != "" { filter.Tag = tag }
notes, err := h.noteRepo.List(r.Context(), filter) if err != nil { WriteInternalError(w, "Internal server error") return }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", "items": items, "totalItems": len(items), })}
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 == "" { WriteBadRequest(w, "creator parameter required") return }
notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ Motivations: []string{"bookmarking"}, AuthorDID: did, Limit: limit, Offset: offset, }) if err != nil { WriteInternalError(w, "Internal server error") return }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", "items": items, "totalItems": len(items), })}
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) viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, limit); err != nil { logger.Error("Background sync error (annotations): %v", err) } }() }
notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ AuthorDID: did, Motivations: parseMotivations("commenting"), Limit: limit, Offset: offset, }) if err != nil { WriteInternalError(w, "Internal server error") return }
notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", "creator": did, "items": items, "totalItems": len(items), })}
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) viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, limit); err != nil { logger.Error("Background sync error (highlights): %v", err) } }() }
notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ AuthorDID: did, Motivations: []string{"highlighting"}, Limit: limit, Offset: offset, }) if err != nil { WriteInternalError(w, "Internal server error") return }
notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", "creator": did, "items": items, "totalItems": len(items), })}
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) viewerDID := h.getViewerDID(r)
if offset == 0 && viewerDID != "" && did == viewerDID { go func() { if _, err := h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, limit); err != nil { logger.Error("Background sync error (bookmarks): %v", err) } }() }
notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ AuthorDID: did, Motivations: []string{"bookmarking"}, Limit: limit, Offset: offset, }) if err != nil { WriteInternalError(w, "Internal server error") return }
notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", "creator": did, "items": items, "totalItems": len(items), })}
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 == "" { WriteBadRequest(w, "source or url parameter required") return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
urlHash := db.HashURL(source)
motivations := append(parseMotivations("commenting"), "highlighting") notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ AuthorDID: did, TargetHash: urlHash, Motivations: motivations, Limit: limit, Offset: offset, }) if err != nil { WriteInternalError(w, "Internal server error") return }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) } annotations, highlights, _ := notesByMotivation(items)
WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "creator": did, "source": source, "sourceHash": urlHash, "annotations": annotations, "highlights": highlights, })}
func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") return }
replies, err := h.db.GetRepliesByRoot(r.Context(), uri) if err != nil { WriteInternalError(w, "Internal server error") return }
enriched, _ := hydrateReplies(h.db, replies)
WriteSuccess(w, 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 == "" { WriteBadRequest(w, "uri query parameter required") return }
count, err := h.engagementRepo.GetLikeCount(r.Context(), uri) if err != nil { WriteInternalError(w, "Internal server error") 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.noteRepo.GetLikeByUserAndSubject(r.Context(), session.DID, uri) if err == nil && userLike != nil { liked = true } } }
WriteSuccess(w, 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 == "" { WriteBadRequest(w, "uri query parameter required") return }
history, err := h.db.GetEditHistory(r.Context(), uri) if err != nil { WriteInternalError(w, "Failed to fetch edit history") return }
if history == nil { history = []db.EditHistory{} }
w.Header().Set("Cache-Control", "public, max-age=3600") WriteSuccess(w, 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) { targetURL := r.URL.Query().Get("url") if targetURL == "" { WriteBadRequest(w, "url parameter required") return }
if cached, ok := h.metaCache.get(targetURL); ok { w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Cache", "HIT") json.NewEncoder(w).Encode(cached) return }
sfVal, loaded := h.metaCache.inflight.LoadOrStore(targetURL, &singleflight{}) sf := sfVal.(*singleflight) if loaded { sf.wg.Wait() w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Cache", "DEDUP") if sf.data != nil { json.NewEncoder(w).Encode(sf.data) } else { json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"}) } return }
sf.wg.Add(1) defer func() { sf.wg.Done() go func() { time.Sleep(100 * time.Millisecond) h.metaCache.inflight.Delete(targetURL) }() }()
select { case h.metaSem <- struct{}{}: defer func() { <-h.metaSem }() case <-r.Context().Done(): sf.data = map[string]string{"title": "", "error": "timeout"} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(sf.data) return }
data := h.fetchURLMetadata(r.Context(), targetURL) sf.data = data
ttl := 1 * time.Hour if data["title"] == "" && data["error"] != "" { ttl = 2 * time.Minute } h.metaCache.set(targetURL, data, ttl)
w.Header().Set("Cache-Control", "public, max-age=3600") WriteSuccess(w, data)}
func (h *Handler) fetchURLMetadata(ctx context.Context, targetURL string) map[string]string { ctx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", targetURL, nil) if err != nil { return map[string]string{"title": "", "error": "invalid url"} } req.Header.Set("User-Agent", "Margin/1.0 (metadata fetcher)") req.Header.Set("Accept", "text/html")
client := &http.Client{ Timeout: 4 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return fmt.Errorf("too many redirects") } return nil }, }
resp, err := client.Do(req) if err != nil { return map[string]string{"title": "", "error": "failed to fetch"} } defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) if err != nil { return map[string]string{"title": ""} }
enc, _, _ := xcharset.DetermineEncoding(body, resp.Header.Get("Content-Type")) decoded, err := enc.NewDecoder().Bytes(body) if err != nil { decoded = body }
content := string(decoded)
extractContent := func(rest string) string { for _, prefix := range []string{"content=\"", "content='"} { if contentIdx := strings.Index(rest, prefix); contentIdx != -1 { quote := prefix[len(prefix)-1] start := contentIdx + len(prefix) if end := strings.IndexByte(rest[start:], quote); end != -1 { return html.UnescapeString(rest[start : start+end]) } } } return "" }
extract := func(key string) string { for _, attr := range []string{ fmt.Sprintf("property=\"og:%s\"", key), fmt.Sprintf("property='og:%s'", key), } { if idx := strings.Index(content, attr); idx != -1 { if v := extractContent(content[idx:]); v != "" { return v } } }
for _, attr := range []string{ fmt.Sprintf("name=\"%s\"", key), fmt.Sprintf("name='%s'", key), } { if idx := strings.Index(content, attr); idx != -1 { if v := extractContent(content[idx:]); v != "" { return v } } } return "" }
extractAllMeta := func(name string) []string { var values []string seen := make(map[string]bool) for _, attr := range []string{ fmt.Sprintf("name=\"%s\"", name), fmt.Sprintf("name='%s'", name), } { rest := content for { idx := strings.Index(rest, attr) if idx == -1 { break } rest = rest[idx+len(attr):] tag := rest if end := strings.IndexByte(tag, '>'); end != -1 { tag = tag[:end] } if v := extractContent(tag); v != "" && !seen[v] { seen[v] = true values = append(values, v) } } } return values }
title := extract("title") if title == "" { if idx := strings.Index(content, "<title>"); idx != -1 { start := idx + 7 if end := strings.Index(content[start:], "</title>"); end != -1 { title = html.UnescapeString(strings.TrimSpace(content[start : start+end])) } } }
description := extract("description") image := extract("image")
var favicon string findIcon := func(rel string) string { search := fmt.Sprintf("rel=\"%s\"", rel) if idx := strings.Index(content, search); idx != -1 { startTag := strings.LastIndex(content[:idx], "<link") if startTag != -1 { endTag := strings.Index(content[startTag:], ">") if endTag != -1 { tag := content[startTag : startTag+endTag] if hrefIdx := strings.Index(tag, "href=\""); hrefIdx != -1 { start := hrefIdx + 6 if end := strings.Index(tag[start:], "\""); end != -1 { return tag[start : start+end] } } } } } return "" }
favicon = findIcon("icon") if favicon == "" { favicon = findIcon("shortcut icon") } if favicon == "" { favicon = findIcon("apple-touch-icon") }
resolveURL := func(base, target string) string { if target == "" { return "" } if strings.HasPrefix(target, "http") { return target } if strings.HasPrefix(target, "//") { return "https:" + target } u, err := url.Parse(base) if err != nil { return target } t, err := url.Parse(target) if err != nil { return target } return u.ResolveReference(t).String() }
image = resolveURL(targetURL, image) favicon = resolveURL(targetURL, favicon)
if favicon == "" { u, err := url.Parse(targetURL) if err == nil { favicon = fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host) } }
result := map[string]string{ "title": title, "description": description, "image": image, "icon": favicon, }
var atCanonical []string for _, v := range extractAllMeta("at:canonical") { if strings.HasPrefix(v, "at://") { atCanonical = append(atCanonical, v) } } if len(atCanonical) > 0 { result["at:canonical"] = strings.Join(atCanonical, " ") }
return result}
func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, "Authentication required") return }
limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
notifications, err := h.notificationRepo.GetNotifications(r.Context(), session.DID, limit, offset) if err != nil { WriteInternalError(w, "Failed to get notifications") return }
enriched, err := hydrateNotifications(h.db, notifications) if err != nil { logger.Error("Failed to hydrate notifications: %v", 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 { WriteUnauthorized(w, "Authentication required") return }
count, err := h.notificationRepo.GetUnreadNotificationCount(r.Context(), session.DID) if err != nil { WriteInternalError(w, "Failed to get count") return }
WriteSuccess(w, map[string]int{"count": count})}
func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, "Authentication required") return }
if err := h.notificationRepo.MarkNotificationsRead(r.Context(), session.DID); err != nil { WriteInternalError(w, "Failed to mark as read") return }
WriteSuccess(w, map[string]string{"status": "ok"})}func (h *Handler) getViewerDID(r *http.Request) string { cookie, err := r.Cookie("margin_session") if err != nil { return "" } sess, err := h.db.GetOAuthSessionByID(r.Context(), cookie.Value) if err != nil { return "" } return sess.DID}
func hiddenDIDSet(ctx context.Context, database *db.DB, viewerDID string) map[string]bool { hidden := make(map[string]bool) if database == nil { return hidden } if banned, err := database.GetBannedDIDs(ctx); err == nil { for _, did := range banned { hidden[did] = true } } if viewerDID == "" { return hidden }
all, err := database.GetAllHiddenDIDs(ctx, viewerDID) if err == nil { for did := range all { hidden[did] = true } } return hidden}
func filterHiddenNotes(ctx context.Context, database *db.DB, viewerDID string, notes []db.Note) []db.Note { hidden := hiddenDIDSet(ctx, database, viewerDID) if len(hidden) == 0 { return notes } filtered := notes[:0] for _, n := range notes { if !hidden[n.AuthorDID] { filtered = append(filtered, n) } } return filtered}
func filterHiddenCollectionItems(ctx context.Context, database *db.DB, viewerDID string, items []db.CollectionItem) []db.CollectionItem { hidden := hiddenDIDSet(ctx, database, viewerDID) if len(hidden) == 0 { return items } filtered := items[:0] for _, ci := range items { if !hidden[ci.AuthorDID] { filtered = append(filtered, ci) } } return filtered}
func (h *Handler) hiddenDIDSet(ctx context.Context, viewerDID string) map[string]bool { return hiddenDIDSet(ctx, h.db, viewerDID)}
func (h *Handler) filterHiddenNotes(ctx context.Context, viewerDID string, notes []db.Note) []db.Note { return filterHiddenNotes(ctx, h.db, viewerDID, notes)}
func (h *Handler) filterHiddenCollectionItems(ctx context.Context, viewerDID string, items []db.CollectionItem) []db.CollectionItem { return filterHiddenCollectionItems(ctx, h.db, viewerDID, items)}
type fullProfileRepository struct { db *db.DB}
func (r *fullProfileRepository) GetProfiles(_ context.Context, dids []string) (map[string]domain.Author, error) { raw := fetchProfilesForDIDs(r.db, dids) result := make(map[string]domain.Author, len(raw)) for did, a := range raw { result[did] = domain.Author{ DID: a.DID, Handle: a.Handle, DisplayName: a.DisplayName, Avatar: a.Avatar, } } return result, nil}
func (r *fullProfileRepository) GetProfile(ctx context.Context, did string) (*domain.Profile, error) { return r.db.GetProfile(ctx, did)}
func (r *fullProfileRepository) UpsertProfile(ctx context.Context, p *domain.Profile) error { return r.db.UpsertProfile(ctx, p)}
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { WriteBadRequest(w, "q parameter required") return }
creator := r.URL.Query().Get("creator") limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0)
filter := db.NoteFilter{Query: query, Limit: limit, Offset: offset} if creator != "" { filter.AuthorDID = creator }
notes, err := h.noteRepo.List(r.Context(), filter) if err != nil { WriteInternalError(w, "Internal server error") return }
viewerDID := h.getViewerDID(r) notes = h.filterHiddenNotes(r.Context(), viewerDID, notes)
lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) items := make([]service.APINote, len(notes)) for i, n := range notes { items[i] = h.hydration.ToAPINote(n, lc) }
WriteSuccess(w, map[string]interface{}{ "items": items, "fetchedCount": len(items), })}
func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { viewerDID := h.getViewerDID(r) if viewerDID == "" { WriteUnauthorized(w, "authentication required") return }
if !h.recommendations.IsEnabled() { WriteJSONError(w, http.StatusServiceUnavailable, "recommendations not available") return }
limit := parseIntParam(r, "limit", 20) if limit > 100 { limit = 100 }
items, err := h.recommendations.GetRecommendations(viewerDID, limit) if err != nil { logger.Error("Recommendations error for %s: %v", viewerDID, err) WriteInternalError(w, "failed to get recommendations") return }
if items == nil { items = []recommendations.RecommendedItem{} }
WriteSuccess(w, map[string]interface{}{ "items": items, "totalItems": len(items), })}
func (h *Handler) GetDocuments(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 30) if limit > 100 { limit = 100 } offset := parseIntParam(r, "offset", 0) sort := r.URL.Query().Get("sort")
var docs []db.Document var err error
switch sort { case "popular": docs, err = h.db.GetPopularDocuments(r.Context(), limit, offset) default: docs, err = h.db.GetRecentDocuments(r.Context(), limit, offset) }
if err != nil { logger.Error("GetDocuments error: %v", err) WriteInternalError(w, "failed to get documents") return }
if docs == nil { docs = []db.Document{} }
type DocumentResponse struct { URI string `json:"uri"` AuthorDID string `json:"authorDid"` Site string `json:"site"` Path *string `json:"path,omitempty"` Title string `json:"title"` Description *string `json:"description,omitempty"` Tags []string `json:"tags,omitempty"` CanonicalURL string `json:"canonicalUrl"` PublishedAt time.Time `json:"publishedAt"` }
items := make([]DocumentResponse, len(docs)) for i, d := range docs { var tags []string if d.TagsJSON != nil { json.Unmarshal([]byte(*d.TagsJSON), &tags) } items[i] = DocumentResponse{ URI: d.URI, AuthorDID: d.AuthorDID, Site: d.Site, Path: d.Path, Title: d.Title, Description: d.Description, Tags: tags, CanonicalURL: d.CanonicalURL, PublishedAt: d.PublishedAt, } }
total, _ := h.db.GetDocumentCount(r.Context())
WriteSuccess(w, map[string]interface{}{ "items": items, "totalItems": total, })}
func (h *Handler) AdminBackfill(w http.ResponseWriter, r *http.Request) { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err != nil || session == nil { WriteUnauthorized(w, "authentication required") return } if !config.Get().IsAdmin(session.DID) { WriteForbidden(w, "admin access required") return } if !h.recommendations.IsEnabled() { WriteJSONError(w, http.StatusServiceUnavailable, "embeddings not enabled (set OPENAI_API_KEY)") return }
batchSize := parseIntParam(r, "batch", 100)
type result struct { Documents int `json:"documents"` Annotations int `json:"annotations"` ProfilesRebuilt int `json:"profilesRebuilt"` Error string `json:"error,omitempty"` } res := result{}
if err := h.recommendations.BackfillDocumentEmbeddings(r.Context(), batchSize); err != nil { logger.Error("Document backfill error: %v", err) res.Error = err.Error() }
annCount, err := h.recommendations.BackfillAnnotationEmbeddings(r.Context(), batchSize) if err != nil { logger.Error("Annotation backfill error: %v", err) if res.Error != "" { res.Error += "; " } res.Error += err.Error() } res.Annotations = annCount
profileCount, err := h.recommendations.RebuildAllProfiles(r.Context()) if err != nil { logger.Error("Profile rebuild error: %v", err) if res.Error != "" { res.Error += "; " } res.Error += err.Error() } res.ProfilesRebuilt = profileCount
docCount, _ := h.db.GetDocumentCount(r.Context()) res.Documents = docCount
WriteSuccess(w, res)}