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}