A locally focused bluesky appview
1package graph
2
3import (
4 "net/http"
5 "strconv"
6
7 "github.com/labstack/echo/v4"
8 "github.com/whyrusleeping/konbini/hydration"
9 "github.com/whyrusleeping/konbini/views"
10 "gorm.io/gorm"
11)
12
13// HandleGetFollowers implements app.bsky.graph.getFollowers
14func HandleGetFollowers(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15 actorParam := c.QueryParam("actor")
16 if actorParam == "" {
17 return c.JSON(http.StatusBadRequest, map[string]interface{}{
18 "error": "InvalidRequest",
19 "message": "actor parameter is required",
20 })
21 }
22
23 // Parse limit
24 limit := 50
25 if limitParam := c.QueryParam("limit"); limitParam != "" {
26 if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
27 limit = l
28 }
29 }
30
31 // Parse cursor (follow ID)
32 var cursor uint
33 if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
34 if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
35 cursor = uint(c)
36 }
37 }
38
39 ctx := c.Request().Context()
40
41 // Resolve actor to DID
42 did, err := hydrator.ResolveDID(ctx, actorParam)
43 if err != nil {
44 return c.JSON(http.StatusBadRequest, map[string]interface{}{
45 "error": "ActorNotFound",
46 "message": "actor not found",
47 })
48 }
49
50 // Get the subject actor info
51 subjectInfo, err := hydrator.HydrateActor(ctx, did)
52 if err != nil {
53 return c.JSON(http.StatusNotFound, map[string]interface{}{
54 "error": "ActorNotFound",
55 "message": "failed to load actor",
56 })
57 }
58
59 // Query followers
60 type followerRow struct {
61 ID uint
62 AuthorDid string
63 }
64 var rows []followerRow
65
66 query := `
67 SELECT f.id, r.did as author_did
68 FROM follows f
69 JOIN repos r ON r.id = f.author
70 WHERE f.subject = (SELECT id FROM repos WHERE did = ?)
71 `
72 if cursor > 0 {
73 query += ` AND f.id < ?`
74 }
75 query += ` ORDER BY f.id DESC LIMIT ?`
76
77 var queryArgs []interface{}
78 queryArgs = append(queryArgs, did)
79 if cursor > 0 {
80 queryArgs = append(queryArgs, cursor)
81 }
82 queryArgs = append(queryArgs, limit)
83
84 if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
85 return c.JSON(http.StatusInternalServerError, map[string]interface{}{
86 "error": "InternalError",
87 "message": "failed to query followers",
88 })
89 }
90
91 // Hydrate follower actors
92 followers := make([]interface{}, 0)
93 for _, row := range rows {
94 actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
95 if err != nil {
96 continue
97 }
98 followers = append(followers, views.ProfileView(actorInfo))
99 }
100
101 // Generate next cursor
102 var nextCursor string
103 if len(rows) > 0 {
104 nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
105 }
106
107 return c.JSON(http.StatusOK, map[string]interface{}{
108 "subject": views.ProfileView(subjectInfo),
109 "followers": followers,
110 "cursor": nextCursor,
111 })
112}