A locally focused bluesky appview
1package xrpc
2
3import (
4 "context"
5 "log/slog"
6 "net/http"
7
8 "github.com/bluesky-social/indigo/atproto/identity"
9 "github.com/labstack/echo/v4"
10 "github.com/labstack/echo/v4/middleware"
11 "github.com/whyrusleeping/konbini/backend"
12 "github.com/whyrusleeping/konbini/hydration"
13 "github.com/whyrusleeping/konbini/models"
14 "github.com/whyrusleeping/konbini/xrpc/actor"
15 "github.com/whyrusleeping/konbini/xrpc/feed"
16 "github.com/whyrusleeping/konbini/xrpc/graph"
17 "github.com/whyrusleeping/konbini/xrpc/labeler"
18 "github.com/whyrusleeping/konbini/xrpc/notification"
19 "github.com/whyrusleeping/konbini/xrpc/repo"
20 "github.com/whyrusleeping/konbini/xrpc/unspecced"
21 "gorm.io/gorm"
22)
23
24// Server represents the XRPC API server
25type Server struct {
26 e *echo.Echo
27 db *gorm.DB
28 dir identity.Directory
29 backend Backend
30 hydrator *hydration.Hydrator
31}
32
33// Backend interface for data access
34type Backend interface {
35 // Add methods as needed for data access
36
37 TrackMissingRecord(identifier string, wait bool)
38 GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error)
39}
40
41// NewServer creates a new XRPC server
42func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server {
43 e := echo.New()
44 e.HidePort = true
45 e.HideBanner = true
46
47 // CORS middleware
48 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
49 AllowOrigins: []string{"*"},
50 AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
51 AllowHeaders: []string{"*"},
52 }))
53
54 // Logging middleware
55 e.Use(middleware.Logger())
56 e.Use(middleware.Recover())
57
58 s := &Server{
59 e: e,
60 db: db,
61 dir: dir,
62 backend: backend,
63 hydrator: hydration.NewHydrator(db, dir, backend),
64 }
65
66 // Register XRPC endpoints
67 s.registerEndpoints()
68
69 return s
70}
71
72// Start starts the XRPC server
73func (s *Server) Start(addr string) error {
74 slog.Info("starting XRPC server", "addr", addr)
75 return s.e.Start(addr)
76}
77
78// registerEndpoints registers all XRPC endpoints
79func (s *Server) registerEndpoints() {
80 // XRPC endpoints follow the pattern: /xrpc/<namespace>.<method>
81
82 s.e.GET("/.well-known/did.json", func(c echo.Context) error {
83 return c.File("did.json")
84 })
85
86 xrpcGroup := s.e.Group("/xrpc")
87
88 // com.atproto.identity.*
89 xrpcGroup.GET("/com.atproto.identity.resolveHandle", s.handleResolveHandle)
90
91 // com.atproto.repo.*
92 xrpcGroup.GET("/com.atproto.repo.getRecord", func(c echo.Context) error {
93 return repo.HandleGetRecord(c, s.db, s.hydrator)
94 })
95
96 // app.bsky.actor.*
97 xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error {
98 return actor.HandleGetProfile(c, s.hydrator)
99 }, s.optionalAuth)
100 xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error {
101 return actor.HandleGetProfiles(c, s.db, s.hydrator)
102 }, s.optionalAuth)
103 xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error {
104 return actor.HandleGetPreferences(c, s.db, s.hydrator)
105 }, s.requireAuth)
106 xrpcGroup.POST("/app.bsky.actor.putPreferences", func(c echo.Context) error {
107 return actor.HandlePutPreferences(c, s.db, s.hydrator)
108 }, s.requireAuth)
109 xrpcGroup.GET("/app.bsky.actor.searchActors", s.handleSearchActors)
110 xrpcGroup.GET("/app.bsky.actor.searchActorsTypeahead", s.handleSearchActorsTypeahead)
111
112 // app.bsky.feed.*
113 xrpcGroup.GET("/app.bsky.feed.getTimeline", func(c echo.Context) error {
114 return feed.HandleGetTimeline(c, s.db, s.hydrator)
115 }, s.requireAuth)
116 xrpcGroup.GET("/app.bsky.feed.getAuthorFeed", func(c echo.Context) error {
117 return feed.HandleGetAuthorFeed(c, s.db, s.hydrator)
118 })
119 xrpcGroup.GET("/app.bsky.feed.getPostThread", func(c echo.Context) error {
120 return feed.HandleGetPostThread(c, s.db, s.hydrator)
121 })
122 xrpcGroup.GET("/app.bsky.feed.getPosts", func(c echo.Context) error {
123 return feed.HandleGetPosts(c, s.hydrator)
124 })
125 xrpcGroup.GET("/app.bsky.feed.getLikes", func(c echo.Context) error {
126 return feed.HandleGetLikes(c, s.db, s.hydrator)
127 })
128 xrpcGroup.GET("/app.bsky.feed.getRepostedBy", func(c echo.Context) error {
129 return feed.HandleGetRepostedBy(c, s.db, s.hydrator)
130 })
131 xrpcGroup.GET("/app.bsky.feed.getActorLikes", func(c echo.Context) error {
132 return feed.HandleGetActorLikes(c, s.db, s.hydrator)
133 }, s.requireAuth)
134 xrpcGroup.GET("/app.bsky.feed.getFeed", func(c echo.Context) error {
135 return feed.HandleGetFeed(c, s.db, s.hydrator, s.dir)
136 }, s.optionalAuth)
137 xrpcGroup.GET("/app.bsky.feed.getFeedGenerator", func(c echo.Context) error {
138 return feed.HandleGetFeedGenerator(c, s.db, s.hydrator, s.dir)
139 })
140
141 // app.bsky.graph.*
142 xrpcGroup.GET("/app.bsky.graph.getFollows", func(c echo.Context) error {
143 return graph.HandleGetFollows(c, s.db, s.hydrator)
144 })
145 xrpcGroup.GET("/app.bsky.graph.getFollowers", func(c echo.Context) error {
146 return graph.HandleGetFollowers(c, s.db, s.hydrator)
147 })
148 xrpcGroup.GET("/app.bsky.graph.getBlocks", func(c echo.Context) error {
149 return graph.HandleGetBlocks(c, s.db, s.hydrator)
150 }, s.requireAuth)
151 xrpcGroup.GET("/app.bsky.graph.getMutes", func(c echo.Context) error {
152 return graph.HandleGetMutes(c, s.db, s.hydrator)
153 }, s.requireAuth)
154 xrpcGroup.GET("/app.bsky.graph.getRelationships", func(c echo.Context) error {
155 return graph.HandleGetRelationships(c, s.db, s.hydrator)
156 })
157 xrpcGroup.GET("/app.bsky.graph.getLists", s.handleGetLists)
158 xrpcGroup.GET("/app.bsky.graph.getList", s.handleGetList)
159
160 // app.bsky.notification.*
161 xrpcGroup.GET("/app.bsky.notification.listNotifications", func(c echo.Context) error {
162 return notification.HandleListNotifications(c, s.db, s.hydrator)
163 }, s.requireAuth)
164 xrpcGroup.GET("/app.bsky.notification.getUnreadCount", func(c echo.Context) error {
165 return notification.HandleGetUnreadCount(c, s.db, s.hydrator)
166 }, s.requireAuth)
167 xrpcGroup.POST("/app.bsky.notification.updateSeen", func(c echo.Context) error {
168 return notification.HandleUpdateSeen(c, s.db, s.hydrator)
169 }, s.requireAuth)
170
171 // app.bsky.labeler.*
172 xrpcGroup.GET("/app.bsky.labeler.getServices", func(c echo.Context) error {
173 return labeler.HandleGetServices(c)
174 })
175
176 // app.bsky.unspecced.*
177 xrpcGroup.GET("/app.bsky.unspecced.getConfig", func(c echo.Context) error {
178 return unspecced.HandleGetConfig(c)
179 })
180 xrpcGroup.GET("/app.bsky.unspecced.getTrendingTopics", func(c echo.Context) error {
181 return unspecced.HandleGetTrendingTopics(c)
182 })
183 xrpcGroup.GET("/app.bsky.unspecced.getPostThreadV2", func(c echo.Context) error {
184 return unspecced.HandleGetPostThreadV2(c, s.db, s.hydrator)
185 })
186}
187
188// XRPCError creates a properly formatted XRPC error response
189func XRPCError(c echo.Context, statusCode int, errType, message string) error {
190 return c.JSON(statusCode, map[string]interface{}{
191 "error": errType,
192 "message": message,
193 })
194}
195
196// getUserDID extracts the viewer DID from the request context
197// Returns empty string if not authenticated
198func getUserDID(c echo.Context) string {
199 did := c.Get("viewer")
200 if did == nil {
201 return ""
202 }
203 if s, ok := did.(string); ok {
204 return s
205 }
206 return ""
207}
208
209func (s *Server) handleSearchActors(c echo.Context) error {
210 return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
211}
212
213func (s *Server) handleSearchActorsTypeahead(c echo.Context) error {
214 return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
215}
216
217func (s *Server) handleGetLists(c echo.Context) error {
218 return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
219}
220
221func (s *Server) handleGetList(c echo.Context) error {
222 return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
223}