A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

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

Improve xrpc handlers

+293 -305
+202 -304
internal/atproto/xrpc.go
··· 1 1 package atproto 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "database/sql" 5 7 "encoding/json" 6 8 "net/http" 7 9 "strconv" 8 10 "strings" 9 - 10 - "github.com/go-chi/chi/v5" 11 + "time" 11 12 12 13 "pkg.rbrt.fr/glean/internal/cluster" 14 + "pkg.rbrt.fr/glean/internal/db" 13 15 ) 14 16 15 17 type XRPCHandler struct { 16 - db *sql.DB 18 + store *db.Store 17 19 engine *cluster.Engine 18 20 } 19 21 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} 22 24 } 23 25 24 26 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 44 31 } 45 - 46 - query += " ORDER BY s.id ASC LIMIT ?" 47 - args = append(args, limit+1) 32 + category := r.URL.Query().Get("category") 33 + limit := parseIntParam(r, "limit", 50, 100) 34 + offset := cursorToOffset(r.URL.Query().Get("cursor")) 48 35 49 - rows, err := h.db.QueryContext(r.Context(), query, args...) 36 + subs, err := h.store.Articles.ListSubscriptions(r.Context(), repo, category, limit+1, offset) 50 37 if err != nil { 51 38 http.Error(w, err.Error(), http.StatusInternalServerError) 52 39 return 53 40 } 54 - defer rows.Close() 55 41 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 + } 65 47 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)), 68 52 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, 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} 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 + }) 86 66 } 87 67 88 68 func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) { 89 69 feedURL := r.URL.Query().Get("feedUrl") 90 70 articleURL := r.URL.Query().Get("articleUrl") 91 71 author := r.URL.Query().Get("author") 92 - limit := parseIntParam(r, "limit", 50) 93 - cursor := r.URL.Query().Get("cursor") 72 + limit := parseIntParam(r, "limit", 50, 100) 73 + offset := cursorToOffset(r.URL.Query().Get("cursor")) 94 74 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 - 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) 124 76 if err != nil { 125 77 http.Error(w, err.Error(), http.StatusInternalServerError) 126 78 return 127 79 } 128 - defer rows.Close() 129 80 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)) 82 + 83 + var nextCursor string 84 + if len(annotations) > limit { 85 + nextCursor = strconv.Itoa(offset + limit) 86 + annotations = annotations[:limit] 87 + } 139 88 89 + views := make([]AnnotationView, len(annotations)) 90 + for i, a := range annotations { 140 91 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, ",") 143 94 } 144 95 145 - av := AnnotationView{ 146 - URI: uri, 147 - CID: cid.String, 96 + views[i] = AnnotationView{ 97 + URI: a.URI, 98 + CID: a.CID.String, 148 99 Author: ActorView{ 149 - DID: did, 150 - Handle: ResolveProfile(r.Context(), did).Handle, 100 + DID: a.AuthorDID, 101 + Handle: profiles[a.AuthorDID].Handle, 151 102 }, 152 103 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, 158 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 114 } 165 115 166 - resp := ListAnnotationsResponse{Annotations: annotations} 167 - if len(annotations) > limit { 168 - resp.Cursor = strconv.Itoa(limit) 169 - resp.Annotations = annotations[:limit] 170 - } 171 - 172 - writeJSON(w, resp) 116 + writeJSON(w, ListAnnotationsResponse{ 117 + Cursor: nextCursor, 118 + Annotations: views, 119 + }) 173 120 } 174 121 175 122 func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) { 176 123 author := r.URL.Query().Get("author") 177 124 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{} 187 - 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) 125 + limit := parseIntParam(r, "limit", 50, 100) 126 + offset := cursorToOffset(r.URL.Query().Get("cursor")) 203 127 204 - rows, err := h.db.QueryContext(r.Context(), query, args...) 128 + likes, err := h.store.Articles.ListLikes(r.Context(), author, feedURL, limit+1, offset) 205 129 if err != nil { 206 130 http.Error(w, err.Error(), http.StatusInternalServerError) 207 131 return 208 132 } 209 - defer rows.Close() 210 133 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)) 135 + 136 + var nextCursor string 137 + if len(likes) > limit { 138 + nextCursor = strconv.Itoa(offset + limit) 139 + likes = likes[:limit] 140 + } 219 141 220 - lv := LikeView{ 221 - URI: uri, 222 - CID: cid.String, 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 147 Author: ActorView{ 224 - DID: did, 225 - Handle: ResolveProfile(r.Context(), did).Handle, 148 + DID: l.AuthorDID, 149 + Handle: profiles[l.AuthorDID].Handle, 226 150 }, 227 151 Value: LikeRecord{ 228 - CreatedAt: createdAt, 229 - FeedURL: fURL, 230 - ArticleURL: artURL, 152 + CreatedAt: formatNullTime(l.CreatedAt), 153 + FeedURL: l.FeedURL, 154 + ArticleURL: l.ArticleURL, 231 155 }, 232 - IndexedAt: createdAt, 156 + IndexedAt: formatNullTime(l.CreatedAt), 233 157 } 234 - likes = append(likes, lv) 235 158 } 236 159 237 - resp := ListLikesResponse{Likes: likes} 238 - if len(likes) > limit { 239 - resp.Cursor = strconv.Itoa(limit) 240 - resp.Likes = likes[:limit] 241 - } 242 - 243 - writeJSON(w, resp) 160 + writeJSON(w, ListLikesResponse{ 161 + Cursor: nextCursor, 162 + Likes: views, 163 + }) 244 164 } 245 165 246 166 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")) 249 169 since := r.URL.Query().Get("since") 250 170 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) 271 172 if err != nil { 272 173 http.Error(w, err.Error(), http.StatusInternalServerError) 273 174 return 274 175 } 275 - defer rows.Close() 276 176 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 - } 177 + var nextCursor string 178 + if len(items) > limit { 179 + nextCursor = strconv.Itoa(offset + limit) 180 + items = items[:limit] 181 + } 286 182 287 - ta := TrendingArticle{ 288 - FeedURL: feedURL, 289 - ArticleURL: articleURL, 290 - Title: title.String, 291 - LikeCount: likeCount, 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, 292 190 } 293 - articles = append(articles, ta) 294 191 } 295 192 296 - resp := GetTrendingResponse{Articles: articles} 297 - if len(articles) > limit { 298 - resp.Cursor = strconv.Itoa(limit) 299 - resp.Articles = articles[:limit] 300 - } 301 - 302 - writeJSON(w, resp) 193 + writeJSON(w, GetTrendingResponse{ 194 + Cursor: nextCursor, 195 + Articles: articles, 196 + }) 303 197 } 304 198 305 199 func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { 306 200 repo := r.URL.Query().Get("repo") 307 - limit := min(parseIntParam(r, "limit", 20), 50) 308 - 201 + limit := parseIntParam(r, "limit", 20, 50) 309 202 ctx := r.Context() 310 203 311 204 feedRecs, err := h.engine.GetFeedRecommendations(ctx, repo, limit) ··· 332 225 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 234 people := make([]RecommendedPerson, 0, len(peopleRecs)) 336 235 for _, rec := range peopleRecs { 236 + p := profiles[rec.DID] 337 237 people = append(people, RecommendedPerson{ 338 238 DID: rec.DID, 339 - Handle: ResolveProfile(r.Context(), rec.DID).Handle, 239 + Handle: p.Handle, 340 240 DisplayName: rec.DisplayName, 341 241 Avatar: rec.AvatarURL, 342 242 Jaccard: rec.Jaccard, ··· 349 249 350 250 func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { 351 251 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")) 354 254 355 255 var dids []string 356 256 if actorsParam != "" { ··· 362 262 return 363 263 } 364 264 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 265 + const maxActors = 50 266 + if len(dids) > maxActors { 267 + dids = dids[:maxActors] 370 268 } 371 269 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 - } 382 - 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) 387 271 if err != nil { 388 272 http.Error(w, err.Error(), http.StatusInternalServerError) 389 273 return 390 274 } 391 - defer rows.Close() 392 275 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] 397 280 } 398 - var users []userRow 399 281 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 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, 290 + } 417 291 } 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 292 + entries[i] = FeedListEntry{ 293 + DID: l.DID, 294 + SubscriptionCount: l.SubscriptionCount, 295 + Subscriptions: subs, 428 296 } 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 - } 437 - subsByDID[did] = append(subsByDID[did], SubscriptionRecord{ 438 - FeedURL: feedURL, 439 - Title: title, 440 - Category: cat.String, 441 - }) 442 - } 443 - _ = subRows.Close() 444 297 } 445 298 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] 458 - } 459 - 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 306 v := r.URL.Query().Get(key) 465 307 if v == "" { 466 308 return defaultVal 467 309 } 468 310 n, err := strconv.Atoi(v) 469 - if err != nil { 311 + if err != nil || n < 1 { 470 312 return defaultVal 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 471 327 } 472 328 return n 473 329 } ··· 476 332 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 342 func writeJSON(w http.ResponseWriter, v any) { 343 + var buf bytes.Buffer 480 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 346 http.Error(w, err.Error(), http.StatusInternalServerError) 347 + return 483 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) 380 + } 381 + return profiles 484 382 }
+90
internal/db/feed.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "errors" 7 + "strings" 7 8 "time" 8 9 9 10 "pkg.rbrt.fr/glean/internal/feed" ··· 498 499 } 499 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 + }
+1 -1
internal/server/server.go
··· 211 211 s.router.Post("/auth/logout", s.handleAuthLogout) 212 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 215 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions) 216 216 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations) 217 217 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)