mirror of
https://github.com/aykhans/bsky-feedgen.git
synced 2025-07-17 13:24:01 +00:00
🦋
This commit is contained in:
62
pkg/api/base.go
Normal file
62
pkg/api/base.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/bsky-feedgen/pkg/api/handler"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/config"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/feed"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/logger"
|
||||
)
|
||||
|
||||
func Run(
|
||||
ctx context.Context,
|
||||
apiConfig *config.APIConfig,
|
||||
feeds []feed.Feed,
|
||||
) error {
|
||||
baseHandler, err := handler.NewBaseHandler(apiConfig.FeedgenHostname, apiConfig.ServiceDID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feedHandler := handler.NewFeedHandler(feeds, apiConfig.FeedgenPublisherDID)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /.well-known/did.json", baseHandler.GetWellKnownDIDDoc)
|
||||
mux.HandleFunc("GET /xrpc/app.bsky.feed.describeFeedGenerator", feedHandler.DescribeFeeds)
|
||||
mux.HandleFunc(
|
||||
"GET /xrpc/app.bsky.feed.getFeedSkeleton",
|
||||
feedHandler.GetFeedSkeleton,
|
||||
)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", apiConfig.APIPort),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
listenerErrChan := make(chan error)
|
||||
|
||||
logger.Log.Info(fmt.Sprintf("Starting server on port %d", apiConfig.APIPort))
|
||||
go func() {
|
||||
listenerErrChan <- httpServer.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-listenerErrChan:
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("error while serving http: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer shutdownCancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("error while shutting down http server: %v", err)
|
||||
}
|
||||
}
|
||||
logger.Log.Info(fmt.Sprintf("Server on port %d stopped", apiConfig.APIPort))
|
||||
|
||||
return nil
|
||||
}
|
49
pkg/api/handler/base.go
Normal file
49
pkg/api/handler/base.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/aykhans/bsky-feedgen/pkg/api/response"
|
||||
"github.com/whyrusleeping/go-did"
|
||||
)
|
||||
|
||||
type BaseHandler struct {
|
||||
WellKnownDIDDoc did.Document
|
||||
}
|
||||
|
||||
func NewBaseHandler(serviceEndpoint *url.URL, serviceDID *did.DID) (*BaseHandler, error) {
|
||||
serviceID, err := did.ParseDID("#bsky_fg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service ID parse error: %v", err)
|
||||
}
|
||||
|
||||
return &BaseHandler{
|
||||
WellKnownDIDDoc: did.Document{
|
||||
Context: []string{did.CtxDIDv1},
|
||||
ID: *serviceDID,
|
||||
Service: []did.Service{
|
||||
{
|
||||
ID: serviceID,
|
||||
Type: "BskyFeedGenerator",
|
||||
ServiceEndpoint: serviceEndpoint.String(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type WellKnownDidResponse struct {
|
||||
Context []string `json:"@context"`
|
||||
ID string `json:"id"`
|
||||
Service []did.Service `json:"service"`
|
||||
}
|
||||
|
||||
func (handler *BaseHandler) GetWellKnownDIDDoc(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, 200, WellKnownDidResponse{
|
||||
Context: handler.WellKnownDIDDoc.Context,
|
||||
ID: handler.WellKnownDIDDoc.ID.String(),
|
||||
Service: handler.WellKnownDIDDoc.Service,
|
||||
})
|
||||
}
|
101
pkg/api/handler/feed.go
Normal file
101
pkg/api/handler/feed.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aykhans/bsky-feedgen/pkg/api/middleware"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/api/response"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/feed"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/types"
|
||||
"github.com/aykhans/bsky-feedgen/pkg/utils"
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/whyrusleeping/go-did"
|
||||
)
|
||||
|
||||
type FeedHandler struct {
|
||||
feedsOutput []*bsky.FeedDescribeFeedGenerator_Feed
|
||||
feedsMap map[string]feed.Feed
|
||||
publisherDID *did.DID
|
||||
}
|
||||
|
||||
func NewFeedHandler(feeds []feed.Feed, publisherDID *did.DID) *FeedHandler {
|
||||
ctx := context.Background()
|
||||
|
||||
feedsMap := make(map[string]feed.Feed)
|
||||
for _, feed := range feeds {
|
||||
feedsMap[feed.GetName(ctx)] = feed
|
||||
}
|
||||
|
||||
feedsOutput := make([]*bsky.FeedDescribeFeedGenerator_Feed, len(feeds))
|
||||
for i, f := range feeds {
|
||||
feedsOutput[i] = utils.ToPtr(f.Describe(ctx))
|
||||
}
|
||||
|
||||
return &FeedHandler{
|
||||
feedsOutput: feedsOutput,
|
||||
feedsMap: feedsMap,
|
||||
publisherDID: publisherDID,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *FeedHandler) DescribeFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, 200, bsky.FeedDescribeFeedGenerator_Output{
|
||||
Did: handler.publisherDID.String(),
|
||||
Feeds: handler.feedsOutput,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *FeedHandler) GetFeedSkeleton(w http.ResponseWriter, r *http.Request) {
|
||||
userDID, _ := r.Context().Value(middleware.UserDIDKey).(string)
|
||||
|
||||
feedQuery := r.URL.Query().Get("feed")
|
||||
if feedQuery == "" {
|
||||
response.JSON(w, 400, response.M{"error": "feed query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
feedNameStartingIndex := strings.LastIndex(feedQuery, "/")
|
||||
if feedNameStartingIndex == -1 {
|
||||
response.JSON(w, 400, response.M{"error": "feed query parameter is invalid"})
|
||||
}
|
||||
|
||||
feedName := feedQuery[feedNameStartingIndex+1:]
|
||||
feed := handler.feedsMap[feedName]
|
||||
if feed == nil {
|
||||
response.JSON(w, 400, response.M{"error": "feed not found"})
|
||||
return
|
||||
}
|
||||
|
||||
limitQuery := r.URL.Query().Get("limit")
|
||||
var limit int64 = 50
|
||||
if limitQuery != "" {
|
||||
parsedLimit, err := strconv.ParseInt(limitQuery, 10, 64)
|
||||
if err == nil && parsedLimit >= 1 && parsedLimit <= 100 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
feedItems, newCursor, err := feed.GetPage(ctx, userDID, limit, cursor)
|
||||
if err != nil {
|
||||
if err == types.ErrInternal {
|
||||
response.JSON500(w)
|
||||
return
|
||||
}
|
||||
response.JSON(w, 400, response.M{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, 200, bsky.FeedGetFeedSkeleton_Output{
|
||||
Feed: feedItems,
|
||||
Cursor: newCursor,
|
||||
})
|
||||
}
|
23
pkg/api/middleware/auth.go
Normal file
23
pkg/api/middleware/auth.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const UserDIDKey ContextKey = "user_did"
|
||||
|
||||
func JWTAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No auth header, continue without authentication
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Add auth verification
|
||||
ctx := context.WithValue(r.Context(), UserDIDKey, "")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
3
pkg/api/middleware/base.go
Normal file
3
pkg/api/middleware/base.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package middleware
|
||||
|
||||
type ContextKey string
|
27
pkg/api/response/json.go
Normal file
27
pkg/api/response/json.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/aykhans/bsky-feedgen/pkg/logger"
|
||||
)
|
||||
|
||||
type M map[string]any
|
||||
|
||||
func JSON(w http.ResponseWriter, statusCode int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
logger.Log.Error("Failed to encode JSON response", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func JSON500(w http.ResponseWriter) {
|
||||
JSON(w, 500, M{"error": "Internal server error"})
|
||||
}
|
||||
|
||||
func JSON404(w http.ResponseWriter) {
|
||||
JSON(w, 404, M{"error": "Not found"})
|
||||
}
|
24
pkg/api/response/text.go
Normal file
24
pkg/api/response/text.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/aykhans/bsky-feedgen/pkg/logger"
|
||||
)
|
||||
|
||||
func Text(w http.ResponseWriter, statusCode int, content []byte) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(statusCode)
|
||||
if _, err := w.Write(content); err != nil {
|
||||
logger.Log.Error("Failed to write text response", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func Text404(w http.ResponseWriter) {
|
||||
Text(w, 404, []byte("Not found"))
|
||||
}
|
||||
|
||||
func Text500(w http.ResponseWriter) {
|
||||
Text(w, 500, []byte("Internal server error"))
|
||||
}
|
Reference in New Issue
Block a user