nandi/gleanpublic Fork 0
3bb7755
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Improve xrpc handlersUnverified

Julien Robert committed 2026-05-04T10:46:02+02:00 Browse files
3bb7755 parent: 945ccd2
modified internal/atproto/xrpc.go +202 -304
@@ -1,311 +1,204 @@
11 package atproto
22
33 import (
4+ "bytes"
5+ "context"
46 "database/sql"
57 "encoding/json"
68 "net/http"
79 "strconv"
810 "strings"
9-
10- "github.com/go-chi/chi/v5"
11+ "time"
1112
1213 "pkg.rbrt.fr/glean/internal/cluster"
14+ "pkg.rbrt.fr/glean/internal/db"
1315 )
1416
1517 type XRPCHandler struct {
16- db *sql.DB
18+ store *db.Store
1719 engine *cluster.Engine
1820 }
1921
20-func NewXRPCHandler(db *sql.DB, engine *cluster.Engine) *XRPCHandler {
21- return &XRPCHandler{db: db, engine: engine}
22+func NewXRPCHandler(store *db.Store, engine *cluster.Engine) *XRPCHandler {
23+ return &XRPCHandler{store: store, engine: engine}
2224 }
2325
2426 func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
25- repo := chi.URLParam(r, "repo")
26- category := r.URL.Query().Get("category")
27- limit := parseIntParam(r, "limit", 50)
28- cursor := r.URL.Query().Get("cursor")
29-
30- query := `
31- SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
32- FROM articles.subscriptions s
33- JOIN articles.feeds f ON s.feed_url = f.feed_url
34- WHERE s.user_did = ?`
35- args := []any{repo}
36-
37- if category != "" {
38- query += " AND s.category = ?"
39- args = append(args, category)
40- }
41- if cursor != "" {
42- query += " AND s.id > ?"
43- args = append(args, cursor)
27+ repo := r.URL.Query().Get("repo")
28+ if repo == "" {
29+ http.Error(w, "missing required query param: repo", http.StatusBadRequest)
30+ return
4431 }
32+ category := r.URL.Query().Get("category")
33+ limit := parseIntParam(r, "limit", 50, 100)
34+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
4535
46- query += " ORDER BY s.id ASC LIMIT ?"
47- args = append(args, limit+1)
48-
49- rows, err := h.db.QueryContext(r.Context(), query, args...)
36+ subs, err := h.store.Articles.ListSubscriptions(r.Context(), repo, category, limit+1, offset)
5037 if err != nil {
5138 http.Error(w, err.Error(), http.StatusInternalServerError)
5239 return
5340 }
54- defer rows.Close()
5541
56- subs := make([]SubscriptionView, 0)
57- for rows.Next() {
58- var id int
59- var feedURL, title string
60- var cat, addedAt sql.NullString
61- if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil {
62- http.Error(w, err.Error(), http.StatusInternalServerError)
63- return
64- }
42+ var nextCursor string
43+ if len(subs) > limit {
44+ nextCursor = strconv.Itoa(offset + limit)
45+ subs = subs[:limit]
46+ }
6547
66- sv := SubscriptionView{
67- URI: fmtATURI(repo, CollectionSubscription, strconv.Itoa(id)),
48+ views := make([]SubscriptionView, len(subs))
49+ for i, s := range subs {
50+ views[i] = SubscriptionView{
51+ URI: fmtATURI(repo, CollectionSubscription, strconv.FormatInt(s.ID, 10)),
6852 Value: SubscriptionRecord{
69- CreatedAt: addedAt.String,
70- FeedURL: feedURL,
71- Title: title,
72- Category: cat.String,
53+ CreatedAt: formatNullTime(s.AddedAt),
54+ FeedURL: s.FeedURL,
55+ Title: s.FeedTitle,
56+ Category: s.Category.String,
7357 },
74- IndexedAt: addedAt.String,
58+ IndexedAt: formatNullTime(s.AddedAt),
7559 }
76- subs = append(subs, sv)
7760 }
7861
79- resp := ListSubscriptionsResponse{Subscriptions: subs}
80- if len(subs) > limit {
81- resp.Cursor = strconv.Itoa(limit)
82- resp.Subscriptions = subs[:limit]
83- }
84-
85- writeJSON(w, resp)
62+ writeJSON(w, ListSubscriptionsResponse{
63+ Cursor: nextCursor,
64+ Subscriptions: views,
65+ })
8666 }
8767
8868 func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
8969 feedURL := r.URL.Query().Get("feedUrl")
9070 articleURL := r.URL.Query().Get("articleUrl")
9171 author := r.URL.Query().Get("author")
92- limit := parseIntParam(r, "limit", 50)
93- cursor := r.URL.Query().Get("cursor")
94-
95- query := `
96- SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url,
97- a.quote, a.note, a.tags, a.rating, a.created_at
98- FROM articles.annotations a
99- JOIN users u ON a.author_did = u.did
100- WHERE 1=1`
101- args := []any{}
102-
103- if feedURL != "" {
104- query += " AND a.feed_url = ?"
105- args = append(args, feedURL)
106- }
107- if articleURL != "" {
108- query += " AND a.article_url = ?"
109- args = append(args, articleURL)
110- }
111- if author != "" {
112- query += " AND a.author_did = ?"
113- args = append(args, author)
114- }
115- if cursor != "" {
116- query += " AND a.id > ?"
117- args = append(args, cursor)
118- }
72+ limit := parseIntParam(r, "limit", 50, 100)
73+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
11974
120- query += " ORDER BY a.id ASC LIMIT ?"
121- args = append(args, limit+1)
122-
123- rows, err := h.db.QueryContext(r.Context(), query, args...)
75+ annotations, err := h.store.Articles.ListAnnotations(r.Context(), feedURL, articleURL, author, limit+1, offset)
12476 if err != nil {
12577 http.Error(w, err.Error(), http.StatusInternalServerError)
12678 return
12779 }
128- defer rows.Close()
12980
130- annotations := make([]AnnotationView, 0)
131- for rows.Next() {
132- var uri, did, fURL, artURL, createdAt string
133- var cid, quote, note, tags sql.NullString
134- var rating sql.NullInt64
135- if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil {
136- http.Error(w, err.Error(), http.StatusInternalServerError)
137- return
138- }
81+ profiles := resolveProfiles(r.Context(), uniqueDIDsFromAnnotations(annotations))
13982
83+ var nextCursor string
84+ if len(annotations) > limit {
85+ nextCursor = strconv.Itoa(offset + limit)
86+ annotations = annotations[:limit]
87+ }
88+
89+ views := make([]AnnotationView, len(annotations))
90+ for i, a := range annotations {
14091 var tagSlice []string
141- if tags.Valid && tags.String != "" {
142- tagSlice = strings.Split(tags.String, ",")
92+ if a.Tags.Valid && a.Tags.String != "" {
93+ tagSlice = strings.Split(a.Tags.String, ",")
14394 }
14495
145- av := AnnotationView{
146- URI: uri,
147- CID: cid.String,
96+ views[i] = AnnotationView{
97+ URI: a.URI,
98+ CID: a.CID.String,
14899 Author: ActorView{
149- DID: did,
150- Handle: ResolveProfile(r.Context(), did).Handle,
100+ DID: a.AuthorDID,
101+ Handle: profiles[a.AuthorDID].Handle,
151102 },
152103 Value: AnnotationRecord{
153- CreatedAt: createdAt,
154- FeedURL: fURL,
155- ArticleURL: artURL,
156- Quote: quote.String,
157- Note: note.String,
104+ CreatedAt: formatNullTime(a.CreatedAt),
105+ FeedURL: a.FeedURL,
106+ ArticleURL: a.ArticleURL,
107+ Quote: a.Quote.String,
108+ Note: a.Note.String,
158109 Tags: tagSlice,
159- Rating: int(rating.Int64),
110+ Rating: int(a.Rating.Int64),
160111 },
161- IndexedAt: createdAt,
112+ IndexedAt: formatNullTime(a.CreatedAt),
162113 }
163- annotations = append(annotations, av)
164- }
165-
166- resp := ListAnnotationsResponse{Annotations: annotations}
167- if len(annotations) > limit {
168- resp.Cursor = strconv.Itoa(limit)
169- resp.Annotations = annotations[:limit]
170114 }
171115
172- writeJSON(w, resp)
116+ writeJSON(w, ListAnnotationsResponse{
117+ Cursor: nextCursor,
118+ Annotations: views,
119+ })
173120 }
174121
175122 func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
176123 author := r.URL.Query().Get("author")
177124 feedURL := r.URL.Query().Get("feedUrl")
178- limit := parseIntParam(r, "limit", 50)
179- cursor := r.URL.Query().Get("cursor")
180-
181- query := `
182- SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at
183- FROM articles.likes l
184- JOIN users u ON l.author_did = u.did
185- WHERE 1=1`
186- args := []any{}
125+ limit := parseIntParam(r, "limit", 50, 100)
126+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
187127
188- if author != "" {
189- query += " AND l.author_did = ?"
190- args = append(args, author)
191- }
192- if feedURL != "" {
193- query += " AND l.feed_url = ?"
194- args = append(args, feedURL)
195- }
196- if cursor != "" {
197- query += " AND l.id > ?"
198- args = append(args, cursor)
199- }
200-
201- query += " ORDER BY l.id ASC LIMIT ?"
202- args = append(args, limit+1)
203-
204- rows, err := h.db.QueryContext(r.Context(), query, args...)
128+ likes, err := h.store.Articles.ListLikes(r.Context(), author, feedURL, limit+1, offset)
205129 if err != nil {
206130 http.Error(w, err.Error(), http.StatusInternalServerError)
207131 return
208132 }
209- defer rows.Close()
210133
211- likes := make([]LikeView, 0)
212- for rows.Next() {
213- var uri, did, fURL, artURL, createdAt string
214- var cid sql.NullString
215- if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &createdAt); err != nil {
216- http.Error(w, err.Error(), http.StatusInternalServerError)
217- return
218- }
134+ profiles := resolveProfiles(r.Context(), uniqueDIDsFromLikes(likes))
219135
220- lv := LikeView{
221- URI: uri,
222- CID: cid.String,
136+ var nextCursor string
137+ if len(likes) > limit {
138+ nextCursor = strconv.Itoa(offset + limit)
139+ likes = likes[:limit]
140+ }
141+
142+ views := make([]LikeView, len(likes))
143+ for i, l := range likes {
144+ views[i] = LikeView{
145+ URI: l.URI,
146+ CID: l.CID.String,
223147 Author: ActorView{
224- DID: did,
225- Handle: ResolveProfile(r.Context(), did).Handle,
148+ DID: l.AuthorDID,
149+ Handle: profiles[l.AuthorDID].Handle,
226150 },
227151 Value: LikeRecord{
228- CreatedAt: createdAt,
229- FeedURL: fURL,
230- ArticleURL: artURL,
152+ CreatedAt: formatNullTime(l.CreatedAt),
153+ FeedURL: l.FeedURL,
154+ ArticleURL: l.ArticleURL,
231155 },
232- IndexedAt: createdAt,
156+ IndexedAt: formatNullTime(l.CreatedAt),
233157 }
234- likes = append(likes, lv)
235- }
236-
237- resp := ListLikesResponse{Likes: likes}
238- if len(likes) > limit {
239- resp.Cursor = strconv.Itoa(limit)
240- resp.Likes = likes[:limit]
241158 }
242159
243- writeJSON(w, resp)
160+ writeJSON(w, ListLikesResponse{
161+ Cursor: nextCursor,
162+ Likes: views,
163+ })
244164 }
245165
246166 func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
247- limit := parseIntParam(r, "limit", 25)
248- cursor := r.URL.Query().Get("cursor")
167+ limit := parseIntParam(r, "limit", 25, 100)
168+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
249169 since := r.URL.Query().Get("since")
250170
251- query := `
252- SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
253- FROM articles.likes l
254- LEFT JOIN articles.articles a ON l.article_url = a.url
255- WHERE 1=1`
256- args := []any{}
257-
258- if since != "" {
259- query += " AND l.created_at >= ?"
260- args = append(args, since)
261- }
262- if cursor != "" {
263- query += " AND l.article_url > ?"
264- args = append(args, cursor)
265- }
266-
267- query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?"
268- args = append(args, limit+1)
269-
270- rows, err := h.db.QueryContext(r.Context(), query, args...)
171+ items, err := h.store.Articles.ListTrendingArticles(r.Context(), "", since, limit+1, offset)
271172 if err != nil {
272173 http.Error(w, err.Error(), http.StatusInternalServerError)
273174 return
274175 }
275- defer rows.Close()
276176
277- articles := make([]TrendingArticle, 0)
278- for rows.Next() {
279- var feedURL, articleURL string
280- var title sql.NullString
281- var likeCount int
282- if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil {
283- http.Error(w, err.Error(), http.StatusInternalServerError)
284- return
285- }
286-
287- ta := TrendingArticle{
288- FeedURL: feedURL,
289- ArticleURL: articleURL,
290- Title: title.String,
291- LikeCount: likeCount,
292- }
293- articles = append(articles, ta)
177+ var nextCursor string
178+ if len(items) > limit {
179+ nextCursor = strconv.Itoa(offset + limit)
180+ items = items[:limit]
294181 }
295182
296- resp := GetTrendingResponse{Articles: articles}
297- if len(articles) > limit {
298- resp.Cursor = strconv.Itoa(limit)
299- resp.Articles = articles[:limit]
183+ articles := make([]TrendingArticle, len(items))
184+ for i, item := range items {
185+ articles[i] = TrendingArticle{
186+ FeedURL: item.FeedURL,
187+ ArticleURL: item.URL,
188+ Title: item.Title,
189+ LikeCount: item.LikeCount,
190+ }
300191 }
301192
302- writeJSON(w, resp)
193+ writeJSON(w, GetTrendingResponse{
194+ Cursor: nextCursor,
195+ Articles: articles,
196+ })
303197 }
304198
305199 func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
306200 repo := r.URL.Query().Get("repo")
307- limit := min(parseIntParam(r, "limit", 20), 50)
308-
201+ limit := parseIntParam(r, "limit", 20, 50)
309202 ctx := r.Context()
310203
311204 feedRecs, err := h.engine.GetFeedRecommendations(ctx, repo, limit)
@@ -332,11 +225,18 @@ func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request)
332225 return
333226 }
334227
228+ dids := make([]string, 0, len(peopleRecs))
229+ for _, rec := range peopleRecs {
230+ dids = append(dids, rec.DID)
231+ }
232+ profiles := resolveProfiles(ctx, dids)
233+
335234 people := make([]RecommendedPerson, 0, len(peopleRecs))
336235 for _, rec := range peopleRecs {
236+ p := profiles[rec.DID]
337237 people = append(people, RecommendedPerson{
338238 DID: rec.DID,
339- Handle: ResolveProfile(r.Context(), rec.DID).Handle,
239+ Handle: p.Handle,
340240 DisplayName: rec.DisplayName,
341241 Avatar: rec.AvatarURL,
342242 Jaccard: rec.Jaccard,
@@ -349,8 +249,8 @@ func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request)
349249
350250 func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
351251 actorsParam := r.URL.Query().Get("actors")
352- limit := parseIntParam(r, "limit", 50)
353- cursor := r.URL.Query().Get("cursor")
252+ limit := parseIntParam(r, "limit", 50, 100)
253+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
354254
355255 var dids []string
356256 if actorsParam != "" {
@@ -362,113 +262,69 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
362262 return
363263 }
364264
365- placeholders := make([]string, len(dids))
366- args := make([]any, len(dids))
367- for i, d := range dids {
368- placeholders[i] = "?"
369- args[i] = d
370- }
371-
372- query := `
373- SELECT u.did, COUNT(s.id) as subscription_count
374- FROM users u
375- LEFT JOIN articles.subscriptions s ON u.did = s.user_did
376- WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
377-
378- if cursor != "" {
379- query += " AND u.did > ?"
380- args = append(args, cursor)
265+ const maxActors = 50
266+ if len(dids) > maxActors {
267+ dids = dids[:maxActors]
381268 }
382269
383- query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?"
384- args = append(args, limit+1)
385-
386- rows, err := h.db.QueryContext(r.Context(), query, args...)
270+ lists, err := h.store.Articles.ListFeedListsByDIDs(r.Context(), dids, limit+1, offset)
387271 if err != nil {
388272 http.Error(w, err.Error(), http.StatusInternalServerError)
389273 return
390274 }
391- defer rows.Close()
392275
393- feedLists := make([]FeedListEntry, 0)
394- type userRow struct {
395- did string
396- subCount int
276+ var nextCursor string
277+ if len(lists) > limit {
278+ nextCursor = strconv.Itoa(offset + limit)
279+ lists = lists[:limit]
397280 }
398- var users []userRow
399281
400- for rows.Next() {
401- var did string
402- var subCount int
403- if err := rows.Scan(&did, &subCount); err != nil {
404- http.Error(w, err.Error(), http.StatusInternalServerError)
405- return
406- }
407- users = append(users, userRow{did: did, subCount: subCount})
408- }
409-
410- subsByDID := make(map[string][]SubscriptionRecord)
411- if len(users) > 0 {
412- ph := make([]string, len(users))
413- args := make([]any, len(users))
414- for i, u := range users {
415- ph[i] = "?"
416- args[i] = u.did
417- }
418- subRows, err := h.db.QueryContext(r.Context(), `
419- SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
420- FROM articles.subscriptions s
421- JOIN articles.feeds f ON s.feed_url = f.feed_url
422- WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
423- ORDER BY s.user_did, s.added_at DESC
424- `, args...)
425- if err != nil {
426- http.Error(w, err.Error(), http.StatusInternalServerError)
427- return
428- }
429- for subRows.Next() {
430- var did, feedURL, title string
431- var cat sql.NullString
432- if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
433- _ = subRows.Close()
434- http.Error(w, err.Error(), http.StatusInternalServerError)
435- return
282+ entries := make([]FeedListEntry, len(lists))
283+ for i, l := range lists {
284+ subs := make([]SubscriptionRecord, len(l.Subscriptions))
285+ for j, s := range l.Subscriptions {
286+ subs[j] = SubscriptionRecord{
287+ FeedURL: s.FeedURL,
288+ Title: s.Title,
289+ Category: s.Category,
436290 }
437- subsByDID[did] = append(subsByDID[did], SubscriptionRecord{
438- FeedURL: feedURL,
439- Title: title,
440- Category: cat.String,
441- })
442291 }
443- _ = subRows.Close()
444- }
445-
446- for _, u := range users {
447- feedLists = append(feedLists, FeedListEntry{
448- DID: u.did,
449- SubscriptionCount: u.subCount,
450- Subscriptions: subsByDID[u.did],
451- })
452- }
453-
454- resp := ListFeedListsResponse{Feeds: feedLists}
455- if len(feedLists) > limit {
456- resp.Cursor = strconv.Itoa(limit)
457- resp.Feeds = feedLists[:limit]
292+ entries[i] = FeedListEntry{
293+ DID: l.DID,
294+ SubscriptionCount: l.SubscriptionCount,
295+ Subscriptions: subs,
296+ }
458297 }
459298
460- writeJSON(w, resp)
299+ writeJSON(w, ListFeedListsResponse{
300+ Cursor: nextCursor,
301+ Feeds: entries,
302+ })
461303 }
462304
463-func parseIntParam(r *http.Request, key string, defaultVal int) int {
305+func parseIntParam(r *http.Request, key string, defaultVal, maxVal int) int {
464306 v := r.URL.Query().Get(key)
465307 if v == "" {
466308 return defaultVal
467309 }
468310 n, err := strconv.Atoi(v)
469- if err != nil {
311+ if err != nil || n < 1 {
470312 return defaultVal
471313 }
314+ if n > maxVal {
315+ return maxVal
316+ }
317+ return n
318+}
319+
320+func cursorToOffset(cursor string) int {
321+ if cursor == "" {
322+ return 0
323+ }
324+ n, err := strconv.Atoi(cursor)
325+ if err != nil || n < 0 {
326+ return 0
327+ }
472328 return n
473329 }
474330
@@ -476,9 +332,51 @@ func fmtATURI(did, collection, rkey string) string {
476332 return "at://" + did + "/" + collection + "/" + rkey
477333 }
478334
335+func formatNullTime(nt sql.NullTime) string {
336+ if nt.Valid {
337+ return nt.Time.Format(time.RFC3339)
338+ }
339+ return ""
340+}
341+
479342 func writeJSON(w http.ResponseWriter, v any) {
343+ var buf bytes.Buffer
480344 w.Header().Set("Content-Type", "application/json")
481- if err := json.NewEncoder(w).Encode(v); err != nil {
345+ if err := json.NewEncoder(&buf).Encode(v); err != nil {
482346 http.Error(w, err.Error(), http.StatusInternalServerError)
347+ return
348+ }
349+ w.Write(buf.Bytes())
350+}
351+
352+func uniqueDIDsFromAnnotations(annotations []*db.Annotation) []string {
353+ seen := make(map[string]bool)
354+ var dids []string
355+ for _, a := range annotations {
356+ if !seen[a.AuthorDID] {
357+ seen[a.AuthorDID] = true
358+ dids = append(dids, a.AuthorDID)
359+ }
360+ }
361+ return dids
362+}
363+
364+func uniqueDIDsFromLikes(likes []*db.Like) []string {
365+ seen := make(map[string]bool)
366+ var dids []string
367+ for _, l := range likes {
368+ if !seen[l.AuthorDID] {
369+ seen[l.AuthorDID] = true
370+ dids = append(dids, l.AuthorDID)
371+ }
372+ }
373+ return dids
374+}
375+
376+func resolveProfiles(ctx context.Context, dids []string) map[string]Profile {
377+ profiles := make(map[string]Profile, len(dids))
378+ for _, did := range dids {
379+ profiles[did] = ResolveProfile(ctx, did)
483380 }
381+ return profiles
484382 }
@@ -1,311 +1,204 @@
1 package atproto1 package atproto
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
4 "database/sql"6 "database/sql"
5 "encoding/json"7 "encoding/json"
6 "net/http"8 "net/http"
7 "strconv"9 "strconv"
8 "strings"10 "strings"
9-11+ "time"
10- "github.com/go-chi/chi/v5"
11 12
12 "pkg.rbrt.fr/glean/internal/cluster"13 "pkg.rbrt.fr/glean/internal/cluster"
14+ "pkg.rbrt.fr/glean/internal/db"
13 )15 )
14 16
15 type XRPCHandler struct {17 type XRPCHandler struct {
16- db *sql.DB18+ store *db.Store
17 engine *cluster.Engine19 engine *cluster.Engine
18 }20 }
19 21
20-func NewXRPCHandler(db *sql.DB, engine *cluster.Engine) *XRPCHandler {22+func NewXRPCHandler(store *db.Store, engine *cluster.Engine) *XRPCHandler {
21- return &XRPCHandler{db: db, engine: engine}23+ return &XRPCHandler{store: store, engine: engine}
22 }24 }
23 25
24 func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {26 func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) {
25- repo := chi.URLParam(r, "repo")27+ repo := r.URL.Query().Get("repo")
26- category := r.URL.Query().Get("category")28+ if repo == "" {
27- limit := parseIntParam(r, "limit", 50)29+ http.Error(w, "missing required query param: repo", http.StatusBadRequest)
28- cursor := r.URL.Query().Get("cursor")30+ return
29-
30- query := `
31- SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
32- FROM articles.subscriptions s
33- JOIN articles.feeds f ON s.feed_url = f.feed_url
34- WHERE s.user_did = ?`
35- args := []any{repo}
36-
37- if category != "" {
38- query += " AND s.category = ?"
39- args = append(args, category)
40- }
41- if cursor != "" {
42- query += " AND s.id > ?"
43- args = append(args, cursor)
44 }31 }
32+ category := r.URL.Query().Get("category")
33+ limit := parseIntParam(r, "limit", 50, 100)
34+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
45 35
46- query += " ORDER BY s.id ASC LIMIT ?"36+ subs, err := h.store.Articles.ListSubscriptions(r.Context(), repo, category, limit+1, offset)
47- args = append(args, limit+1)
48-
49- rows, err := h.db.QueryContext(r.Context(), query, args...)
50 if err != nil {37 if err != nil {
51 http.Error(w, err.Error(), http.StatusInternalServerError)38 http.Error(w, err.Error(), http.StatusInternalServerError)
52 return39 return
53 }40 }
54- defer rows.Close()
55 41
56- subs := make([]SubscriptionView, 0)42+ var nextCursor string
57- for rows.Next() {43+ if len(subs) > limit {
58- var id int44+ nextCursor = strconv.Itoa(offset + limit)
59- var feedURL, title string45+ subs = subs[:limit]
60- var cat, addedAt sql.NullString46+ }
61- if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil {
62- http.Error(w, err.Error(), http.StatusInternalServerError)
63- return
64- }
65 47
66- sv := SubscriptionView{48+ views := make([]SubscriptionView, len(subs))
67- URI: fmtATURI(repo, CollectionSubscription, strconv.Itoa(id)),49+ for i, s := range subs {
50+ views[i] = SubscriptionView{
51+ URI: fmtATURI(repo, CollectionSubscription, strconv.FormatInt(s.ID, 10)),
68 Value: SubscriptionRecord{52 Value: SubscriptionRecord{
69- CreatedAt: addedAt.String,53+ CreatedAt: formatNullTime(s.AddedAt),
70- FeedURL: feedURL,54+ FeedURL: s.FeedURL,
71- Title: title,55+ Title: s.FeedTitle,
72- Category: cat.String,56+ Category: s.Category.String,
73 },57 },
74- IndexedAt: addedAt.String,58+ IndexedAt: formatNullTime(s.AddedAt),
75 }59 }
76- subs = append(subs, sv)
77 }60 }
78 61
79- resp := ListSubscriptionsResponse{Subscriptions: subs}62+ writeJSON(w, ListSubscriptionsResponse{
80- if len(subs) > limit {63+ Cursor: nextCursor,
81- resp.Cursor = strconv.Itoa(limit)64+ Subscriptions: views,
82- resp.Subscriptions = subs[:limit]65+ })
83- }
84-
85- writeJSON(w, resp)
86 }66 }
87 67
88 func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {68 func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
89 feedURL := r.URL.Query().Get("feedUrl")69 feedURL := r.URL.Query().Get("feedUrl")
90 articleURL := r.URL.Query().Get("articleUrl")70 articleURL := r.URL.Query().Get("articleUrl")
91 author := r.URL.Query().Get("author")71 author := r.URL.Query().Get("author")
92- limit := parseIntParam(r, "limit", 50)72+ limit := parseIntParam(r, "limit", 50, 100)
93- cursor := r.URL.Query().Get("cursor")73+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
94-
95- query := `
96- SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url,
97- a.quote, a.note, a.tags, a.rating, a.created_at
98- FROM articles.annotations a
99- JOIN users u ON a.author_did = u.did
100- WHERE 1=1`
101- args := []any{}
102-
103- if feedURL != "" {
104- query += " AND a.feed_url = ?"
105- args = append(args, feedURL)
106- }
107- if articleURL != "" {
108- query += " AND a.article_url = ?"
109- args = append(args, articleURL)
110- }
111- if author != "" {
112- query += " AND a.author_did = ?"
113- args = append(args, author)
114- }
115- if cursor != "" {
116- query += " AND a.id > ?"
117- args = append(args, cursor)
118- }
119 74
120- query += " ORDER BY a.id ASC LIMIT ?"75+ annotations, err := h.store.Articles.ListAnnotations(r.Context(), feedURL, articleURL, author, limit+1, offset)
121- args = append(args, limit+1)
122-
123- rows, err := h.db.QueryContext(r.Context(), query, args...)
124 if err != nil {76 if err != nil {
125 http.Error(w, err.Error(), http.StatusInternalServerError)77 http.Error(w, err.Error(), http.StatusInternalServerError)
126 return78 return
127 }79 }
128- defer rows.Close()
129 80
130- annotations := make([]AnnotationView, 0)81+ profiles := resolveProfiles(r.Context(), uniqueDIDsFromAnnotations(annotations))
131- for rows.Next() {
132- var uri, did, fURL, artURL, createdAt string
133- var cid, quote, note, tags sql.NullString
134- var rating sql.NullInt64
135- if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil {
136- http.Error(w, err.Error(), http.StatusInternalServerError)
137- return
138- }
139 82
83+ var nextCursor string
84+ if len(annotations) > limit {
85+ nextCursor = strconv.Itoa(offset + limit)
86+ annotations = annotations[:limit]
87+ }
88+
89+ views := make([]AnnotationView, len(annotations))
90+ for i, a := range annotations {
140 var tagSlice []string91 var tagSlice []string
141- if tags.Valid && tags.String != "" {92+ if a.Tags.Valid && a.Tags.String != "" {
142- tagSlice = strings.Split(tags.String, ",")93+ tagSlice = strings.Split(a.Tags.String, ",")
143 }94 }
144 95
145- av := AnnotationView{96+ views[i] = AnnotationView{
146- URI: uri,97+ URI: a.URI,
147- CID: cid.String,98+ CID: a.CID.String,
148 Author: ActorView{99 Author: ActorView{
149- DID: did,100+ DID: a.AuthorDID,
150- Handle: ResolveProfile(r.Context(), did).Handle,101+ Handle: profiles[a.AuthorDID].Handle,
151 },102 },
152 Value: AnnotationRecord{103 Value: AnnotationRecord{
153- CreatedAt: createdAt,104+ CreatedAt: formatNullTime(a.CreatedAt),
154- FeedURL: fURL,105+ FeedURL: a.FeedURL,
155- ArticleURL: artURL,106+ ArticleURL: a.ArticleURL,
156- Quote: quote.String,107+ Quote: a.Quote.String,
157- Note: note.String,108+ Note: a.Note.String,
158 Tags: tagSlice,109 Tags: tagSlice,
159- Rating: int(rating.Int64),110+ Rating: int(a.Rating.Int64),
160 },111 },
161- IndexedAt: createdAt,112+ IndexedAt: formatNullTime(a.CreatedAt),
162 }113 }
163- annotations = append(annotations, av)
164- }
165-
166- resp := ListAnnotationsResponse{Annotations: annotations}
167- if len(annotations) > limit {
168- resp.Cursor = strconv.Itoa(limit)
169- resp.Annotations = annotations[:limit]
170 }114 }
171 115
172- writeJSON(w, resp)116+ writeJSON(w, ListAnnotationsResponse{
117+ Cursor: nextCursor,
118+ Annotations: views,
119+ })
173 }120 }
174 121
175 func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {122 func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
176 author := r.URL.Query().Get("author")123 author := r.URL.Query().Get("author")
177 feedURL := r.URL.Query().Get("feedUrl")124 feedURL := r.URL.Query().Get("feedUrl")
178- limit := parseIntParam(r, "limit", 50)125+ limit := parseIntParam(r, "limit", 50, 100)
179- cursor := r.URL.Query().Get("cursor")126+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
180-
181- query := `
182- SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at
183- FROM articles.likes l
184- JOIN users u ON l.author_did = u.did
185- WHERE 1=1`
186- args := []any{}
187 127
188- if author != "" {128+ likes, err := h.store.Articles.ListLikes(r.Context(), author, feedURL, limit+1, offset)
189- query += " AND l.author_did = ?"
190- args = append(args, author)
191- }
192- if feedURL != "" {
193- query += " AND l.feed_url = ?"
194- args = append(args, feedURL)
195- }
196- if cursor != "" {
197- query += " AND l.id > ?"
198- args = append(args, cursor)
199- }
200-
201- query += " ORDER BY l.id ASC LIMIT ?"
202- args = append(args, limit+1)
203-
204- rows, err := h.db.QueryContext(r.Context(), query, args...)
205 if err != nil {129 if err != nil {
206 http.Error(w, err.Error(), http.StatusInternalServerError)130 http.Error(w, err.Error(), http.StatusInternalServerError)
207 return131 return
208 }132 }
209- defer rows.Close()
210 133
211- likes := make([]LikeView, 0)134+ profiles := resolveProfiles(r.Context(), uniqueDIDsFromLikes(likes))
212- for rows.Next() {
213- var uri, did, fURL, artURL, createdAt string
214- var cid sql.NullString
215- if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &createdAt); err != nil {
216- http.Error(w, err.Error(), http.StatusInternalServerError)
217- return
218- }
219 135
220- lv := LikeView{136+ var nextCursor string
221- URI: uri,137+ if len(likes) > limit {
222- CID: cid.String,138+ nextCursor = strconv.Itoa(offset + limit)
139+ likes = likes[:limit]
140+ }
141+
142+ views := make([]LikeView, len(likes))
143+ for i, l := range likes {
144+ views[i] = LikeView{
145+ URI: l.URI,
146+ CID: l.CID.String,
223 Author: ActorView{147 Author: ActorView{
224- DID: did,148+ DID: l.AuthorDID,
225- Handle: ResolveProfile(r.Context(), did).Handle,149+ Handle: profiles[l.AuthorDID].Handle,
226 },150 },
227 Value: LikeRecord{151 Value: LikeRecord{
228- CreatedAt: createdAt,152+ CreatedAt: formatNullTime(l.CreatedAt),
229- FeedURL: fURL,153+ FeedURL: l.FeedURL,
230- ArticleURL: artURL,154+ ArticleURL: l.ArticleURL,
231 },155 },
232- IndexedAt: createdAt,156+ IndexedAt: formatNullTime(l.CreatedAt),
233 }157 }
234- likes = append(likes, lv)
235- }
236-
237- resp := ListLikesResponse{Likes: likes}
238- if len(likes) > limit {
239- resp.Cursor = strconv.Itoa(limit)
240- resp.Likes = likes[:limit]
241 }158 }
242 159
243- writeJSON(w, resp)160+ writeJSON(w, ListLikesResponse{
161+ Cursor: nextCursor,
162+ Likes: views,
163+ })
244 }164 }
245 165
246 func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {166 func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
247- limit := parseIntParam(r, "limit", 25)167+ limit := parseIntParam(r, "limit", 25, 100)
248- cursor := r.URL.Query().Get("cursor")168+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
249 since := r.URL.Query().Get("since")169 since := r.URL.Query().Get("since")
250 170
251- query := `171+ items, err := h.store.Articles.ListTrendingArticles(r.Context(), "", since, limit+1, offset)
252- SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
253- FROM articles.likes l
254- LEFT JOIN articles.articles a ON l.article_url = a.url
255- WHERE 1=1`
256- args := []any{}
257-
258- if since != "" {
259- query += " AND l.created_at >= ?"
260- args = append(args, since)
261- }
262- if cursor != "" {
263- query += " AND l.article_url > ?"
264- args = append(args, cursor)
265- }
266-
267- query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?"
268- args = append(args, limit+1)
269-
270- rows, err := h.db.QueryContext(r.Context(), query, args...)
271 if err != nil {172 if err != nil {
272 http.Error(w, err.Error(), http.StatusInternalServerError)173 http.Error(w, err.Error(), http.StatusInternalServerError)
273 return174 return
274 }175 }
275- defer rows.Close()
276 176
277- articles := make([]TrendingArticle, 0)177+ var nextCursor string
278- for rows.Next() {178+ if len(items) > limit {
279- var feedURL, articleURL string179+ nextCursor = strconv.Itoa(offset + limit)
280- var title sql.NullString180+ items = items[:limit]
281- var likeCount int
282- if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil {
283- http.Error(w, err.Error(), http.StatusInternalServerError)
284- return
285- }
286-
287- ta := TrendingArticle{
288- FeedURL: feedURL,
289- ArticleURL: articleURL,
290- Title: title.String,
291- LikeCount: likeCount,
292- }
293- articles = append(articles, ta)
294 }181 }
295 182
296- resp := GetTrendingResponse{Articles: articles}183+ articles := make([]TrendingArticle, len(items))
297- if len(articles) > limit {184+ for i, item := range items {
298- resp.Cursor = strconv.Itoa(limit)185+ articles[i] = TrendingArticle{
299- resp.Articles = articles[:limit]186+ FeedURL: item.FeedURL,
187+ ArticleURL: item.URL,
188+ Title: item.Title,
189+ LikeCount: item.LikeCount,
190+ }
300 }191 }
301 192
302- writeJSON(w, resp)193+ writeJSON(w, GetTrendingResponse{
194+ Cursor: nextCursor,
195+ Articles: articles,
196+ })
303 }197 }
304 198
305 func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {199 func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
306 repo := r.URL.Query().Get("repo")200 repo := r.URL.Query().Get("repo")
307- limit := min(parseIntParam(r, "limit", 20), 50)201+ limit := parseIntParam(r, "limit", 20, 50)
308-
309 ctx := r.Context()202 ctx := r.Context()
310 203
311 feedRecs, err := h.engine.GetFeedRecommendations(ctx, repo, limit)204 feedRecs, err := h.engine.GetFeedRecommendations(ctx, repo, limit)
@@ -332,11 +225,18 @@ func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request)
332 return225 return
333 }226 }
334 227
228+ dids := make([]string, 0, len(peopleRecs))
229+ for _, rec := range peopleRecs {
230+ dids = append(dids, rec.DID)
231+ }
232+ profiles := resolveProfiles(ctx, dids)
233+
335 people := make([]RecommendedPerson, 0, len(peopleRecs))234 people := make([]RecommendedPerson, 0, len(peopleRecs))
336 for _, rec := range peopleRecs {235 for _, rec := range peopleRecs {
236+ p := profiles[rec.DID]
337 people = append(people, RecommendedPerson{237 people = append(people, RecommendedPerson{
338 DID: rec.DID,238 DID: rec.DID,
339- Handle: ResolveProfile(r.Context(), rec.DID).Handle,239+ Handle: p.Handle,
340 DisplayName: rec.DisplayName,240 DisplayName: rec.DisplayName,
341 Avatar: rec.AvatarURL,241 Avatar: rec.AvatarURL,
342 Jaccard: rec.Jaccard,242 Jaccard: rec.Jaccard,
@@ -349,8 +249,8 @@ func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request)
349 249
350 func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {250 func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
351 actorsParam := r.URL.Query().Get("actors")251 actorsParam := r.URL.Query().Get("actors")
352- limit := parseIntParam(r, "limit", 50)252+ limit := parseIntParam(r, "limit", 50, 100)
353- cursor := r.URL.Query().Get("cursor")253+ offset := cursorToOffset(r.URL.Query().Get("cursor"))
354 254
355 var dids []string255 var dids []string
356 if actorsParam != "" {256 if actorsParam != "" {
@@ -362,113 +262,69 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
362 return262 return
363 }263 }
364 264
365- placeholders := make([]string, len(dids))265+ const maxActors = 50
366- args := make([]any, len(dids))266+ if len(dids) > maxActors {
367- for i, d := range dids {267+ dids = dids[:maxActors]
368- placeholders[i] = "?"
369- args[i] = d
370- }
371-
372- query := `
373- SELECT u.did, COUNT(s.id) as subscription_count
374- FROM users u
375- LEFT JOIN articles.subscriptions s ON u.did = s.user_did
376- WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
377-
378- if cursor != "" {
379- query += " AND u.did > ?"
380- args = append(args, cursor)
381 }268 }
382 269
383- query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?"270+ lists, err := h.store.Articles.ListFeedListsByDIDs(r.Context(), dids, limit+1, offset)
384- args = append(args, limit+1)
385-
386- rows, err := h.db.QueryContext(r.Context(), query, args...)
387 if err != nil {271 if err != nil {
388 http.Error(w, err.Error(), http.StatusInternalServerError)272 http.Error(w, err.Error(), http.StatusInternalServerError)
389 return273 return
390 }274 }
391- defer rows.Close()
392 275
393- feedLists := make([]FeedListEntry, 0)276+ var nextCursor string
394- type userRow struct {277+ if len(lists) > limit {
395- did string278+ nextCursor = strconv.Itoa(offset + limit)
396- subCount int279+ lists = lists[:limit]
397 }280 }
398- var users []userRow
399 281
400- for rows.Next() {282+ entries := make([]FeedListEntry, len(lists))
401- var did string283+ for i, l := range lists {
402- var subCount int284+ subs := make([]SubscriptionRecord, len(l.Subscriptions))
403- if err := rows.Scan(&did, &subCount); err != nil {285+ for j, s := range l.Subscriptions {
404- http.Error(w, err.Error(), http.StatusInternalServerError)286+ subs[j] = SubscriptionRecord{
405- return287+ FeedURL: s.FeedURL,
406- }288+ Title: s.Title,
407- users = append(users, userRow{did: did, subCount: subCount})289+ Category: s.Category,
408- }
409-
410- subsByDID := make(map[string][]SubscriptionRecord)
411- if len(users) > 0 {
412- ph := make([]string, len(users))
413- args := make([]any, len(users))
414- for i, u := range users {
415- ph[i] = "?"
416- args[i] = u.did
417- }
418- subRows, err := h.db.QueryContext(r.Context(), `
419- SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
420- FROM articles.subscriptions s
421- JOIN articles.feeds f ON s.feed_url = f.feed_url
422- WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
423- ORDER BY s.user_did, s.added_at DESC
424- `, args...)
425- if err != nil {
426- http.Error(w, err.Error(), http.StatusInternalServerError)
427- return
428- }
429- for subRows.Next() {
430- var did, feedURL, title string
431- var cat sql.NullString
432- if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
433- _ = subRows.Close()
434- http.Error(w, err.Error(), http.StatusInternalServerError)
435- return
436 }290 }
437- subsByDID[did] = append(subsByDID[did], SubscriptionRecord{
438- FeedURL: feedURL,
439- Title: title,
440- Category: cat.String,
441- })
442 }291 }
443- _ = subRows.Close()292+ entries[i] = FeedListEntry{
444- }293+ DID: l.DID,
445-294+ SubscriptionCount: l.SubscriptionCount,
446- for _, u := range users {295+ Subscriptions: subs,
447- feedLists = append(feedLists, FeedListEntry{296+ }
448- DID: u.did,
449- SubscriptionCount: u.subCount,
450- Subscriptions: subsByDID[u.did],
451- })
452- }
453-
454- resp := ListFeedListsResponse{Feeds: feedLists}
455- if len(feedLists) > limit {
456- resp.Cursor = strconv.Itoa(limit)
457- resp.Feeds = feedLists[:limit]
458 }297 }
459 298
460- writeJSON(w, resp)299+ writeJSON(w, ListFeedListsResponse{
300+ Cursor: nextCursor,
301+ Feeds: entries,
302+ })
461 }303 }
462 304
463-func parseIntParam(r *http.Request, key string, defaultVal int) int {305+func parseIntParam(r *http.Request, key string, defaultVal, maxVal int) int {
464 v := r.URL.Query().Get(key)306 v := r.URL.Query().Get(key)
465 if v == "" {307 if v == "" {
466 return defaultVal308 return defaultVal
467 }309 }
468 n, err := strconv.Atoi(v)310 n, err := strconv.Atoi(v)
469- if err != nil {311+ if err != nil || n < 1 {
470 return defaultVal312 return defaultVal
471 }313 }
314+ if n > maxVal {
315+ return maxVal
316+ }
317+ return n
318+}
319+
320+func cursorToOffset(cursor string) int {
321+ if cursor == "" {
322+ return 0
323+ }
324+ n, err := strconv.Atoi(cursor)
325+ if err != nil || n < 0 {
326+ return 0
327+ }
472 return n328 return n
473 }329 }
474 330
@@ -476,9 +332,51 @@ func fmtATURI(did, collection, rkey string) string {
476 return "at://" + did + "/" + collection + "/" + rkey332 return "at://" + did + "/" + collection + "/" + rkey
477 }333 }
478 334
335+func formatNullTime(nt sql.NullTime) string {
336+ if nt.Valid {
337+ return nt.Time.Format(time.RFC3339)
338+ }
339+ return ""
340+}
341+
479 func writeJSON(w http.ResponseWriter, v any) {342 func writeJSON(w http.ResponseWriter, v any) {
343+ var buf bytes.Buffer
480 w.Header().Set("Content-Type", "application/json")344 w.Header().Set("Content-Type", "application/json")
481- if err := json.NewEncoder(w).Encode(v); err != nil {345+ if err := json.NewEncoder(&buf).Encode(v); err != nil {
482 http.Error(w, err.Error(), http.StatusInternalServerError)346 http.Error(w, err.Error(), http.StatusInternalServerError)
347+ return
348+ }
349+ w.Write(buf.Bytes())
350+}
351+
352+func uniqueDIDsFromAnnotations(annotations []*db.Annotation) []string {
353+ seen := make(map[string]bool)
354+ var dids []string
355+ for _, a := range annotations {
356+ if !seen[a.AuthorDID] {
357+ seen[a.AuthorDID] = true
358+ dids = append(dids, a.AuthorDID)
359+ }
360+ }
361+ return dids
362+}
363+
364+func uniqueDIDsFromLikes(likes []*db.Like) []string {
365+ seen := make(map[string]bool)
366+ var dids []string
367+ for _, l := range likes {
368+ if !seen[l.AuthorDID] {
369+ seen[l.AuthorDID] = true
370+ dids = append(dids, l.AuthorDID)
371+ }
372+ }
373+ return dids
374+}
375+
376+func resolveProfiles(ctx context.Context, dids []string) map[string]Profile {
377+ profiles := make(map[string]Profile, len(dids))
378+ for _, did := range dids {
379+ profiles[did] = ResolveProfile(ctx, did)
483 }380 }
381+ return profiles
484 }382 }
modified internal/db/feed.go +90 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "database/sql"
66 "errors"
7+ "strings"
78 "time"
89
910 "pkg.rbrt.fr/glean/internal/feed"
@@ -498,3 +499,92 @@ func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string
498499 }
499500 return feeds, rows.Err()
500501 }
502+
503+type FeedListByDID struct {
504+ DID string
505+ SubscriptionCount int
506+ Subscriptions []SubData
507+}
508+
509+func (s *ArticleStore) ListFeedListsByDIDs(ctx context.Context, dids []string, limit, offset int) ([]*FeedListByDID, error) {
510+ placeholders := make([]string, len(dids))
511+ args := make([]any, len(dids))
512+ for i, d := range dids {
513+ placeholders[i] = "?"
514+ args[i] = d
515+ }
516+
517+ query := `
518+ SELECT u.did, COUNT(s.id) as subscription_count
519+ FROM users u
520+ LEFT JOIN articles.subscriptions s ON u.did = s.user_did
521+ WHERE u.did IN (` + strings.Join(placeholders, ",") + `)
522+ GROUP BY u.did ORDER BY u.did ASC LIMIT ? OFFSET ?`
523+ args = append(args, limit, offset)
524+
525+ rows, err := s.db.QueryContext(ctx, query, args...)
526+ if err != nil {
527+ return nil, err
528+ }
529+ defer rows.Close()
530+
531+ type userRow struct {
532+ did string
533+ subCount int
534+ }
535+ var users []userRow
536+ for rows.Next() {
537+ var did string
538+ var subCount int
539+ if err := rows.Scan(&did, &subCount); err != nil {
540+ return nil, err
541+ }
542+ users = append(users, userRow{did: did, subCount: subCount})
543+ }
544+ if err := rows.Err(); err != nil {
545+ return nil, err
546+ }
547+
548+ subsByDID := make(map[string][]SubData)
549+ if len(users) > 0 {
550+ ph := make([]string, len(users))
551+ subArgs := make([]any, len(users))
552+ for i, u := range users {
553+ ph[i] = "?"
554+ subArgs[i] = u.did
555+ }
556+ subRows, err := s.db.QueryContext(ctx, `
557+ SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), COALESCE(s.category, '')
558+ FROM articles.subscriptions s
559+ JOIN articles.feeds f ON s.feed_url = f.feed_url
560+ WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
561+ ORDER BY s.user_did, s.added_at DESC
562+ `, subArgs...)
563+ if err != nil {
564+ return nil, err
565+ }
566+ for subRows.Next() {
567+ var did, feedURL, title, cat string
568+ if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
569+ _ = subRows.Close()
570+ return nil, err
571+ }
572+ subsByDID[did] = append(subsByDID[did], SubData{
573+ FeedURL: feedURL,
574+ Title: title,
575+ Category: cat,
576+ })
577+ }
578+ _ = subRows.Close()
579+ }
580+
581+ var result []*FeedListByDID
582+ for _, u := range users {
583+ result = append(result, &FeedListByDID{
584+ DID: u.did,
585+ SubscriptionCount: u.subCount,
586+ Subscriptions: subsByDID[u.did],
587+ })
588+ }
589+ return result, nil
590+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "errors"6 "errors"
7+ "strings"
7 "time"8 "time"
8 9
9 "pkg.rbrt.fr/glean/internal/feed"10 "pkg.rbrt.fr/glean/internal/feed"
@@ -498,3 +499,92 @@ func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string
498 }499 }
499 return feeds, rows.Err()500 return feeds, rows.Err()
500 }501 }
502+
503+type FeedListByDID struct {
504+ DID string
505+ SubscriptionCount int
506+ Subscriptions []SubData
507+}
508+
509+func (s *ArticleStore) ListFeedListsByDIDs(ctx context.Context, dids []string, limit, offset int) ([]*FeedListByDID, error) {
510+ placeholders := make([]string, len(dids))
511+ args := make([]any, len(dids))
512+ for i, d := range dids {
513+ placeholders[i] = "?"
514+ args[i] = d
515+ }
516+
517+ query := `
518+ SELECT u.did, COUNT(s.id) as subscription_count
519+ FROM users u
520+ LEFT JOIN articles.subscriptions s ON u.did = s.user_did
521+ WHERE u.did IN (` + strings.Join(placeholders, ",") + `)
522+ GROUP BY u.did ORDER BY u.did ASC LIMIT ? OFFSET ?`
523+ args = append(args, limit, offset)
524+
525+ rows, err := s.db.QueryContext(ctx, query, args...)
526+ if err != nil {
527+ return nil, err
528+ }
529+ defer rows.Close()
530+
531+ type userRow struct {
532+ did string
533+ subCount int
534+ }
535+ var users []userRow
536+ for rows.Next() {
537+ var did string
538+ var subCount int
539+ if err := rows.Scan(&did, &subCount); err != nil {
540+ return nil, err
541+ }
542+ users = append(users, userRow{did: did, subCount: subCount})
543+ }
544+ if err := rows.Err(); err != nil {
545+ return nil, err
546+ }
547+
548+ subsByDID := make(map[string][]SubData)
549+ if len(users) > 0 {
550+ ph := make([]string, len(users))
551+ subArgs := make([]any, len(users))
552+ for i, u := range users {
553+ ph[i] = "?"
554+ subArgs[i] = u.did
555+ }
556+ subRows, err := s.db.QueryContext(ctx, `
557+ SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), COALESCE(s.category, '')
558+ FROM articles.subscriptions s
559+ JOIN articles.feeds f ON s.feed_url = f.feed_url
560+ WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
561+ ORDER BY s.user_did, s.added_at DESC
562+ `, subArgs...)
563+ if err != nil {
564+ return nil, err
565+ }
566+ for subRows.Next() {
567+ var did, feedURL, title, cat string
568+ if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
569+ _ = subRows.Close()
570+ return nil, err
571+ }
572+ subsByDID[did] = append(subsByDID[did], SubData{
573+ FeedURL: feedURL,
574+ Title: title,
575+ Category: cat,
576+ })
577+ }
578+ _ = subRows.Close()
579+ }
580+
581+ var result []*FeedListByDID
582+ for _, u := range users {
583+ result = append(result, &FeedListByDID{
584+ DID: u.did,
585+ SubscriptionCount: u.subCount,
586+ Subscriptions: subsByDID[u.did],
587+ })
588+ }
589+ return result, nil
590+}
modified internal/server/server.go +1 -1
@@ -211,7 +211,7 @@ func (s *Server) setupRoutes() {
211211 s.router.Post("/auth/logout", s.handleAuthLogout)
212212 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
213213
214- xrpc := atproto.NewXRPCHandler(s.dbs.SQLDB(), s.engine)
214+ xrpc := atproto.NewXRPCHandler(s.dbs, s.engine)
215215 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
216216 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
217217 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
@@ -211,7 +211,7 @@ func (s *Server) setupRoutes() {
211 s.router.Post("/auth/logout", s.handleAuthLogout)211 s.router.Post("/auth/logout", s.handleAuthLogout)
212 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)212 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
213 213
214- xrpc := atproto.NewXRPCHandler(s.dbs.SQLDB(), s.engine)214+ xrpc := atproto.NewXRPCHandler(s.dbs, s.engine)
215 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)215 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
216 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)216 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
217 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)217 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)