···11+# If you prefer the allow list template instead of the deny list, see community template:
22+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
33+#
44+# Binaries for programs and plugins
55+*.exe
66+*.exe~
77+*.dll
88+*.so
99+*.dylib
1010+1111+.vscode/
1212+.env
1313+1414+# Feed Generator Binary
1515+feedgen
1616+1717+# Test binary, built with `go test -c`
1818+*.test
1919+2020+# Output of the go coverage tool, specifically when used with LiteIDE
2121+*.out
2222+2323+# Dependency directories (remove the comment below to include it)
2424+# vendor/
2525+2626+# Go workspace file
2727+go.work
+27
Dockerfile
···11+FROM golang:1.22 as builder
22+33+WORKDIR /app
44+55+COPY go.mod go.sum ./
66+77+RUN go mod download
88+99+COPY pkg/ pkg/
1010+1111+COPY cmd/ cmd/
1212+1313+COPY Makefile Makefile
1414+1515+RUN make build
1616+1717+FROM alpine:latest as certs
1818+1919+RUN apk --update add ca-certificates
2020+2121+FROM debian:stable-slim
2222+2323+COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
2424+2525+COPY --from=builder /app/feedgen .
2626+2727+CMD ["./feedgen"]
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2023 Jaz
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+12
Makefile
···11+# Variables
22+ENV_FILE = .env
33+GO_CMD = CGO_ENABLED=1 GOOS=linux go
44+55+# Build the Feedgen Go binary
66+build:
77+ @echo "Building Feed Generator Go binary..."
88+ $(GO_CMD) build -o feedgen cmd/*.go
99+1010+up:
1111+ @echo "Starting Go Feed Generator..."
1212+ docker compose -f docker-compose.yml up --build -d
+64
README.md
···11+# go-bsky-feed-generator
22+A minimal implementation of a BlueSky Feed Generator in Go
33+44+55+## Requirements
66+77+To run this feed generator, all you need is `docker` with `docker-compose`.
88+99+## Running
1010+1111+Start up the feed generator by running: `make up`
1212+1313+This will build the feed generator service binary inside a docker container and stand up the service on your machine at port `9032`.
1414+1515+To view a sample static feed (with only one post) go to:
1616+1717+- [`http://localhost:9032/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:replace-me-with-your-did/app.bsky.feed.generator/static`](http://localhost:9032/xrpc/app.bsky.feed.getFeedSkeleton?feed=at://did:plc:replace-me-with-your-did/app.bsky.feed.generator/static)
1818+1919+Update the variables in `.env` when you actually want to deploy the service somewhere, at which point `did:plc:replace-me-with-your-did` should be replaced with the value of `FEED_ACTOR_DID`.
2020+2121+## Accessing
2222+2323+This service exposes the following routes:
2424+2525+- `/.well-known/did.json`
2626+ - This route is used by ATProto to verify ownership of the DID the service is claiming, it's a static JSON document.
2727+ - You can see how this is generated in `pkg/gin/endpoints.go:GetWellKnownDID()`
2828+- `/xrpc/app.bsky.feed.getFeedSkeleton`
2929+ - This route is what clients call to generate a feed page, it includes three query parameters for feed generation: `feed`, `cursor`, and `limit`
3030+ - You can see how those are parsed and handled in `pkg/gin/endpoints.go:GetFeedSkeleton()`
3131+- `/xrpc/app.bsky.feed.describeFeedGenerator`
3232+ - This route is how the service advertises which feeds it supports to clients.
3333+ - You can see how those are parsed and handled in `pkg/gin/endpoints.go:DescribeFeeds()`
3434+3535+## Publishing
3636+3737+Once you've got your feed generator up and running and have it exposed to the internet, you can publish the feed using the script from the official BSky repo [here](https://github.com/bluesky-social/feed-generator/blob/main/scripts/publishFeedGen.ts).
3838+3939+Your feed will be published under _your_ DID and should show up in your profile under the `feeds` tab.
4040+4141+## Architecture
4242+4343+This repo is structured to abstract away a `Feed` interface that allows for you to add all sorts of feeds to the router.
4444+4545+These feeds can be simple static feeds like the `pkg/feeds/static/feed.go` implementation, or they can be much more complex feeds that draw on different data sources and filter them in cool ways to produce pages of feed items.
4646+4747+The `Feed` interface is defined by any struct implementing two functions:
4848+4949+``` go
5050+type Feed interface {
5151+ GetPage(ctx context.Context, feed string, userDID string, limit int64, cursor string) (feedPosts []*appbsky.FeedDefs_SkeletonFeedPost, newCursor *string, err error)
5252+ Describe(ctx context.Context) ([]appbsky.FeedDescribeFeedGenerator_Feed, error)
5353+}
5454+```
5555+5656+`GetPage` gets a page of a feed for a given user with the limit and cursor provided, this is the main function that serves posts to a user.
5757+5858+`Describe` is used by the router to advertise what feeds are available, for foward compatibility, `Feed`s should be self describing in case this endpoint allows more details about feeds to be provided.
5959+6060+You can configure external resources and requirements in your Feed implementation before `Adding` the feed to the `FeedRouter` with `feedRouter.AddFeed([]string{"{feed_name}"}, feedInstance)`
6161+6262+This `Feed` interface is somewhat flexible right now but it could be better. I'm not sure if it will change in the future so keep that in mind when using this template.
6363+6464+- This has since been updated to allow a Feed to take in a feed name when generating a page and register multiple aliases for feeds that are supported.
+178
cmd/main.go
···11+package main
22+33+import (
44+ "context"
55+ "fmt"
66+ "log"
77+ "net/http"
88+ "net/url"
99+ "os"
1010+ "time"
1111+1212+ auth "github.com/ericvolp12/go-bsky-feed-generator/pkg/auth"
1313+ "github.com/ericvolp12/go-bsky-feed-generator/pkg/feedrouter"
1414+ ginendpoints "github.com/ericvolp12/go-bsky-feed-generator/pkg/gin"
1515+1616+ staticfeed "github.com/ericvolp12/go-bsky-feed-generator/pkg/feeds/static"
1717+ ginprometheus "github.com/ericvolp12/go-gin-prometheus"
1818+ "github.com/gin-gonic/gin"
1919+ "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
2020+ "go.opentelemetry.io/otel"
2121+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace"
2222+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
2323+ "go.opentelemetry.io/otel/sdk/resource"
2424+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
2525+ semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
2626+)
2727+2828+func main() {
2929+ ctx := context.Background()
3030+3131+ // Configure feed generator from environment variables
3232+3333+ // Registers a tracer Provider globally if the exporter endpoint is set
3434+ if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" {
3535+ log.Println("initializing tracer...")
3636+ shutdown, err := installExportPipeline(ctx)
3737+ if err != nil {
3838+ log.Fatal(err)
3939+ }
4040+ defer func() {
4141+ if err := shutdown(ctx); err != nil {
4242+ log.Fatal(err)
4343+ }
4444+ }()
4545+ }
4646+4747+ feedActorDID := os.Getenv("FEED_ACTOR_DID")
4848+ if feedActorDID == "" {
4949+ log.Fatal("FEED_ACTOR_DID environment variable must be set")
5050+ }
5151+5252+ // serviceEndpoint is a URL that the feed generator will be available at
5353+ serviceEndpoint := os.Getenv("SERVICE_ENDPOINT")
5454+ if serviceEndpoint == "" {
5555+ log.Fatal("SERVICE_ENDPOINT environment variable must be set")
5656+ }
5757+5858+ // Set the acceptable DIDs for the feed generator to respond to
5959+ // We'll default to the feedActorDID and the Service Endpoint as a did:web
6060+ serviceURL, err := url.Parse(serviceEndpoint)
6161+ if err != nil {
6262+ log.Fatal(fmt.Errorf("error parsing service endpoint: %w", err))
6363+ }
6464+6565+ serviceWebDID := "did:web:" + serviceURL.Hostname()
6666+6767+ log.Printf("service DID Web: %s", serviceWebDID)
6868+6969+ acceptableDIDs := []string{feedActorDID, serviceWebDID}
7070+7171+ // Create a new feed router instance
7272+ feedRouter, err := feedrouter.NewFeedRouter(ctx, feedActorDID, serviceWebDID, acceptableDIDs, serviceEndpoint)
7373+ if err != nil {
7474+ log.Fatal(fmt.Errorf("error creating feed router: %w", err))
7575+ }
7676+7777+ // Here we can add feeds to the Feed Router instance
7878+ // Feeds conform to the Feed interface, which is defined in
7979+ // pkg/feedrouter/feedrouter.go
8080+8181+ // For demonstration purposes, we'll use a static feed generator
8282+ // that will always return the same feed skeleton (one post)
8383+ staticFeed, staticFeedAliases, err := staticfeed.NewStaticFeed(
8484+ ctx,
8585+ feedActorDID,
8686+ "static",
8787+ // This static post is the conversation that sparked this demo repo
8888+ []string{"at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.post/3jx7msc4ive26"},
8989+ )
9090+9191+ // Add the static feed to the feed generator
9292+ feedRouter.AddFeed(staticFeedAliases, staticFeed)
9393+9494+ // Create a gin router with default middleware for logging and recovery
9595+ router := gin.Default()
9696+9797+ // Plug in OTEL Middleware and skip metrics endpoint
9898+ router.Use(
9999+ otelgin.Middleware(
100100+ "go-bsky-feed-generator",
101101+ otelgin.WithFilter(func(req *http.Request) bool {
102102+ return req.URL.Path != "/metrics"
103103+ }),
104104+ ),
105105+ )
106106+107107+ // Add Prometheus metrics middleware
108108+ p := ginprometheus.NewPrometheus("gin", nil)
109109+ p.Use(router)
110110+111111+ // Add unauthenticated routes for feed generator
112112+ ep := ginendpoints.NewEndpoints(feedRouter)
113113+ router.GET("/.well-known/did.json", ep.GetWellKnownDID)
114114+ router.GET("/xrpc/app.bsky.feed.describeFeedGenerator", ep.DescribeFeeds)
115115+116116+ // Plug in Authentication Middleware
117117+ auther, err := auth.NewAuth(
118118+ 100_000,
119119+ time.Hour*12,
120120+ 5,
121121+ serviceWebDID,
122122+ )
123123+ if err != nil {
124124+ log.Fatalf("Failed to create Auth: %v", err)
125125+ }
126126+127127+ router.Use(auther.AuthenticateGinRequestViaJWT)
128128+129129+ // Add authenticated routes for feed generator
130130+ router.GET("/xrpc/app.bsky.feed.getFeedSkeleton", ep.GetFeedSkeleton)
131131+132132+ port := os.Getenv("PORT")
133133+ if port == "" {
134134+ port = "8080"
135135+ }
136136+137137+ log.Printf("Starting server on port %s", port)
138138+ router.Run(fmt.Sprintf(":%s", port))
139139+}
140140+141141+// installExportPipeline registers a trace provider instance as a global trace provider,
142142+func installExportPipeline(ctx context.Context) (func(context.Context) error, error) {
143143+ client := otlptracehttp.NewClient()
144144+ exporter, err := otlptrace.New(ctx, client)
145145+ if err != nil {
146146+ return nil, fmt.Errorf("creating OTLP trace exporter: %w", err)
147147+ }
148148+149149+ tracerProvider := newTraceProvider(exporter)
150150+ otel.SetTracerProvider(tracerProvider)
151151+152152+ return tracerProvider.Shutdown, nil
153153+}
154154+155155+// newTraceProvider creates a new trace provider instance.
156156+func newTraceProvider(exp sdktrace.SpanExporter) *sdktrace.TracerProvider {
157157+ // Ensure default SDK resources and the required service name are set.
158158+ r, err := resource.Merge(
159159+ resource.Default(),
160160+ resource.NewWithAttributes(
161161+ semconv.SchemaURL,
162162+ semconv.ServiceName("go-bsky-feed-generator"),
163163+ ),
164164+ )
165165+166166+ if err != nil {
167167+ panic(err)
168168+ }
169169+170170+ // initialize the traceIDRatioBasedSampler to sample all traces
171171+ traceIDRatioBasedSampler := sdktrace.TraceIDRatioBased(1)
172172+173173+ return sdktrace.NewTracerProvider(
174174+ sdktrace.WithSampler(traceIDRatioBasedSampler),
175175+ sdktrace.WithBatcher(exp),
176176+ sdktrace.WithResource(r),
177177+ )
178178+}
···11+package auth
22+33+import (
44+ "context"
55+ "fmt"
66+ "net/http"
77+ "strings"
88+ "time"
99+1010+ "github.com/bluesky-social/indigo/atproto/identity"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1212+ es256k "github.com/ericvolp12/jwt-go-secp256k1"
1313+ "github.com/gin-gonic/gin"
1414+ "github.com/golang-jwt/jwt"
1515+ lru "github.com/hashicorp/golang-lru/arc/v2"
1616+ "github.com/prometheus/client_golang/prometheus"
1717+ "github.com/prometheus/client_golang/prometheus/promauto"
1818+ "gitlab.com/yawning/secp256k1-voi/secec"
1919+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
2020+ "go.opentelemetry.io/otel"
2121+ "go.opentelemetry.io/otel/attribute"
2222+ "golang.org/x/time/rate"
2323+)
2424+2525+type KeyCacheEntry struct {
2626+ UserDID string
2727+ Key any
2828+ ExpiresAt time.Time
2929+}
3030+3131+// Initialize Prometheus Metrics for cache hits and misses
3232+var cacheHits = promauto.NewCounterVec(prometheus.CounterOpts{
3333+ Name: "feedgen_auth_cache_hits_total",
3434+ Help: "The total number of cache hits",
3535+}, []string{"cache_type"})
3636+3737+var cacheMisses = promauto.NewCounterVec(prometheus.CounterOpts{
3838+ Name: "feedgen_auth_cache_misses_total",
3939+ Help: "The total number of cache misses",
4040+}, []string{"cache_type"})
4141+4242+var cacheSize = promauto.NewGaugeVec(prometheus.GaugeOpts{
4343+ Name: "feedgen_auth_cache_size_bytes",
4444+ Help: "The size of the cache in bytes",
4545+}, []string{"cache_type"})
4646+4747+type Auth struct {
4848+ KeyCache *lru.ARCCache[string, KeyCacheEntry]
4949+ KeyCacheTTL time.Duration
5050+ ServiceDID string
5151+ Dir *identity.CacheDirectory
5252+}
5353+5454+// NewAuth creates a new Auth instance with the given key cache size and TTL
5555+// The PLC Directory URL is also required, as well as the DID of the service
5656+// for JWT audience validation
5757+// The key cache is used to cache the public keys of users for a given TTL
5858+// The PLC Directory URL is used to fetch the public keys of users
5959+// The service DID is used to validate the audience of JWTs
6060+// The HTTP client is used to make requests to the PLC Directory
6161+// A rate limiter is used to limit the number of requests to the PLC Directory
6262+func NewAuth(
6363+ keyCacheSize int,
6464+ keyCacheTTL time.Duration,
6565+ requestsPerSecond int,
6666+ serviceDID string,
6767+) (*Auth, error) {
6868+ keyCache, err := lru.NewARC[string, KeyCacheEntry](keyCacheSize)
6969+ if err != nil {
7070+ return nil, fmt.Errorf("Failed to create key cache: %v", err)
7171+ }
7272+7373+ // Initialize the HTTP client with OpenTelemetry instrumentation
7474+ client := http.Client{
7575+ Transport: otelhttp.NewTransport(http.DefaultTransport),
7676+ }
7777+7878+ baseDir := identity.BaseDirectory{
7979+ PLCURL: identity.DefaultPLCURL,
8080+ PLCLimiter: rate.NewLimiter(rate.Limit(float64(requestsPerSecond)), 1),
8181+ HTTPClient: client,
8282+ TryAuthoritativeDNS: true,
8383+ // primary Bluesky PDS instance only supports HTTP resolution method
8484+ SkipDNSDomainSuffixes: []string{".bsky.social"},
8585+ }
8686+ dir := identity.NewCacheDirectory(&baseDir, keyCacheSize, keyCacheTTL, time.Minute*2, keyCacheTTL)
8787+8888+ return &Auth{
8989+ KeyCache: keyCache,
9090+ KeyCacheTTL: keyCacheTTL,
9191+ ServiceDID: serviceDID,
9292+ Dir: &dir,
9393+ }, nil
9494+}
9595+9696+func (auth *Auth) GetClaimsFromAuthHeader(ctx context.Context, authHeader string, claims jwt.Claims) error {
9797+ tracer := otel.Tracer("auth")
9898+ ctx, span := tracer.Start(ctx, "Auth:GetClaimsFromAuthHeader")
9999+ defer span.End()
100100+101101+ if authHeader == "" {
102102+ span.End()
103103+ return fmt.Errorf("No Authorization header provided")
104104+ }
105105+106106+ authHeaderParts := strings.Split(authHeader, " ")
107107+ if len(authHeaderParts) != 2 {
108108+ return fmt.Errorf("Invalid Authorization header")
109109+ }
110110+111111+ if authHeaderParts[0] != "Bearer" {
112112+ return fmt.Errorf("Invalid Authorization header (expected Bearer)")
113113+ }
114114+115115+ accessToken := authHeaderParts[1]
116116+117117+ parser := jwt.Parser{
118118+ ValidMethods: []string{es256k.SigningMethodES256K.Alg()},
119119+ }
120120+121121+ token, err := parser.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (interface{}, error) {
122122+ if claims, ok := token.Claims.(*jwt.StandardClaims); ok {
123123+ // Get the user's key from PLC Directory
124124+ userDID := claims.Issuer
125125+ entry, ok := auth.KeyCache.Get(userDID)
126126+ if ok && entry.ExpiresAt.After(time.Now()) {
127127+ cacheHits.WithLabelValues("key").Inc()
128128+ span.SetAttributes(attribute.Bool("caches.keys.hit", true))
129129+ return entry.Key, nil
130130+ }
131131+132132+ cacheMisses.WithLabelValues("key").Inc()
133133+ span.SetAttributes(attribute.Bool("caches.keys.hit", false))
134134+135135+ did, err := syntax.ParseDID(userDID)
136136+ if err != nil {
137137+ return nil, fmt.Errorf("Failed to parse user DID: %v", err)
138138+ }
139139+140140+ // Get the user's key from PLC Directory
141141+ id, err := auth.Dir.LookupDID(ctx, did)
142142+ if err != nil {
143143+ return nil, fmt.Errorf("Failed to lookup user DID: %v", err)
144144+ }
145145+146146+ key, err := id.GetPublicKey("atproto")
147147+ if err != nil {
148148+ return nil, fmt.Errorf("Failed to get user public key: %v", err)
149149+ }
150150+151151+ parsedPubkey, err := secec.NewPublicKey(key.UncompressedBytes())
152152+ if err != nil {
153153+ return nil, fmt.Errorf("Failed to parse user public key: %v", err)
154154+ }
155155+156156+ // Add the ECDSA key to the cache
157157+ auth.KeyCache.Add(userDID, KeyCacheEntry{
158158+ Key: parsedPubkey,
159159+ ExpiresAt: time.Now().Add(auth.KeyCacheTTL),
160160+ })
161161+162162+ return parsedPubkey, nil
163163+ }
164164+165165+ return nil, fmt.Errorf("Invalid authorization token (failed to parse claims)")
166166+ })
167167+168168+ if err != nil {
169169+ return fmt.Errorf("Failed to parse authorization token: %v", err)
170170+ }
171171+172172+ if !token.Valid {
173173+ return fmt.Errorf("Invalid authorization token")
174174+ }
175175+176176+ return nil
177177+}
178178+179179+func (auth *Auth) AuthenticateGinRequestViaJWT(c *gin.Context) {
180180+ tracer := otel.Tracer("auth")
181181+ ctx, span := tracer.Start(c.Request.Context(), "Auth:AuthenticateGinRequestViaJWT")
182182+183183+ authHeader := c.GetHeader("Authorization")
184184+ if authHeader == "" {
185185+ span.End()
186186+ c.Next()
187187+ return
188188+ }
189189+190190+ claims := jwt.StandardClaims{}
191191+192192+ err := auth.GetClaimsFromAuthHeader(ctx, authHeader, &claims)
193193+ if err != nil {
194194+ c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Failed to get claims from auth header: %v", err).Error()})
195195+ span.End()
196196+ c.Abort()
197197+ return
198198+ }
199199+200200+ if claims.Audience != auth.ServiceDID {
201201+ c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("Invalid audience (expected %s)", auth.ServiceDID)})
202202+ c.Abort()
203203+ return
204204+ }
205205+206206+ // Set claims Issuer to context as user DID
207207+ c.Set("user_did", claims.Issuer)
208208+ span.SetAttributes(attribute.String("user.did", claims.Issuer))
209209+ span.End()
210210+ c.Next()
211211+}
+101
pkg/feedrouter/feedrouter.go
···11+// Package feedrouter describes the FeedRouter type, which is responsible for generating feeds for a given DID.
22+// It also describes the Feed interface, which is implemented by the various feed types.
33+package feedrouter
44+55+import (
66+ "context"
77+ "fmt"
88+99+ appbsky "github.com/bluesky-social/indigo/api/bsky"
1010+ did "github.com/whyrusleeping/go-did"
1111+)
1212+1313+type Feed interface {
1414+ GetPage(ctx context.Context, feed string, userDID string, limit int64, cursor string) (feedPosts []*appbsky.FeedDefs_SkeletonFeedPost, newCursor *string, err error)
1515+ Describe(ctx context.Context) ([]appbsky.FeedDescribeFeedGenerator_Feed, error)
1616+}
1717+1818+type FeedRouter struct {
1919+ FeedActorDID did.DID // DID of the Repo the Feed is published under
2020+ ServiceEndpoint string // URL of the FeedRouter service
2121+ ServiceDID did.DID // DID of the FeedRouter service
2222+ DIDDocument did.Document // DID Document of the FeedRouter service
2323+ AcceptableURIPrefixes []string // URIs that the FeedRouter is allowed to generate feeds for
2424+ FeedMap map[string]Feed // map of FeedName to Feed
2525+ Feeds []Feed
2626+}
2727+2828+type NotFoundError struct {
2929+ error
3030+}
3131+3232+// NewFeedRouter returns a new FeedRouter
3333+func NewFeedRouter(
3434+ ctx context.Context,
3535+ feedActorDIDString string,
3636+ serviceDIDString string,
3737+ acceptableDIDs []string,
3838+ serviceEndpoint string,
3939+) (*FeedRouter, error) {
4040+ acceptableURIPrefixes := []string{}
4141+ for _, did := range acceptableDIDs {
4242+ acceptableURIPrefixes = append(acceptableURIPrefixes, "at://"+did+"/app.bsky.feed.generator/")
4343+ }
4444+4545+ serviceDID, err := did.ParseDID(serviceDIDString)
4646+ if err != nil {
4747+ return nil, fmt.Errorf("error parsing serviceDID: %w", err)
4848+ }
4949+5050+ feedActorDID, err := did.ParseDID(feedActorDIDString)
5151+ if err != nil {
5252+ return nil, fmt.Errorf("error parsing feedActorDID: %w", err)
5353+ }
5454+5555+ serviceID, err := did.ParseDID("#bsky_fg")
5656+ if err != nil {
5757+ panic(err)
5858+ }
5959+6060+ doc := did.Document{
6161+ Context: []string{did.CtxDIDv1},
6262+ ID: serviceDID,
6363+ Service: []did.Service{
6464+ {
6565+ ID: serviceID,
6666+ Type: "BskyFeedGenerator",
6767+ ServiceEndpoint: serviceEndpoint,
6868+ },
6969+ },
7070+ }
7171+7272+ return &FeedRouter{
7373+ FeedMap: map[string]Feed{},
7474+ FeedActorDID: feedActorDID,
7575+ ServiceDID: serviceDID,
7676+ DIDDocument: doc,
7777+ AcceptableURIPrefixes: acceptableURIPrefixes,
7878+ ServiceEndpoint: serviceEndpoint,
7979+ }, nil
8080+}
8181+8282+// AddFeed adds a feed to the FeedRouter
8383+// Feed precedence for overlapping aliases is determined by the order in which
8484+// they are added (first added is highest precedence)
8585+func (fg *FeedRouter) AddFeed(feedAliases []string, feed Feed) {
8686+ if fg.FeedMap == nil {
8787+ fg.FeedMap = map[string]Feed{}
8888+ }
8989+9090+ for _, feedAlias := range feedAliases {
9191+ // Skip the feed if we already have the alias registered so we don't add it twice
9292+ // Feed precedence is determined by the order in which they are added
9393+ if _, ok := fg.FeedMap[feedAlias]; ok {
9494+ continue
9595+ }
9696+9797+ fg.FeedMap[feedAlias] = feed
9898+ }
9999+100100+ fg.Feeds = append(fg.Feeds, feed)
101101+}
+79
pkg/feeds/static/feed.go
···11+package static
22+33+import (
44+ "context"
55+ "fmt"
66+ "strconv"
77+88+ appbsky "github.com/bluesky-social/indigo/api/bsky"
99+)
1010+1111+type StaticFeed struct {
1212+ FeedActorDID string
1313+ FeedName string
1414+ StaticPostURIs []string
1515+}
1616+1717+// NewStaticFeed returns a new StaticFeed, a list of aliases for the feed, and an error
1818+// StaticFeed is a trivial implementation of the Feed interface, so its aliases are just the input feedName
1919+func NewStaticFeed(ctx context.Context, feedActorDID string, feedName string, staticPostURIs []string) (*StaticFeed, []string, error) {
2020+ return &StaticFeed{
2121+ FeedActorDID: feedActorDID,
2222+ FeedName: feedName,
2323+ StaticPostURIs: staticPostURIs,
2424+ }, []string{feedName}, nil
2525+}
2626+2727+// GetPage returns a list of FeedDefs_SkeletonFeedPost, a new cursor, and an error
2828+// It takes a feed name, a user DID, a limit, and a cursor
2929+// The feed name can be used to produce different feeds from the same feed generator
3030+func (sf *StaticFeed) GetPage(ctx context.Context, feed string, userDID string, limit int64, cursor string) ([]*appbsky.FeedDefs_SkeletonFeedPost, *string, error) {
3131+ cursorAsInt := int64(0)
3232+ var err error
3333+3434+ if cursor != "" {
3535+ cursorAsInt, err = strconv.ParseInt(cursor, 10, 64)
3636+ if err != nil {
3737+ return nil, nil, fmt.Errorf("cursor is not an integer: %w", err)
3838+ }
3939+ }
4040+4141+ posts := []*appbsky.FeedDefs_SkeletonFeedPost{}
4242+4343+ for i, postURI := range sf.StaticPostURIs {
4444+ if int64(i) < cursorAsInt {
4545+ continue
4646+ }
4747+4848+ if int64(len(posts)) >= limit {
4949+ break
5050+ }
5151+5252+ posts = append(posts, &appbsky.FeedDefs_SkeletonFeedPost{
5353+ Post: postURI,
5454+ })
5555+ }
5656+5757+ cursorAsInt += int64(len(posts))
5858+5959+ var newCursor *string
6060+6161+ if cursorAsInt < int64(len(sf.StaticPostURIs)) {
6262+ newCursor = new(string)
6363+ *newCursor = strconv.FormatInt(cursorAsInt, 10)
6464+ }
6565+6666+ return posts, newCursor, nil
6767+}
6868+6969+// Describe returns a list of FeedDescribeFeedGenerator_Feed, and an error
7070+// StaticFeed is a trivial implementation of the Feed interface, so it returns a single FeedDescribeFeedGenerator_Feed
7171+// For a more complicated feed, this function would return a list of FeedDescribeFeedGenerator_Feed with the URIs of aliases
7272+// supported by the feed
7373+func (sf *StaticFeed) Describe(ctx context.Context) ([]appbsky.FeedDescribeFeedGenerator_Feed, error) {
7474+ return []appbsky.FeedDescribeFeedGenerator_Feed{
7575+ {
7676+ Uri: "at://" + sf.FeedActorDID + "/app.bsky.feed.generator/" + sf.FeedName,
7777+ },
7878+ }, nil
7979+}
+173
pkg/gin/endpoints.go
···11+package gin
22+33+import (
44+ "fmt"
55+ "net/http"
66+ "strconv"
77+ "strings"
88+99+ appbsky "github.com/bluesky-social/indigo/api/bsky"
1010+ "github.com/ericvolp12/go-bsky-feed-generator/pkg/feedrouter"
1111+ "github.com/gin-gonic/gin"
1212+ "github.com/whyrusleeping/go-did"
1313+ "go.opentelemetry.io/otel"
1414+ "go.opentelemetry.io/otel/attribute"
1515+)
1616+1717+type Endpoints struct {
1818+ FeedRouter *feedrouter.FeedRouter
1919+}
2020+2121+type DidResponse struct {
2222+ Context []string `json:"@context"`
2323+ ID string `json:"id"`
2424+ Service []did.Service `json:"service"`
2525+}
2626+2727+func NewEndpoints(feedRouter *feedrouter.FeedRouter) *Endpoints {
2828+ return &Endpoints{
2929+ FeedRouter: feedRouter,
3030+ }
3131+}
3232+3333+func (ep *Endpoints) GetWellKnownDID(c *gin.Context) {
3434+ tracer := otel.Tracer("feedrouter")
3535+ _, span := tracer.Start(c.Request.Context(), "GetWellKnownDID")
3636+ defer span.End()
3737+3838+ // Use a custom struct to fix missing omitempty on did.Document
3939+ didResponse := DidResponse{
4040+ Context: ep.FeedRouter.DIDDocument.Context,
4141+ ID: ep.FeedRouter.DIDDocument.ID.String(),
4242+ Service: ep.FeedRouter.DIDDocument.Service,
4343+ }
4444+4545+ c.JSON(http.StatusOK, didResponse)
4646+}
4747+4848+func (ep *Endpoints) DescribeFeeds(c *gin.Context) {
4949+ tracer := otel.Tracer("feedrouter")
5050+ ctx, span := tracer.Start(c.Request.Context(), "DescribeFeeds")
5151+ defer span.End()
5252+5353+ feedDescriptions := []*appbsky.FeedDescribeFeedGenerator_Feed{}
5454+5555+ for _, feed := range ep.FeedRouter.Feeds {
5656+ newDescriptions, err := feed.Describe(ctx)
5757+ if err != nil {
5858+ span.RecordError(err)
5959+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
6060+ return
6161+ }
6262+6363+ for _, newDescription := range newDescriptions {
6464+ description := newDescription
6565+ feedDescriptions = append(feedDescriptions, &description)
6666+ }
6767+ }
6868+6969+ span.SetAttributes(attribute.Int("feeds.length", len(feedDescriptions)))
7070+7171+ feedGeneratorDescription := appbsky.FeedDescribeFeedGenerator_Output{
7272+ Did: ep.FeedRouter.FeedActorDID.String(),
7373+ Feeds: feedDescriptions,
7474+ }
7575+7676+ c.JSON(http.StatusOK, feedGeneratorDescription)
7777+}
7878+7979+func (ep *Endpoints) GetFeedSkeleton(c *gin.Context) {
8080+ // Incoming requests should have a query parameter "feed" that looks like:
8181+ // at://did:web:feedsky.jazco.io/app.bsky.feed.generator/feed-name
8282+ // Also a query parameter "limit" that looks like: 50
8383+ // Also a query parameter "cursor" that is either the empty string
8484+ // or the cursor returned from a previous request
8585+ tracer := otel.Tracer("feed-generator")
8686+ ctx, span := tracer.Start(c.Request.Context(), "FeedGenerator:GetFeedSkeleton")
8787+ defer span.End()
8888+8989+ // Get userDID from the request context, which is set by the auth middleware
9090+ userDID := c.GetString("user_did")
9191+9292+ feedQuery := c.Query("feed")
9393+ if feedQuery == "" {
9494+ c.JSON(http.StatusBadRequest, gin.H{"error": "feed query parameter is required"})
9595+ return
9696+ }
9797+9898+ c.Set("feedQuery", feedQuery)
9999+ span.SetAttributes(attribute.String("feed.query", feedQuery))
100100+101101+ feedPrefix := ""
102102+ for _, acceptablePrefix := range ep.FeedRouter.AcceptableURIPrefixes {
103103+ if strings.HasPrefix(feedQuery, acceptablePrefix) {
104104+ feedPrefix = acceptablePrefix
105105+ break
106106+ }
107107+ }
108108+109109+ if feedPrefix == "" {
110110+ c.JSON(http.StatusBadRequest, gin.H{"error": "this feed generator does not serve feeds for the given DID"})
111111+ return
112112+ }
113113+114114+ // Get the feed name from the query
115115+ feedName := strings.TrimPrefix(feedQuery, feedPrefix)
116116+ if feedName == "" {
117117+ c.JSON(http.StatusBadRequest, gin.H{"error": "feed name is required"})
118118+ return
119119+ }
120120+121121+ span.SetAttributes(attribute.String("feed.name", feedName))
122122+ c.Set("feedName", feedName)
123123+124124+ // Get the limit from the query, default to 50, maximum of 250
125125+ limit := int64(50)
126126+ limitQuery := c.Query("limit")
127127+ span.SetAttributes(attribute.String("feed.limit.raw", limitQuery))
128128+ if limitQuery != "" {
129129+ parsedLimit, err := strconv.ParseInt(limitQuery, 10, 64)
130130+ if err != nil {
131131+ span.SetAttributes(attribute.Bool("feed.limit.failed_to_parse", true))
132132+ limit = 50
133133+ } else {
134134+ limit = parsedLimit
135135+ if limit > 250 {
136136+ span.SetAttributes(attribute.Bool("feed.limit.clamped", true))
137137+ limit = 250
138138+ }
139139+ }
140140+ }
141141+142142+ span.SetAttributes(attribute.Int64("feed.limit.parsed", limit))
143143+144144+ // Get the cursor from the query
145145+ cursor := c.Query("cursor")
146146+ c.Set("cursor", cursor)
147147+148148+ if ep.FeedRouter.FeedMap == nil {
149149+ c.JSON(http.StatusInternalServerError, gin.H{"error": "feed generator has no feeds configured"})
150150+ return
151151+ }
152152+153153+ feed, ok := ep.FeedRouter.FeedMap[feedName]
154154+ if !ok {
155155+ c.JSON(http.StatusNotFound, gin.H{"error": "feed not found"})
156156+ return
157157+ }
158158+159159+ // Get the feed items
160160+ feedItems, newCursor, err := feed.GetPage(ctx, feedName, userDID, limit, cursor)
161161+ if err != nil {
162162+ span.RecordError(err)
163163+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get feed items: %s", err.Error())})
164164+ return
165165+ }
166166+167167+ span.SetAttributes(attribute.Int("feed.items.length", len(feedItems)))
168168+169169+ c.JSON(http.StatusOK, appbsky.FeedGetFeedSkeleton_Output{
170170+ Feed: feedItems,
171171+ Cursor: newCursor,
172172+ })
173173+}