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 "tangled": "https://tangled.org/@keays.io",
73 },
74 },
75 )
76
77 w.WriteHeader(http.StatusOK)
78 w.Write(encoded)
79 })
80
81 httpServer.Router.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
82 if !strings.HasSuffix(httpServer.Settings.ServiceDID, httpServer.Settings.Hostname) {
83 w.WriteHeader(http.StatusNotFound)
84 return
85 }
86
87 encoded, _ := json.Marshal(
88 map[string]interface{}{
89 "@context": []string{"https://www.w3.org/ns/did/v1"},
90 "id": httpServer.Settings.ServiceDID,
91 "service": []map[string]string{
92 map[string]string{
93 "id": "#bsky_fg",
94 "type": "BskyFeedGenerator",
95 "serviceEndpoint": fmt.Sprintf("https://%s", httpServer.Settings.Hostname),
96 },
97 },
98 },
99 )
100 w.WriteHeader(http.StatusOK)
101 w.Write(encoded)
102 return
103 })
104
105 // TBD - Does Indigo provide some kind-of wrapper for this?
106 httpServer.Router.Get("/xrpc/app.bsky.feed.getFeedSkeleton", func(w http.ResponseWriter, r *http.Request) {
107 query := r.URL.Query()
108 feedName := query.Get("feed")
109 cursor := query.Get("cursor")
110 paramLimit := query.Get("limit")
111
112 feedNameBase := fmt.Sprintf("at://%s/app.bsky.feed.generator/", httpServer.Settings.PublisherDID)
113 algo, hasFeedNameBase := strings.CutPrefix(feedName, feedNameBase)
114 if !hasFeedNameBase {
115 encoded, _ := json.Marshal(
116 map[string]string{
117 "error": "UnsupportedAlgorithm",
118 "message": "Unsupported algorithm",
119 },
120 )
121
122 w.WriteHeader(http.StatusBadRequest)
123 w.Write(encoded)
124 return
125 }
126
127 switch algo {
128 case "banana-slip":
129 case "coffee":
130 break
131 default:
132 encoded, _ := json.Marshal(
133 map[string]string{
134 "error": "UnsupportedAlgorithm",
135 "message": "Unsupported algorithm",
136 },
137 )
138
139 w.WriteHeader(http.StatusBadRequest)
140 w.Write(encoded)
141 return
142 }
143
144 limit, err := strconv.Atoi(paramLimit)
145 if err != nil || limit < 1 || limit > 100 {
146 limit = 50
147 }
148
149 indexedBefore := time.Now().UTC()
150 if cursor != "" {
151 epoch, err := strconv.ParseInt(cursor, 10, 64)
152 if err != nil {
153 encoded, _ := json.Marshal(
154 map[string]string{
155 "error": "InvalidRequest",
156 "message": "Bad cursor value",
157 },
158 )
159
160 w.WriteHeader(http.StatusBadRequest)
161 w.Write(encoded)
162 return
163 }
164 indexedBefore = time.Unix(epoch, 0)
165 }
166
167 feedData, err := httpServer.DatabaseConn.ReadPostsFromDatabase(algo, indexedBefore, limit)
168 if err != nil {
169 log.Println(fmt.Errorf("failed to read posts from database: ", err))
170 encoded, _ := json.Marshal(
171 map[string]string{
172 "error": "internal server error",
173 "message": "Failed to get feed data",
174 },
175 )
176
177 w.WriteHeader(http.StatusInternalServerError)
178 w.Write(encoded)
179 return
180 }
181
182 feed := make([]combskytypes.FeedDefs_SkeletonFeedPost, 0)
183 for _, entry := range *feedData {
184 feed = append(feed, combskytypes.FeedDefs_SkeletonFeedPost{
185 Post: entry.Uri,
186 })
187 }
188
189 lastIndexed := indexedBefore
190 if len(*feedData) > 0 {
191 lastIndexed, err = time.Parse(time.RFC3339, (*feedData)[len(*feedData)-1].IndexedAt)
192 if err != nil {
193 log.Println(fmt.Errorf("failed to read posts from database: ", err))
194 encoded, _ := json.Marshal(
195 map[string]string{
196 "error": "internal server error",
197 "message": "Failed to get feed data",
198 },
199 )
200
201 w.WriteHeader(http.StatusInternalServerError)
202 w.Write(encoded)
203 return
204 }
205 }
206
207 encoded, _ := json.Marshal(
208 map[string]interface{}{
209 "cursor": strconv.FormatInt(lastIndexed.UTC().Unix(), 10),
210 "feed": feed,
211 },
212 )
213
214 w.WriteHeader(http.StatusOK)
215 w.Write(encoded)
216 })
217}
218
219func (httpServer HTTPServer) StartListener() {
220 env := GetEnvironmentVariables()
221 host, hostFound := env["FEEDGEN_LISTENHOST"]
222 if !hostFound {
223 host = "0.0.0.0"
224 }
225
226 port, portFound := env["FEEDGEN_PORT"]
227 if !portFound {
228 port = "3123"
229 }
230
231 defer close(*httpServer.DoneSignal)
232 http.ListenAndServe(fmt.Sprintf("%s:%s", host, port), httpServer.Router)
233}