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}