Feed generator written in Golang
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "net/http"
8 "strconv"
9 "strings"
10 "time"
11
12 combskytypes "github.com/bluesky-social/indigo/api/bsky"
13 chi "github.com/go-chi/chi/v5"
14 middleware "github.com/go-chi/chi/v5/middleware"
15)
16
17type HTTPServerSettings struct {
18 PublisherDID string
19 ServiceDID string
20 Hostname string
21}
22
23type HTTPServer struct {
24 Router chi.Router
25 DatabaseConn *ManagedDatabaseConnection
26 DoneSignal *chan interface{}
27 Settings HTTPServerSettings
28}
29
30func NewHTTPServer(dbcon *ManagedDatabaseConnection) *HTTPServer {
31 router := chi.NewRouter()
32
33 router.Use(middleware.RequestID)
34 router.Use(middleware.Logger)
35 router.Use(middleware.Recoverer)
36 router.Use(middleware.Compress(5))
37 router.Use(middleware.SetHeader("Content-Type", "application/json"))
38
39 env := GetEnvironmentVariables()
40 publisherDid := env["FEEDGEN_PUBLISHER_DID"]
41 hostname := env["FEEDGEN_HOSTNAME"]
42 serviceDid, hasSvcDid := env["FEEDGEN_SERVICE_DID"]
43 if !hasSvcDid {
44 serviceDid = fmt.Sprintf("did:web:%s", hostname)
45 }
46
47 done := make(chan interface{})
48
49 server := HTTPServer{
50 router,
51 dbcon,
52 &done,
53 HTTPServerSettings{
54 publisherDid,
55 serviceDid,
56 hostname,
57 },
58 }
59
60 return &server
61}
62
63func (httpServer HTTPServer) RegisterRoutes() {
64 httpServer.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
65 encoded, _ := json.Marshal(
66 map[string]interface{}{
67 "what": "atproto feed generator, written in from scratch in go",
68 "when": "may 2025",
69 "who": map[string]string{
70 "did": fmt.Sprintf("at://%s", httpServer.Settings.PublisherDID),
71 "github": "https://github.com/keaysma",
72 },
73 },
74 )
75
76 w.WriteHeader(http.StatusOK)
77 w.Write(encoded)
78 })
79
80 httpServer.Router.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
81 if !strings.HasSuffix(httpServer.Settings.ServiceDID, httpServer.Settings.Hostname) {
82 w.WriteHeader(http.StatusNotFound)
83 return
84 }
85
86 encoded, _ := json.Marshal(
87 map[string]interface{}{
88 "@context": []string{"https://www.w3.org/ns/did/v1"},
89 "id": httpServer.Settings.ServiceDID,
90 "service": []map[string]string{
91 map[string]string{
92 "id": "#bsky_fg",
93 "type": "BskyFeedGenerator",
94 "serviceEndpoint": fmt.Sprintf("https://%s", httpServer.Settings.Hostname),
95 },
96 },
97 },
98 )
99 w.WriteHeader(http.StatusOK)
100 w.Write(encoded)
101 return
102 })
103
104 // TBD - Does Indigo provide some kind-of wrapper for this?
105 httpServer.Router.Get("/xrpc/app.bsky.feed.getFeedSkeleton", func(w http.ResponseWriter, r *http.Request) {
106 query := r.URL.Query()
107 feedName := query.Get("feed")
108 cursor := query.Get("cursor")
109 paramLimit := query.Get("limit")
110
111 feedNameBase := fmt.Sprintf("at://%s/app.bsky.feed.generator/", httpServer.Settings.PublisherDID)
112 algo, hasFeedNameBase := strings.CutPrefix(feedName, feedNameBase)
113 if !hasFeedNameBase {
114 encoded, _ := json.Marshal(
115 map[string]string{
116 "error": "UnsupportedAlgorithm",
117 "message": "Unsupported algorithm",
118 },
119 )
120
121 w.WriteHeader(http.StatusBadRequest)
122 w.Write(encoded)
123 return
124 }
125
126 switch algo {
127 case "banana-slip":
128 case "coffee":
129 break
130 default:
131 encoded, _ := json.Marshal(
132 map[string]string{
133 "error": "UnsupportedAlgorithm",
134 "message": "Unsupported algorithm",
135 },
136 )
137
138 w.WriteHeader(http.StatusBadRequest)
139 w.Write(encoded)
140 return
141 }
142
143 limit, err := strconv.Atoi(paramLimit)
144 if err != nil || limit < 1 || limit > 100 {
145 limit = 50
146 }
147
148 indexedBefore := time.Now().UTC()
149 if cursor != "" {
150 epoch, err := strconv.ParseInt(cursor, 10, 64)
151 if err != nil {
152 encoded, _ := json.Marshal(
153 map[string]string{
154 "error": "InvalidRequest",
155 "message": "Bad cursor value",
156 },
157 )
158
159 w.WriteHeader(http.StatusBadRequest)
160 w.Write(encoded)
161 return
162 }
163 indexedBefore = time.Unix(epoch, 0)
164 }
165
166 feedData, err := httpServer.DatabaseConn.ReadPostsFromDatabase(algo, indexedBefore, limit)
167 if err != nil {
168 log.Println(fmt.Errorf("failed to read posts from database: ", err))
169 encoded, _ := json.Marshal(
170 map[string]string{
171 "error": "internal server error",
172 "message": "Failed to get feed data",
173 },
174 )
175
176 w.WriteHeader(http.StatusInternalServerError)
177 w.Write(encoded)
178 return
179 }
180
181 feed := make([]combskytypes.FeedDefs_SkeletonFeedPost, 0)
182 for _, entry := range *feedData {
183 feed = append(feed, combskytypes.FeedDefs_SkeletonFeedPost{
184 Post: entry.Uri,
185 })
186 }
187
188 lastIndexed := indexedBefore
189 if len(*feedData) > 0 {
190 lastIndexed, err = time.Parse(time.RFC3339, (*feedData)[len(*feedData)-1].IndexedAt)
191 if err != nil {
192 log.Println(fmt.Errorf("failed to read posts from database: ", err))
193 encoded, _ := json.Marshal(
194 map[string]string{
195 "error": "internal server error",
196 "message": "Failed to get feed data",
197 },
198 )
199
200 w.WriteHeader(http.StatusInternalServerError)
201 w.Write(encoded)
202 return
203 }
204 }
205
206 encoded, _ := json.Marshal(
207 map[string]interface{}{
208 "cursor": strconv.FormatInt(lastIndexed.UTC().Unix(), 10),
209 "feed": feed,
210 },
211 )
212
213 w.WriteHeader(http.StatusOK)
214 w.Write(encoded)
215 })
216}
217
218func (httpServer HTTPServer) StartListener() {
219 env := GetEnvironmentVariables()
220 host, hostFound := env["FEEDGEN_LISTENHOST"]
221 if !hostFound {
222 host = "0.0.0.0"
223 }
224
225 port, portFound := env["FEEDGEN_PORT"]
226 if !portFound {
227 port = "3123"
228 }
229
230 defer close(*httpServer.DoneSignal)
231 http.ListenAndServe(fmt.Sprintf("%s:%s", host, port), httpServer.Router)
232}