A locally focused bluesky appview

Add xrpc endpoints to satisfy running the main bluesky app against this

+4 -4
events.go
··· 240 240 p.InThread = thread 241 241 242 242 if p.ReplyToUsr == b.s.myrepo.ID { 243 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, NotifKindReply); err != nil { 243 + if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindReply); err != nil { 244 244 slog.Warn("failed to create notification", "uri", uri, "error", err) 245 245 } 246 246 } ··· 286 286 287 287 // Create notification if the mentioned user is the current user 288 288 if mentionedRepo.ID == b.s.myrepo.ID { 289 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, NotifKindMention); err != nil { 289 + if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil { 290 290 slog.Warn("failed to create mention notification", "uri", uri, "error", err) 291 291 } 292 292 } ··· 383 383 // Create notification if the liked post belongs to the current user 384 384 if pinfo.Author == b.s.myrepo.ID { 385 385 uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey) 386 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, NotifKindLike); err != nil { 386 + if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil { 387 387 slog.Warn("failed to create like notification", "uri", uri, "error", err) 388 388 } 389 389 } ··· 422 422 // Create notification if the reposted post belongs to the current user 423 423 if pinfo.Author == b.s.myrepo.ID { 424 424 uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey) 425 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, NotifKindRepost); err != nil { 425 + if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil { 426 426 slog.Warn("failed to create repost notification", "uri", uri, "error", err) 427 427 } 428 428 }
+2 -1
go.mod
··· 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f 7 + github.com/golang-jwt/jwt/v5 v5.2.2 7 8 github.com/gorilla/websocket v1.5.1 8 9 github.com/hashicorp/golang-lru/v2 v2.0.7 9 10 github.com/ipfs/go-cid v0.4.1 10 11 github.com/jackc/pgx/v5 v5.6.0 11 12 github.com/labstack/echo/v4 v4.11.3 13 + github.com/labstack/gommon v0.4.1 12 14 github.com/prometheus/client_golang v1.19.1 13 15 github.com/urfave/cli/v2 v2.27.7 14 16 github.com/whyrusleeping/market v0.0.0-20250711215409-cc684a207f15 ··· 61 63 github.com/jinzhu/inflection v1.0.0 // indirect 62 64 github.com/jinzhu/now v1.1.5 // indirect 63 65 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 64 - github.com/labstack/gommon v0.4.1 // indirect 65 66 github.com/lestrrat-go/blackmagic v1.0.1 // indirect 66 67 github.com/lestrrat-go/httpcc v1.0.1 // indirect 67 68 github.com/lestrrat-go/httprc v1.0.4 // indirect
+2
go.sum
··· 44 44 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 45 45 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 46 46 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 47 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 48 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 47 49 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 48 50 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 49 51 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
+9 -2
handlers.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/api/bsky" 13 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/bluesky-social/indigo/xrpc" 14 + xrpclib "github.com/bluesky-social/indigo/xrpc" 15 15 "github.com/labstack/echo/v4" 16 16 "github.com/labstack/echo/v4/middleware" 17 17 "github.com/labstack/gommon/log" ··· 23 23 e := echo.New() 24 24 e.Use(middleware.CORS()) 25 25 e.GET("/debug", s.handleGetDebugInfo) 26 + e.GET("/reldids", s.handleGetRelevantDids) 26 27 27 28 views := e.Group("/api") 28 29 views.GET("/me", s.handleGetMe) ··· 47 48 48 49 return e.JSON(200, map[string]any{ 49 50 "seq": seq, 51 + }) 52 + } 53 + 54 + func (s *Server) handleGetRelevantDids(e echo.Context) error { 55 + return e.JSON(200, map[string]any{ 56 + "dids": s.backend.relevantDids, 50 57 }) 51 58 } 52 59 ··· 862 869 } 863 870 864 871 var resp createRecordResponse 865 - if err := s.client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil { 872 + if err := s.client.Do(ctx, xrpclib.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil { 866 873 slog.Error("failed to create record", "error", err) 867 874 return e.JSON(500, map[string]any{ 868 875 "error": "failed to create record",
+85
hydration/actor.go
··· 1 + package hydration 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "log/slog" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // ActorInfo contains hydrated actor information 15 + type ActorInfo struct { 16 + DID string 17 + Handle string 18 + Profile *bsky.ActorProfile 19 + } 20 + 21 + // HydrateActor hydrates full actor information 22 + func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) { 23 + // Look up handle 24 + resp, err := h.dir.LookupDID(ctx, syntax.DID(did)) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to lookup DID: %w", err) 27 + } 28 + 29 + info := &ActorInfo{ 30 + DID: did, 31 + Handle: resp.Handle.String(), 32 + } 33 + 34 + // Load profile from database 35 + var dbProfile struct { 36 + Repo uint 37 + Raw []byte 38 + } 39 + err = h.db.Raw("SELECT repo, raw FROM profiles WHERE repo = (SELECT id FROM repos WHERE did = ?)", did). 40 + Scan(&dbProfile).Error 41 + if err != nil { 42 + slog.Error("failed to fetch user profile", "error", err) 43 + } else { 44 + if len(dbProfile.Raw) > 0 { 45 + var profile bsky.ActorProfile 46 + if err := profile.UnmarshalCBOR(bytes.NewReader(dbProfile.Raw)); err == nil { 47 + info.Profile = &profile 48 + } 49 + } else { 50 + h.addMissingActor(did) 51 + } 52 + } 53 + 54 + return info, nil 55 + } 56 + 57 + // HydrateActors hydrates multiple actors 58 + func (h *Hydrator) HydrateActors(ctx context.Context, dids []string) (map[string]*ActorInfo, error) { 59 + result := make(map[string]*ActorInfo, len(dids)) 60 + for _, did := range dids { 61 + info, err := h.HydrateActor(ctx, did) 62 + if err != nil { 63 + // Skip actors that fail to hydrate rather than failing the whole batch 64 + continue 65 + } 66 + result[did] = info 67 + } 68 + return result, nil 69 + } 70 + 71 + // ResolveDID resolves a handle or DID to a DID 72 + func (h *Hydrator) ResolveDID(ctx context.Context, actor string) (string, error) { 73 + // If it's already a DID, return it 74 + if strings.HasPrefix(actor, "did:") { 75 + return actor, nil 76 + } 77 + 78 + // Otherwise, resolve the handle 79 + resp, err := h.dir.LookupHandle(ctx, syntax.Handle(actor)) 80 + if err != nil { 81 + return "", fmt.Errorf("failed to resolve handle: %w", err) 82 + } 83 + 84 + return resp.DID.String(), nil 85 + }
+45
hydration/hydrator.go
··· 1 + package hydration 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/identity" 5 + "gorm.io/gorm" 6 + ) 7 + 8 + // Hydrator handles data hydration from the database 9 + type Hydrator struct { 10 + db *gorm.DB 11 + dir identity.Directory 12 + 13 + missingActorCallback func(string) 14 + missingPostCallback func(string) 15 + } 16 + 17 + // NewHydrator creates a new Hydrator 18 + func NewHydrator(db *gorm.DB, dir identity.Directory) *Hydrator { 19 + return &Hydrator{ 20 + db: db, 21 + dir: dir, 22 + } 23 + } 24 + 25 + func (h *Hydrator) SetMissingActorCallback(fn func(string)) { 26 + h.missingActorCallback = fn 27 + } 28 + 29 + func (h *Hydrator) addMissingActor(did string) { 30 + if h.missingActorCallback != nil { 31 + h.missingActorCallback(did) 32 + } 33 + } 34 + 35 + // HydrateCtx contains context for hydration operations 36 + type HydrateCtx struct { 37 + Viewer string 38 + } 39 + 40 + // NewHydrateCtx creates a new hydration context 41 + func NewHydrateCtx(viewer string) *HydrateCtx { 42 + return &HydrateCtx{ 43 + Viewer: viewer, 44 + } 45 + }
+152
hydration/post.go
··· 1 + package hydration 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "log/slog" 8 + 9 + "github.com/bluesky-social/indigo/api/bsky" 10 + ) 11 + 12 + // PostInfo contains hydrated post information 13 + type PostInfo struct { 14 + URI string 15 + Cid string 16 + Post *bsky.FeedPost 17 + Author string // DID 18 + ReplyTo uint 19 + ReplyToUsr uint 20 + InThread uint 21 + LikeCount int 22 + RepostCount int 23 + ReplyCount int 24 + ViewerLike string // URI of viewer's like, if any 25 + } 26 + 27 + const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu" 28 + 29 + // HydratePost hydrates a single post by URI 30 + func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) { 31 + // Query post from database 32 + var dbPost struct { 33 + ID uint 34 + Cid string 35 + Raw []byte 36 + NotFound bool 37 + ReplyTo uint 38 + ReplyToUsr uint 39 + InThread uint 40 + AuthorID uint 41 + } 42 + 43 + err := h.db.Raw(` 44 + SELECT p.id, p.cid, p.raw, p.not_found, p.reply_to, p.reply_to_usr, p.in_thread, p.author as author_id 45 + FROM posts p 46 + WHERE p.id = ( 47 + SELECT id FROM posts 48 + WHERE author = (SELECT id FROM repos WHERE did = ?) 49 + AND rkey = ? 50 + ) 51 + `, extractDIDFromURI(uri), extractRkeyFromURI(uri)).Scan(&dbPost).Error 52 + 53 + if err != nil { 54 + return nil, fmt.Errorf("failed to query post: %w", err) 55 + } 56 + 57 + if dbPost.NotFound || len(dbPost.Raw) == 0 { 58 + return nil, fmt.Errorf("post not found") 59 + } 60 + 61 + // Unmarshal post record 62 + var feedPost bsky.FeedPost 63 + if err := feedPost.UnmarshalCBOR(bytes.NewReader(dbPost.Raw)); err != nil { 64 + return nil, fmt.Errorf("failed to unmarshal post: %w", err) 65 + } 66 + 67 + // Get author DID 68 + var authorDID string 69 + h.db.Raw("SELECT did FROM repos WHERE id = ?", dbPost.AuthorID).Scan(&authorDID) 70 + 71 + // Get engagement counts 72 + var likes, reposts, replies int 73 + h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes) 74 + h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts) 75 + h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies) 76 + 77 + info := &PostInfo{ 78 + URI: uri, 79 + Cid: dbPost.Cid, 80 + Post: &feedPost, 81 + Author: authorDID, 82 + ReplyTo: dbPost.ReplyTo, 83 + ReplyToUsr: dbPost.ReplyToUsr, 84 + InThread: dbPost.InThread, 85 + LikeCount: likes, 86 + RepostCount: reposts, 87 + ReplyCount: replies, 88 + } 89 + 90 + if info.Cid == "" { 91 + slog.Error("MISSING CID", "uri", uri) 92 + info.Cid = fakeCid 93 + } 94 + 95 + // Check if viewer liked this post 96 + if viewerDID != "" { 97 + var likeRkey string 98 + h.db.Raw(` 99 + SELECT l.rkey FROM likes l 100 + WHERE l.subject = ? 101 + AND l.author = (SELECT id FROM repos WHERE did = ?) 102 + `, dbPost.ID, viewerDID).Scan(&likeRkey) 103 + if likeRkey != "" { 104 + info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey) 105 + } 106 + } 107 + 108 + return info, nil 109 + } 110 + 111 + // HydratePosts hydrates multiple posts 112 + func (h *Hydrator) HydratePosts(ctx context.Context, uris []string, viewerDID string) (map[string]*PostInfo, error) { 113 + result := make(map[string]*PostInfo, len(uris)) 114 + for _, uri := range uris { 115 + info, err := h.HydratePost(ctx, uri, viewerDID) 116 + if err != nil { 117 + // Skip posts that fail to hydrate 118 + continue 119 + } 120 + result[uri] = info 121 + } 122 + return result, nil 123 + } 124 + 125 + // Helper functions to extract DID and rkey from AT URI 126 + func extractDIDFromURI(uri string) string { 127 + // URI format: at://did:plc:xxx/collection/rkey 128 + if len(uri) < 5 || uri[:5] != "at://" { 129 + return "" 130 + } 131 + parts := []rune(uri[5:]) 132 + for i, r := range parts { 133 + if r == '/' { 134 + return string(parts[:i]) 135 + } 136 + } 137 + return string(parts) 138 + } 139 + 140 + func extractRkeyFromURI(uri string) string { 141 + // URI format: at://did:plc:xxx/collection/rkey 142 + if len(uri) < 5 || uri[:5] != "at://" { 143 + return "" 144 + } 145 + // Find last slash 146 + for i := len(uri) - 1; i >= 5; i-- { 147 + if uri[i] == '/' { 148 + return uri[i+1:] 149 + } 150 + } 151 + return "" 152 + }
+35 -10
main.go
··· 20 20 "github.com/bluesky-social/indigo/cmd/relay/stream" 21 21 "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel" 22 22 "github.com/bluesky-social/indigo/util/cliutil" 23 - "github.com/bluesky-social/indigo/xrpc" 23 + xrpclib "github.com/bluesky-social/indigo/xrpc" 24 24 "github.com/gorilla/websocket" 25 25 lru "github.com/hashicorp/golang-lru/v2" 26 + "github.com/ipfs/go-cid" 26 27 "github.com/jackc/pgx/v5/pgxpool" 27 28 "github.com/prometheus/client_golang/prometheus" 28 29 "github.com/prometheus/client_golang/prometheus/promauto" 29 30 "github.com/urfave/cli/v2" 31 + "github.com/whyrusleeping/konbini/xrpc" 30 32 "gorm.io/gorm/logger" 31 33 ) 32 34 ··· 125 127 } 126 128 mydid := resp.DID.String() 127 129 128 - cc := &xrpc.Client{ 130 + cc := &xrpclib.Client{ 129 131 Host: resp.PDSEndpoint(), 130 132 } 131 133 ··· 137 139 return err 138 140 } 139 141 140 - cc.Auth = &xrpc.AuthInfo{ 142 + cc.Auth = &xrpclib.AuthInfo{ 141 143 AccessJwt: nsess.AccessJwt, 142 144 Did: mydid, 143 145 Handle: nsess.Handle, ··· 152 154 missingProfiles: make(chan string, 1024), 153 155 missingPosts: make(chan string, 1024), 154 156 } 157 + fmt.Println("MY DID: ", s.mydid) 155 158 156 159 pgb := &PostgresBackend{ 157 160 relevantDids: make(map[string]bool), ··· 174 177 return fmt.Errorf("failed to load relevant dids set: %w", err) 175 178 } 176 179 180 + // Start custom API server (for the custom frontend) 177 181 go func() { 178 182 if err := s.runApiServer(); err != nil { 179 183 fmt.Println("failed to start api server: ", err) 180 184 } 181 185 }() 182 186 187 + // Start XRPC server (for official Bluesky app compatibility) 188 + go func() { 189 + xrpcServer := xrpc.NewServer(db, dir, pgb) 190 + if err := xrpcServer.Start(":4446"); err != nil { 191 + fmt.Println("failed to start XRPC server: ", err) 192 + } 193 + }() 194 + 195 + // Start pprof server 183 196 go func() { 184 197 http.ListenAndServe(":4445", nil) 185 198 }() ··· 203 216 204 217 dir identity.Directory 205 218 206 - client *xrpc.Client 219 + client *xrpclib.Client 207 220 mydid string 208 221 myrepo *Repo 209 222 ··· 215 228 missingPosts chan string 216 229 } 217 230 218 - func (s *Server) getXrpcClient() (*xrpc.Client, error) { 231 + func (s *Server) getXrpcClient() (*xrpclib.Client, error) { 219 232 // TODO: handle refreshing the token periodically 220 233 return s.client, nil 221 234 } ··· 335 348 NotifKindRepost = "repost" 336 349 ) 337 350 338 - func (s *Server) AddNotification(ctx context.Context, forUser, author uint, recordUri string, kind string) error { 351 + func (s *Server) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error { 339 352 return s.backend.db.Create(&Notification{ 340 - For: forUser, 341 - Author: author, 342 - Source: recordUri, 343 - Kind: kind, 353 + For: forUser, 354 + Author: author, 355 + Source: recordUri, 356 + SourceCid: recordCid.String(), 357 + Kind: kind, 344 358 }).Error 345 359 } 360 + 361 + func (s *Server) rescanRepo(ctx context.Context, did string) error { 362 + resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 363 + if err != nil { 364 + return err 365 + } 366 + 367 + _ = resp 368 + return nil 369 + 370 + }
+3 -3
missing.go
··· 10 10 "github.com/bluesky-social/indigo/api/atproto" 11 11 "github.com/bluesky-social/indigo/api/bsky" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/bluesky-social/indigo/xrpc" 13 + xrpclib "github.com/bluesky-social/indigo/xrpc" 14 14 "github.com/ipfs/go-cid" 15 15 "github.com/labstack/gommon/log" 16 16 ) ··· 41 41 return err 42 42 } 43 43 44 - c := &xrpc.Client{ 44 + c := &xrpclib.Client{ 45 45 Host: resp.PDSEndpoint(), 46 46 } 47 47 ··· 105 105 return err 106 106 } 107 107 108 - c := &xrpc.Client{ 108 + c := &xrpclib.Client{ 109 109 Host: resp.PDSEndpoint(), 110 110 } 111 111
+4 -3
models.go
··· 36 36 gorm.Model 37 37 For uint 38 38 39 - Author uint 40 - Source string 41 - Kind string 39 + Author uint 40 + Source string 41 + SourceCid string 42 + Kind string 42 43 } 43 44 44 45 type SequenceTracker struct {
+4
pgbackend.go
··· 400 400 401 401 return &r, nil 402 402 } 403 + 404 + func (b *PostgresBackend) TrackMissingActor(did string) { 405 + b.s.addMissingProfile(context.TODO(), did) 406 + }
+100
views/actor.go
··· 1 + package views 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/api/bsky" 7 + "github.com/bluesky-social/indigo/lex/util" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + ) 10 + 11 + // ProfileViewBasic builds a basic profile view (app.bsky.actor.defs#profileViewBasic) 12 + func ProfileViewBasic(actor *hydration.ActorInfo) *bsky.ActorDefs_ProfileViewBasic { 13 + view := &bsky.ActorDefs_ProfileViewBasic{ 14 + Did: actor.DID, 15 + Handle: actor.Handle, 16 + } 17 + 18 + if actor.Profile != nil { 19 + if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" { 20 + view.DisplayName = actor.Profile.DisplayName 21 + } 22 + if actor.Profile.Avatar != nil { 23 + avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar) 24 + if avatarURL != "" { 25 + view.Avatar = &avatarURL 26 + } 27 + } 28 + } 29 + 30 + return view 31 + } 32 + 33 + // ProfileView builds a profile view (app.bsky.actor.defs#profileView) 34 + func ProfileView(actor *hydration.ActorInfo) *bsky.ActorDefs_ProfileView { 35 + view := &bsky.ActorDefs_ProfileView{ 36 + Did: actor.DID, 37 + Handle: actor.Handle, 38 + } 39 + 40 + if actor.Profile != nil { 41 + if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" { 42 + view.DisplayName = actor.Profile.DisplayName 43 + } 44 + if actor.Profile.Description != nil && *actor.Profile.Description != "" { 45 + view.Description = actor.Profile.Description 46 + } 47 + if actor.Profile.Avatar != nil { 48 + avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar) 49 + if avatarURL != "" { 50 + view.Avatar = &avatarURL 51 + } 52 + } 53 + // Note: CreatedAt is typically set on the profile record itself 54 + } 55 + 56 + return view 57 + } 58 + 59 + // ProfileViewDetailed builds a detailed profile view (app.bsky.actor.defs#profileViewDetailed) 60 + func ProfileViewDetailed(actor *hydration.ActorInfo, followerCount, followsCount, postsCount int) *bsky.ActorDefs_ProfileViewDetailed { 61 + view := &bsky.ActorDefs_ProfileViewDetailed{ 62 + Did: actor.DID, 63 + Handle: actor.Handle, 64 + } 65 + 66 + if actor.Profile != nil { 67 + if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" { 68 + view.DisplayName = actor.Profile.DisplayName 69 + } 70 + if actor.Profile.Description != nil && *actor.Profile.Description != "" { 71 + view.Description = actor.Profile.Description 72 + } 73 + if actor.Profile.Avatar != nil { 74 + avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar) 75 + if avatarURL != "" { 76 + view.Avatar = &avatarURL 77 + } 78 + } 79 + if actor.Profile.Banner != nil { 80 + bannerURL := formatBlobRef(actor.DID, actor.Profile.Banner) 81 + if bannerURL != "" { 82 + view.Banner = &bannerURL 83 + } 84 + } 85 + } 86 + 87 + // Add counts 88 + fc := int64(followerCount) 89 + fsc := int64(followsCount) 90 + pc := int64(postsCount) 91 + view.FollowersCount = &fc 92 + view.FollowsCount = &fsc 93 + view.PostsCount = &pc 94 + 95 + return view 96 + } 97 + 98 + func formatBlobRef(did string, blob *util.LexBlob) string { 99 + return fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", did, blob.Ref.String()) 100 + }
+69
views/feed.go
··· 1 + package views 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/api/bsky" 5 + "github.com/bluesky-social/indigo/lex/util" 6 + "github.com/whyrusleeping/konbini/hydration" 7 + ) 8 + 9 + // PostView builds a post view (app.bsky.feed.defs#postView) 10 + func PostView(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_PostView { 11 + view := &bsky.FeedDefs_PostView{ 12 + LexiconTypeID: "app.bsky.feed.defs#postView", 13 + Uri: post.URI, 14 + Cid: post.Cid, 15 + Author: ProfileViewBasic(author), 16 + Record: &util.LexiconTypeDecoder{ 17 + Val: post.Post, 18 + }, 19 + IndexedAt: post.Post.CreatedAt, // Using createdAt as indexedAt for now 20 + } 21 + 22 + // Add engagement counts 23 + if post.LikeCount > 0 { 24 + lc := int64(post.LikeCount) 25 + view.LikeCount = &lc 26 + } 27 + if post.RepostCount > 0 { 28 + rc := int64(post.RepostCount) 29 + view.RepostCount = &rc 30 + } 31 + if post.ReplyCount > 0 { 32 + rpc := int64(post.ReplyCount) 33 + view.ReplyCount = &rpc 34 + } 35 + 36 + // Add viewer state 37 + if post.ViewerLike != "" { 38 + view.Viewer = &bsky.FeedDefs_ViewerState{ 39 + Like: &post.ViewerLike, 40 + } 41 + } 42 + 43 + // TODO: Add embed handling - need to convert embed types to proper views 44 + // if post.Post.Embed != nil { 45 + // view.Embed = formatEmbed(post.Post.Embed) 46 + // } 47 + 48 + return view 49 + } 50 + 51 + // FeedViewPost builds a feed view post (app.bsky.feed.defs#feedViewPost) 52 + func FeedViewPost(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_FeedViewPost { 53 + return &bsky.FeedDefs_FeedViewPost{ 54 + Post: PostView(post, author), 55 + } 56 + } 57 + 58 + // ThreadViewPost builds a thread view post (app.bsky.feed.defs#threadViewPost) 59 + func ThreadViewPost(post *hydration.PostInfo, author *hydration.ActorInfo, parent, replies interface{}) *bsky.FeedDefs_ThreadViewPost { 60 + view := &bsky.FeedDefs_ThreadViewPost{ 61 + LexiconTypeID: "app.bsky.feed.defs#threadViewPost", 62 + Post: PostView(post, author), 63 + } 64 + 65 + // TODO: Type parent and replies properly as union types 66 + // For now leaving them as interface{} to be handled by handlers 67 + 68 + return view 69 + }
+111
xrpc/actor/getPreferences.go
··· 1 + package actor 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/bluesky-social/indigo/api/bsky" 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "gorm.io/gorm" 10 + ) 11 + 12 + // HandleGetPreferences implements app.bsky.actor.getPreferences 13 + // This is typically a PDS endpoint, not an AppView endpoint. 14 + // For now, return empty preferences. 15 + func HandleGetPreferences(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + // Get viewer from authentication 17 + viewer := c.Get("viewer") 18 + if viewer == nil { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + 25 + out := bsky.ActorGetPreferences_Output{ 26 + Preferences: []bsky.ActorDefs_Preferences_Elem{ 27 + { 28 + ActorDefs_AdultContentPref: &bsky.ActorDefs_AdultContentPref{ 29 + Enabled: true, 30 + }, 31 + }, 32 + { 33 + ActorDefs_ContentLabelPref: &bsky.ActorDefs_ContentLabelPref{ 34 + Label: "nsfw", 35 + Visibility: "warn", 36 + }, 37 + }, 38 + /* 39 + { 40 + ActorDefs_LabelersPref: &bsky.ActorDefs_LabelersPref{ 41 + Labelers: []*bsky.ActorDefs_LabelerPrefItem{}, 42 + }, 43 + }, 44 + */ 45 + { 46 + ActorDefs_BskyAppStatePref: &bsky.ActorDefs_BskyAppStatePref{ 47 + Nuxs: []*bsky.ActorDefs_Nux{ 48 + { 49 + Id: "NeueTypography", 50 + Completed: true, 51 + }, 52 + { 53 + Id: "PolicyUpdate202508", 54 + Completed: true, 55 + }, 56 + }, 57 + }, 58 + }, 59 + { 60 + ActorDefs_SavedFeedsPrefV2: &bsky.ActorDefs_SavedFeedsPrefV2{ 61 + Items: []*bsky.ActorDefs_SavedFeed{ 62 + { 63 + Id: "3m2k6cbfsq22n", 64 + Pinned: true, 65 + Type: "timeline", 66 + Value: "following", 67 + }, 68 + }, 69 + }, 70 + }, 71 + }, 72 + } 73 + 74 + return c.JSON(http.StatusOK, out) 75 + } 76 + 77 + /* 78 + { 79 + "nuxs": [ 80 + { 81 + "id": "TenMillionDialog", 82 + "completed": true 83 + }, 84 + { 85 + "id": "NeueTypography", 86 + "completed": true 87 + }, 88 + { 89 + "id": "NeueChar", 90 + "completed": true 91 + }, 92 + { 93 + "id": "InitialVerificationAnnouncement", 94 + "completed": true 95 + }, 96 + { 97 + "id": "ActivitySubscriptions", 98 + "completed": true 99 + }, 100 + { 101 + "id": "BookmarksAnnouncement", 102 + "completed": true 103 + }, 104 + { 105 + "id": "PolicyUpdate202508", 106 + "completed": true 107 + } 108 + ], 109 + "$type": "app.bsky.actor.defs#bskyAppStatePref" 110 + } 111 + */
+54
xrpc/actor/getProfile.go
··· 1 + package actor 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "github.com/whyrusleeping/konbini/views" 9 + ) 10 + 11 + // HandleGetProfile implements app.bsky.actor.getProfile 12 + func HandleGetProfile(c echo.Context, hydrator *hydration.Hydrator) error { 13 + actorParam := c.QueryParam("actor") 14 + if actorParam == "" { 15 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 16 + "error": "InvalidRequest", 17 + "message": "actor parameter is required", 18 + }) 19 + } 20 + 21 + ctx := c.Request().Context() 22 + 23 + // Resolve actor to DID 24 + did, err := hydrator.ResolveDID(ctx, actorParam) 25 + if err != nil { 26 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 27 + "error": "ActorNotFound", 28 + "message": "actor not found", 29 + }) 30 + } 31 + 32 + // Hydrate actor info 33 + actorInfo, err := hydrator.HydrateActor(ctx, did) 34 + if err != nil { 35 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 36 + "error": "ActorNotFound", 37 + "message": "failed to load actor", 38 + }) 39 + } 40 + 41 + // Get follower/follows/posts counts 42 + // TODO: These queries should be optimized 43 + var followerCount, followsCount, postsCount int 44 + 45 + // We'll return 0 for now - can optimize later 46 + followerCount = 0 47 + followsCount = 0 48 + postsCount = 0 49 + 50 + // Build response 51 + profile := views.ProfileViewDetailed(actorInfo, followerCount, followsCount, postsCount) 52 + 53 + return c.JSON(http.StatusOK, profile) 54 + }
+67
xrpc/actor/getProfiles.go
··· 1 + package actor 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "github.com/whyrusleeping/konbini/views" 9 + "gorm.io/gorm" 10 + ) 11 + 12 + // HandleGetProfiles implements app.bsky.actor.getProfiles 13 + func HandleGetProfiles(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 14 + // Parse actors parameter (can be multiple) 15 + actors := c.QueryParams()["actors"] 16 + if len(actors) == 0 { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actors parameter is required", 20 + }) 21 + } 22 + 23 + // Limit to reasonable batch size 24 + if len(actors) > 25 { 25 + actors = actors[:25] 26 + } 27 + 28 + ctx := c.Request().Context() 29 + 30 + // Resolve all actors to DIDs and hydrate profiles 31 + profiles := make([]interface{}, 0) 32 + for _, actor := range actors { 33 + // Resolve actor to DID 34 + did, err := hydrator.ResolveDID(ctx, actor) 35 + if err != nil { 36 + // Skip actors that can't be resolved 37 + continue 38 + } 39 + 40 + // Hydrate actor info 41 + actorInfo, err := hydrator.HydrateActor(ctx, did) 42 + if err != nil { 43 + // Skip actors that can't be hydrated 44 + continue 45 + } 46 + 47 + // Get counts for the profile 48 + type counts struct { 49 + Followers int 50 + Follows int 51 + Posts int 52 + } 53 + var c counts 54 + db.Raw(` 55 + SELECT 56 + (SELECT COUNT(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)) as followers, 57 + (SELECT COUNT(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)) as follows, 58 + (SELECT COUNT(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)) as posts 59 + `, did, did, did).Scan(&c) 60 + 61 + profiles = append(profiles, views.ProfileViewDetailed(actorInfo, c.Followers, c.Follows, c.Posts)) 62 + } 63 + 64 + return c.JSON(http.StatusOK, map[string]interface{}{ 65 + "profiles": profiles, 66 + }) 67 + }
+25
xrpc/actor/putPreferences.go
··· 1 + package actor 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandlePutPreferences implements app.bsky.actor.putPreferences 12 + // Stubbed out for now - just returns success without doing anything 13 + func HandlePutPreferences(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 14 + // Get viewer from authentication 15 + viewer := c.Get("viewer") 16 + if viewer == nil { 17 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 18 + "error": "AuthenticationRequired", 19 + "message": "authentication required", 20 + }) 21 + } 22 + 23 + // For now, just return success without storing anything 24 + return c.JSON(http.StatusOK, map[string]interface{}{}) 25 + }
+106
xrpc/auth.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/labstack/echo/v4" 11 + "github.com/lestrrat-go/jwx/v2/jwt" 12 + ) 13 + 14 + // requireAuth is middleware that requires authentication 15 + func (s *Server) requireAuth(next echo.HandlerFunc) echo.HandlerFunc { 16 + return func(c echo.Context) error { 17 + viewer, err := s.authenticate(c) 18 + if err != nil { 19 + return XRPCError(c, http.StatusUnauthorized, "AuthenticationRequired", err.Error()) 20 + } 21 + c.Set("viewer", viewer) 22 + return next(c) 23 + } 24 + } 25 + 26 + // optionalAuth is middleware that optionally authenticates 27 + func (s *Server) optionalAuth(next echo.HandlerFunc) echo.HandlerFunc { 28 + return func(c echo.Context) error { 29 + viewer, _ := s.authenticate(c) 30 + if viewer != "" { 31 + c.Set("viewer", viewer) 32 + } 33 + return next(c) 34 + } 35 + } 36 + 37 + // authenticate extracts and validates the JWT from the Authorization header 38 + // Returns the viewer DID if valid, empty string otherwise 39 + func (s *Server) authenticate(c echo.Context) (string, error) { 40 + authHeader := c.Request().Header.Get("Authorization") 41 + if authHeader == "" { 42 + return "", fmt.Errorf("missing authorization header") 43 + } 44 + 45 + // Extract Bearer token 46 + parts := strings.Split(authHeader, " ") 47 + if len(parts) != 2 || parts[0] != "Bearer" { 48 + return "", fmt.Errorf("invalid authorization header format") 49 + } 50 + 51 + tokenString := parts[1] 52 + 53 + // Parse JWT without signature validation (for development) 54 + // In production, you'd want to validate the signature using the issuer's public key 55 + token, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false)) 56 + if err != nil { 57 + return "", fmt.Errorf("failed to parse token: %w", err) 58 + } 59 + 60 + // Extract the user's DID - try both "sub" (PDS tokens) and "iss" (service tokens) 61 + var userDID string 62 + 63 + // First try "sub" claim (used by PDS tokens and entryway tokens) 64 + sub := token.Subject() 65 + if sub != "" && strings.HasPrefix(sub, "did:") { 66 + userDID = sub 67 + } else { 68 + // Fall back to "iss" claim (used by some service tokens) 69 + iss := token.Issuer() 70 + if iss != "" && strings.HasPrefix(iss, "did:") { 71 + userDID = iss 72 + } 73 + } 74 + 75 + if userDID == "" { 76 + return "", fmt.Errorf("missing 'sub' or 'iss' claim with DID in token") 77 + } 78 + 79 + // Optional: check scope if present 80 + scope, ok := token.Get("scope") 81 + if ok { 82 + scopeStr, _ := scope.(string) 83 + // Valid scopes are: com.atproto.access, com.atproto.appPass, com.atproto.appPassPrivileged 84 + if scopeStr != "com.atproto.access" && scopeStr != "com.atproto.appPass" && scopeStr != "com.atproto.appPassPrivileged" { 85 + return "", fmt.Errorf("invalid token scope: %s", scopeStr) 86 + } 87 + } 88 + 89 + return userDID, nil 90 + } 91 + 92 + // resolveActor resolves an actor identifier (handle or DID) to a DID 93 + func (s *Server) resolveActor(ctx context.Context, actor string) (string, error) { 94 + // If it's already a DID, return it 95 + if strings.HasPrefix(actor, "did:") { 96 + return actor, nil 97 + } 98 + 99 + // Otherwise, resolve the handle 100 + resp, err := s.dir.LookupHandle(ctx, syntax.Handle(actor)) 101 + if err != nil { 102 + return "", fmt.Errorf("failed to resolve handle: %w", err) 103 + } 104 + 105 + return resp.DID.String(), nil 106 + }
+119
xrpc/feed/getActorLikes.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetActorLikes implements app.bsky.feed.getActorLikes 14 + func HandleGetActorLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + ctx := c.Request().Context() 24 + 25 + // Resolve actor to DID 26 + actorDID, err := hydrator.ResolveDID(ctx, actorParam) 27 + if err != nil { 28 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 29 + "error": "ActorNotFound", 30 + "message": "actor not found", 31 + }) 32 + } 33 + 34 + // Check authentication - user can only view their own likes 35 + viewer := c.Get("viewer") 36 + if viewer == nil || viewer.(string) != actorDID { 37 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 38 + "error": "AuthenticationRequired", 39 + "message": "you can only view your own likes", 40 + }) 41 + } 42 + 43 + // Parse limit 44 + limit := 50 45 + if limitParam := c.QueryParam("limit"); limitParam != "" { 46 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 47 + limit = l 48 + } 49 + } 50 + 51 + // Parse cursor (like ID) 52 + var cursor uint 53 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 54 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 55 + cursor = uint(c) 56 + } 57 + } 58 + 59 + // Query likes 60 + type likeRow struct { 61 + ID uint 62 + Subject string // post URI 63 + } 64 + var rows []likeRow 65 + 66 + query := ` 67 + SELECT l.id, 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as subject 68 + FROM likes l 69 + JOIN posts p ON p.id = l.subject 70 + JOIN repos r ON r.id = p.author 71 + WHERE l.author = (SELECT id FROM repos WHERE did = ?) 72 + ` 73 + if cursor > 0 { 74 + query += ` AND l.id < ?` 75 + } 76 + query += ` ORDER BY l.id DESC LIMIT ?` 77 + 78 + var queryArgs []interface{} 79 + queryArgs = append(queryArgs, actorDID) 80 + if cursor > 0 { 81 + queryArgs = append(queryArgs, cursor) 82 + } 83 + queryArgs = append(queryArgs, limit) 84 + 85 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 86 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 87 + "error": "InternalError", 88 + "message": "failed to query likes", 89 + }) 90 + } 91 + 92 + // Hydrate posts 93 + feed := make([]interface{}, 0) 94 + for _, row := range rows { 95 + postInfo, err := hydrator.HydratePost(ctx, row.Subject, actorDID) 96 + if err != nil { 97 + continue 98 + } 99 + 100 + // Hydrate the post author 101 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 102 + if err != nil { 103 + continue 104 + } 105 + 106 + feed = append(feed, views.FeedViewPost(postInfo, authorInfo)) 107 + } 108 + 109 + // Generate next cursor 110 + var nextCursor string 111 + if len(rows) > 0 { 112 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 113 + } 114 + 115 + return c.JSON(http.StatusOK, map[string]interface{}{ 116 + "feed": feed, 117 + "cursor": nextCursor, 118 + }) 119 + }
+137
xrpc/feed/getAuthorFeed.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + "time" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/whyrusleeping/konbini/hydration" 10 + "github.com/whyrusleeping/konbini/views" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // HandleGetAuthorFeed implements app.bsky.feed.getAuthorFeed 15 + func HandleGetAuthorFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + actorParam := c.QueryParam("actor") 17 + if actorParam == "" { 18 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 19 + "error": "InvalidRequest", 20 + "message": "actor parameter is required", 21 + }) 22 + } 23 + 24 + // Parse limit 25 + limit := 50 26 + if limitParam := c.QueryParam("limit"); limitParam != "" { 27 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + // Parse cursor (timestamp) 33 + cursor := time.Now() 34 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 35 + if t, err := time.Parse(time.RFC3339, cursorParam); err == nil { 36 + cursor = t 37 + } 38 + } 39 + 40 + // Parse filter (posts_with_replies, posts_no_replies, posts_with_media, etc.) 41 + filter := c.QueryParam("filter") 42 + if filter == "" { 43 + filter = "posts_with_replies" // default 44 + } 45 + 46 + ctx := c.Request().Context() 47 + viewer := getUserDID(c) 48 + 49 + // Resolve actor to DID 50 + did, err := hydrator.ResolveDID(ctx, actorParam) 51 + if err != nil { 52 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 53 + "error": "ActorNotFound", 54 + "message": "actor not found", 55 + }) 56 + } 57 + 58 + // Build query based on filter 59 + var query string 60 + switch filter { 61 + case "posts_no_replies", "posts_and_author_threads": 62 + query = ` 63 + SELECT 64 + 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri, 65 + p.author as author_id 66 + FROM posts p 67 + JOIN repos r ON r.id = p.author 68 + WHERE p.author = (SELECT id FROM repos WHERE did = ?) 69 + AND p.reply_to = 0 70 + AND p.created < ? 71 + AND p.not_found = false 72 + ORDER BY p.created DESC 73 + LIMIT ? 74 + ` 75 + default: // posts_with_replies 76 + query = ` 77 + SELECT 78 + 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri, 79 + p.author as author_id 80 + FROM posts p 81 + JOIN repos r ON r.id = p.author 82 + WHERE p.author = (SELECT id FROM repos WHERE did = ?) 83 + AND p.created < ? 84 + AND p.not_found = false 85 + ORDER BY p.created DESC 86 + LIMIT ? 87 + ` 88 + } 89 + 90 + type postRow struct { 91 + URI string 92 + AuthorID uint 93 + } 94 + var rows []postRow 95 + if err := db.Raw(query, did, cursor, limit).Scan(&rows).Error; err != nil { 96 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 97 + "error": "InternalError", 98 + "message": "failed to query author feed", 99 + }) 100 + } 101 + 102 + // Hydrate posts 103 + feed := make([]interface{}, 0) 104 + for _, row := range rows { 105 + postInfo, err := hydrator.HydratePost(ctx, row.URI, viewer) 106 + if err != nil { 107 + continue 108 + } 109 + 110 + // Hydrate author 111 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 112 + if err != nil { 113 + continue 114 + } 115 + 116 + feedItem := views.FeedViewPost(postInfo, authorInfo) 117 + feed = append(feed, feedItem) 118 + } 119 + 120 + // Generate next cursor 121 + var nextCursor string 122 + if len(rows) > 0 { 123 + lastURI := rows[len(rows)-1].URI 124 + postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer) 125 + if err == nil && postInfo.Post != nil { 126 + t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt) 127 + if err == nil { 128 + nextCursor = t.Format(time.RFC3339) 129 + } 130 + } 131 + } 132 + 133 + return c.JSON(http.StatusOK, map[string]interface{}{ 134 + "feed": feed, 135 + "cursor": nextCursor, 136 + }) 137 + }
+117
xrpc/feed/getLikes.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetLikes implements app.bsky.feed.getLikes 14 + func HandleGetLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + uriParam := c.QueryParam("uri") 16 + if uriParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uri parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (like ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Get post ID from URI 42 + var postID uint 43 + db.Raw(` 44 + SELECT id FROM posts 45 + WHERE author = (SELECT id FROM repos WHERE did = ?) 46 + AND rkey = ? 47 + `, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID) 48 + 49 + if postID == 0 { 50 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 51 + "error": "NotFound", 52 + "message": "post not found", 53 + }) 54 + } 55 + 56 + // Query likes 57 + type likeRow struct { 58 + ID uint 59 + AuthorDid string 60 + Rkey string 61 + Created string 62 + } 63 + var rows []likeRow 64 + 65 + query := ` 66 + SELECT l.id, r.did as author_did, l.rkey, l.created 67 + FROM likes l 68 + JOIN repos r ON r.id = l.author 69 + WHERE l.subject = ? 70 + ` 71 + if cursor > 0 { 72 + query += ` AND l.id < ?` 73 + } 74 + query += ` ORDER BY l.id DESC LIMIT ?` 75 + 76 + var queryArgs []interface{} 77 + queryArgs = append(queryArgs, postID) 78 + if cursor > 0 { 79 + queryArgs = append(queryArgs, cursor) 80 + } 81 + queryArgs = append(queryArgs, limit) 82 + 83 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 84 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 85 + "error": "InternalError", 86 + "message": "failed to query likes", 87 + }) 88 + } 89 + 90 + // Hydrate actors 91 + likes := make([]interface{}, 0) 92 + for _, row := range rows { 93 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 94 + if err != nil { 95 + continue 96 + } 97 + 98 + like := map[string]interface{}{ 99 + "actor": views.ProfileView(actorInfo), 100 + "createdAt": row.Created, 101 + "indexedAt": row.Created, 102 + } 103 + likes = append(likes, like) 104 + } 105 + 106 + // Generate next cursor 107 + var nextCursor string 108 + if len(rows) > 0 { 109 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 110 + } 111 + 112 + return c.JSON(http.StatusOK, map[string]interface{}{ 113 + "uri": uriParam, 114 + "likes": likes, 115 + "cursor": nextCursor, 116 + }) 117 + }
+187
xrpc/feed/getPostThread.go
··· 1 + package feed 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/whyrusleeping/konbini/hydration" 10 + "github.com/whyrusleeping/konbini/views" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // HandleGetPostThread implements app.bsky.feed.getPostThread 15 + func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + uriParam := c.QueryParam("uri") 17 + if uriParam == "" { 18 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 19 + "error": "InvalidRequest", 20 + "message": "uri parameter is required", 21 + }) 22 + } 23 + 24 + ctx := c.Request().Context() 25 + viewer := getUserDID(c) 26 + 27 + // Hydrate the requested post 28 + postInfo, err := hydrator.HydratePost(ctx, uriParam, viewer) 29 + if err != nil { 30 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 31 + "error": "NotFound", 32 + "message": "post not found", 33 + }) 34 + } 35 + 36 + // Determine the root post ID for the thread 37 + rootPostID := postInfo.InThread 38 + if rootPostID == 0 { 39 + // This post is the root 40 + // Query to find what the post's internal ID is 41 + var postID uint 42 + db.Raw(` 43 + SELECT id FROM posts 44 + WHERE author = (SELECT id FROM repos WHERE did = ?) 45 + AND rkey = ? 46 + `, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID) 47 + rootPostID = postID 48 + } 49 + 50 + // Query all posts in this thread 51 + type threadPost struct { 52 + ID uint 53 + Rkey string 54 + ReplyTo uint 55 + InThread uint 56 + AuthorDID string 57 + } 58 + var threadPosts []threadPost 59 + db.Raw(` 60 + SELECT p.id, p.rkey, p.reply_to, p.in_thread, r.did as author_did 61 + FROM posts p 62 + JOIN repos r ON r.id = p.author 63 + WHERE (p.id = ? OR p.in_thread = ?) 64 + AND p.not_found = false 65 + ORDER BY p.created ASC 66 + `, rootPostID, rootPostID).Scan(&threadPosts) 67 + 68 + // Build a map of posts by ID for easy lookup 69 + postsByID := make(map[uint]*threadPostNode) 70 + for _, tp := range threadPosts { 71 + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", tp.AuthorDID, tp.Rkey) 72 + postsByID[tp.ID] = &threadPostNode{ 73 + id: tp.ID, 74 + uri: uri, 75 + replyTo: tp.ReplyTo, 76 + inThread: tp.InThread, 77 + replies: []interface{}{}, 78 + } 79 + } 80 + 81 + // Build the thread tree structure 82 + for _, node := range postsByID { 83 + if node.replyTo != 0 { 84 + parent := postsByID[node.replyTo] 85 + if parent != nil { 86 + parent.replies = append(parent.replies, node) 87 + } 88 + } 89 + } 90 + 91 + // Find the root node 92 + var rootNode *threadPostNode 93 + for _, node := range postsByID { 94 + if node.inThread == 0 || node.id == rootPostID { 95 + rootNode = node 96 + break 97 + } 98 + } 99 + 100 + if rootNode == nil { 101 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 102 + "error": "NotFound", 103 + "message": "thread root not found", 104 + }) 105 + } 106 + 107 + // Build the response by traversing the tree 108 + thread := buildThreadView(ctx, db, rootNode, postsByID, hydrator, viewer, nil) 109 + 110 + return c.JSON(http.StatusOK, map[string]interface{}{ 111 + "thread": thread, 112 + }) 113 + } 114 + 115 + type threadPostNode struct { 116 + id uint 117 + uri string 118 + replyTo uint 119 + inThread uint 120 + replies []interface{} 121 + } 122 + 123 + func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent interface{}) interface{} { 124 + // Hydrate this post 125 + postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer) 126 + if err != nil { 127 + // Return a notFound post 128 + return map[string]interface{}{ 129 + "$type": "app.bsky.feed.defs#notFoundPost", 130 + "uri": node.uri, 131 + } 132 + } 133 + 134 + // Hydrate author 135 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 136 + if err != nil { 137 + return map[string]interface{}{ 138 + "$type": "app.bsky.feed.defs#notFoundPost", 139 + "uri": node.uri, 140 + } 141 + } 142 + 143 + // Build replies 144 + var replies []interface{} 145 + for _, replyNode := range node.replies { 146 + if rn, ok := replyNode.(*threadPostNode); ok { 147 + replyView := buildThreadView(ctx, db, rn, allNodes, hydrator, viewer, nil) 148 + replies = append(replies, replyView) 149 + } 150 + } 151 + 152 + // Build the thread view post 153 + var repliesForView interface{} 154 + if len(replies) > 0 { 155 + repliesForView = replies 156 + } 157 + 158 + return views.ThreadViewPost(postInfo, authorInfo, parent, repliesForView) 159 + } 160 + 161 + func extractDIDFromURI(uri string) string { 162 + // URI format: at://did:plc:xxx/collection/rkey 163 + if len(uri) < 5 || uri[:5] != "at://" { 164 + return "" 165 + } 166 + parts := []rune(uri[5:]) 167 + for i, r := range parts { 168 + if r == '/' { 169 + return string(parts[:i]) 170 + } 171 + } 172 + return string(parts) 173 + } 174 + 175 + func extractRkeyFromURI(uri string) string { 176 + // URI format: at://did:plc:xxx/collection/rkey 177 + if len(uri) < 5 || uri[:5] != "at://" { 178 + return "" 179 + } 180 + // Find last slash 181 + for i := len(uri) - 1; i >= 5; i-- { 182 + if uri[i] == '/' { 183 + return uri[i+1:] 184 + } 185 + } 186 + return "" 187 + }
+85
xrpc/feed/getPosts.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + ) 11 + 12 + // HandleGetPosts implements app.bsky.feed.getPosts 13 + func HandleGetPosts(c echo.Context, hydrator *hydration.Hydrator) error { 14 + // Get URIs from query params (can be multiple) 15 + urisParam := c.QueryParam("uris") 16 + if urisParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uris parameter is required", 20 + }) 21 + } 22 + 23 + // Parse URIs (they come as a comma-separated list or as multiple query params) 24 + var uris []string 25 + if strings.Contains(urisParam, ",") { 26 + uris = strings.Split(urisParam, ",") 27 + } else { 28 + // Check for multiple uri query params 29 + uris = c.QueryParams()["uris"] 30 + if len(uris) == 0 { 31 + uris = []string{urisParam} 32 + } 33 + } 34 + 35 + // Limit to reasonable number 36 + if len(uris) > 25 { 37 + uris = uris[:25] 38 + } 39 + 40 + ctx := c.Request().Context() 41 + viewer := getUserDID(c) 42 + 43 + // Hydrate posts 44 + postsMap, err := hydrator.HydratePosts(ctx, uris, viewer) 45 + if err != nil { 46 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 47 + "error": "InternalError", 48 + "message": "failed to load posts", 49 + }) 50 + } 51 + 52 + // Build response - need to maintain order of requested URIs 53 + posts := make([]interface{}, 0) 54 + for _, uri := range uris { 55 + postInfo, ok := postsMap[uri] 56 + if !ok { 57 + // Post not found, skip it 58 + continue 59 + } 60 + 61 + // Hydrate author 62 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 63 + if err != nil { 64 + continue 65 + } 66 + 67 + postView := views.PostView(postInfo, authorInfo) 68 + posts = append(posts, postView) 69 + } 70 + 71 + return c.JSON(http.StatusOK, map[string]interface{}{ 72 + "posts": posts, 73 + }) 74 + } 75 + 76 + func getUserDID(c echo.Context) string { 77 + did := c.Get("viewer") 78 + if did == nil { 79 + return "" 80 + } 81 + if s, ok := did.(string); ok { 82 + return s 83 + } 84 + return "" 85 + }
+111
xrpc/feed/getRepostedBy.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetRepostedBy implements app.bsky.feed.getRepostedBy 14 + func HandleGetRepostedBy(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + uriParam := c.QueryParam("uri") 16 + if uriParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uri parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (repost ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Get post ID from URI 42 + var postID uint 43 + db.Raw(` 44 + SELECT id FROM posts 45 + WHERE author = (SELECT id FROM repos WHERE did = ?) 46 + AND rkey = ? 47 + `, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID) 48 + 49 + if postID == 0 { 50 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 51 + "error": "NotFound", 52 + "message": "post not found", 53 + }) 54 + } 55 + 56 + // Query reposts 57 + type repostRow struct { 58 + ID uint 59 + AuthorDid string 60 + Rkey string 61 + Created string 62 + } 63 + var rows []repostRow 64 + 65 + query := ` 66 + SELECT rp.id, r.did as author_did, rp.rkey, rp.created 67 + FROM reposts rp 68 + JOIN repos r ON r.id = rp.author 69 + WHERE rp.subject = ? 70 + ` 71 + if cursor > 0 { 72 + query += ` AND rp.id < ?` 73 + } 74 + query += ` ORDER BY rp.id DESC LIMIT ?` 75 + 76 + var queryArgs []interface{} 77 + queryArgs = append(queryArgs, postID) 78 + if cursor > 0 { 79 + queryArgs = append(queryArgs, cursor) 80 + } 81 + queryArgs = append(queryArgs, limit) 82 + 83 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 84 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 85 + "error": "InternalError", 86 + "message": "failed to query reposts", 87 + }) 88 + } 89 + 90 + // Hydrate actors 91 + repostedBy := make([]interface{}, 0) 92 + for _, row := range rows { 93 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 94 + if err != nil { 95 + continue 96 + } 97 + repostedBy = append(repostedBy, views.ProfileView(actorInfo)) 98 + } 99 + 100 + // Generate next cursor 101 + var nextCursor string 102 + if len(rows) > 0 { 103 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 104 + } 105 + 106 + return c.JSON(http.StatusOK, map[string]interface{}{ 107 + "uri": uriParam, 108 + "repostedBy": repostedBy, 109 + "cursor": nextCursor, 110 + }) 111 + }
+118
xrpc/feed/getTimeline.go
··· 1 + package feed 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "strconv" 7 + "time" 8 + 9 + "github.com/labstack/echo/v4" 10 + "github.com/whyrusleeping/konbini/hydration" 11 + "github.com/whyrusleeping/konbini/views" 12 + "gorm.io/gorm" 13 + ) 14 + 15 + // HandleGetTimeline implements app.bsky.feed.getTimeline 16 + func HandleGetTimeline(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 17 + viewer := getUserDID(c) 18 + if viewer == "" { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + 25 + // Parse limit 26 + limit := 50 27 + if limitParam := c.QueryParam("limit"); limitParam != "" { 28 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + // Parse cursor (timestamp) 34 + cursor := time.Now() 35 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 36 + if t, err := time.Parse(time.RFC3339, cursorParam); err == nil { 37 + cursor = t 38 + } 39 + } 40 + 41 + ctx := c.Request().Context() 42 + 43 + // Get viewer's repo ID 44 + var viewerRepoID uint 45 + if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil { 46 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 47 + "error": "InternalError", 48 + "message": "failed to load viewer", 49 + }) 50 + } 51 + 52 + // Query posts from followed users 53 + type postRow struct { 54 + URI string 55 + AuthorID uint 56 + } 57 + var rows []postRow 58 + err := db.Raw(` 59 + SELECT 60 + 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri, 61 + p.author as author_id 62 + FROM posts p 63 + JOIN repos r ON r.id = p.author 64 + WHERE p.reply_to = 0 65 + AND p.author IN (SELECT subject FROM follows WHERE author = ?) 66 + AND p.created < ? 67 + AND p.not_found = false 68 + ORDER BY p.created DESC 69 + LIMIT ? 70 + `, viewerRepoID, cursor, limit).Scan(&rows).Error 71 + 72 + if err != nil { 73 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 74 + "error": "InternalError", 75 + "message": "failed to query timeline", 76 + }) 77 + } 78 + 79 + // Hydrate posts 80 + feed := make([]interface{}, 0) 81 + for _, row := range rows { 82 + postInfo, err := hydrator.HydratePost(ctx, row.URI, viewer) 83 + if err != nil { 84 + continue 85 + } 86 + 87 + // Hydrate author 88 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 89 + if err != nil { 90 + slog.Error("failed to hydrate actor", "author", postInfo.Author, "error", err) 91 + continue 92 + } 93 + 94 + feedItem := views.FeedViewPost(postInfo, authorInfo) 95 + feed = append(feed, feedItem) 96 + } 97 + 98 + // Generate next cursor 99 + var nextCursor string 100 + if len(rows) > 0 { 101 + // Get the created time of the last post 102 + var lastCreated time.Time 103 + lastURI := rows[len(rows)-1].URI 104 + postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer) 105 + if err == nil && postInfo.Post != nil { 106 + t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt) 107 + if err == nil { 108 + lastCreated = t 109 + nextCursor = lastCreated.Format(time.RFC3339) 110 + } 111 + } 112 + } 113 + 114 + return c.JSON(http.StatusOK, map[string]interface{}{ 115 + "feed": feed, 116 + "cursor": nextCursor, 117 + }) 118 + }
+97
xrpc/graph/getBlocks.go
··· 1 + package graph 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/whyrusleeping/konbini/hydration" 10 + "github.com/whyrusleeping/konbini/views" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // HandleGetBlocks implements app.bsky.graph.getBlocks 15 + func HandleGetBlocks(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + // Get viewer from authentication 17 + viewer := c.Get("viewer") 18 + if viewer == nil { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + viewerDID := viewer.(string) 25 + 26 + // Parse limit 27 + limit := 50 28 + if limitParam := c.QueryParam("limit"); limitParam != "" { 29 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 30 + limit = l 31 + } 32 + } 33 + 34 + // Parse cursor (block ID) 35 + var cursor uint 36 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 37 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 38 + cursor = uint(c) 39 + } 40 + } 41 + 42 + ctx := c.Request().Context() 43 + 44 + // Query blocks 45 + type blockRow struct { 46 + ID uint 47 + SubjectDid string 48 + } 49 + var rows []blockRow 50 + 51 + query := ` 52 + SELECT b.id, r.did as subject_did 53 + FROM blocks b 54 + LEFT JOIN repos r ON r.id = b.subject 55 + WHERE b.author = (SELECT id FROM repos WHERE did = ?) 56 + ` 57 + if cursor > 0 { 58 + query += ` AND b.id < ?` 59 + } 60 + query += ` ORDER BY b.id DESC LIMIT ?` 61 + 62 + var queryArgs []interface{} 63 + queryArgs = append(queryArgs, viewerDID) 64 + if cursor > 0 { 65 + queryArgs = append(queryArgs, cursor) 66 + } 67 + queryArgs = append(queryArgs, limit) 68 + 69 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 70 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 71 + "error": "InternalError", 72 + "message": "failed to query blocks", 73 + }) 74 + } 75 + 76 + // Hydrate blocked actors 77 + blocks := make([]interface{}, 0) 78 + for _, row := range rows { 79 + actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid) 80 + if err != nil { 81 + fmt.Println("Hydrating actor failed: ", err) 82 + continue 83 + } 84 + blocks = append(blocks, views.ProfileView(actorInfo)) 85 + } 86 + 87 + // Generate next cursor 88 + var nextCursor string 89 + if len(rows) > 0 { 90 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 91 + } 92 + 93 + return c.JSON(http.StatusOK, map[string]interface{}{ 94 + "blocks": blocks, 95 + "cursor": nextCursor, 96 + }) 97 + }
+112
xrpc/graph/getFollowers.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetFollowers implements app.bsky.graph.getFollowers 14 + func HandleGetFollowers(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (follow ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Resolve actor to DID 42 + did, err := hydrator.ResolveDID(ctx, actorParam) 43 + if err != nil { 44 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 45 + "error": "ActorNotFound", 46 + "message": "actor not found", 47 + }) 48 + } 49 + 50 + // Get the subject actor info 51 + subjectInfo, err := hydrator.HydrateActor(ctx, did) 52 + if err != nil { 53 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 54 + "error": "ActorNotFound", 55 + "message": "failed to load actor", 56 + }) 57 + } 58 + 59 + // Query followers 60 + type followerRow struct { 61 + ID uint 62 + AuthorDid string 63 + } 64 + var rows []followerRow 65 + 66 + query := ` 67 + SELECT f.id, r.did as author_did 68 + FROM follows f 69 + JOIN repos r ON r.id = f.author 70 + WHERE f.subject = (SELECT id FROM repos WHERE did = ?) 71 + ` 72 + if cursor > 0 { 73 + query += ` AND f.id < ?` 74 + } 75 + query += ` ORDER BY f.id DESC LIMIT ?` 76 + 77 + var queryArgs []interface{} 78 + queryArgs = append(queryArgs, did) 79 + if cursor > 0 { 80 + queryArgs = append(queryArgs, cursor) 81 + } 82 + queryArgs = append(queryArgs, limit) 83 + 84 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 85 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 86 + "error": "InternalError", 87 + "message": "failed to query followers", 88 + }) 89 + } 90 + 91 + // Hydrate follower actors 92 + followers := make([]interface{}, 0) 93 + for _, row := range rows { 94 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 95 + if err != nil { 96 + continue 97 + } 98 + followers = append(followers, views.ProfileView(actorInfo)) 99 + } 100 + 101 + // Generate next cursor 102 + var nextCursor string 103 + if len(rows) > 0 { 104 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 105 + } 106 + 107 + return c.JSON(http.StatusOK, map[string]interface{}{ 108 + "subject": views.ProfileView(subjectInfo), 109 + "followers": followers, 110 + "cursor": nextCursor, 111 + }) 112 + }
+112
xrpc/graph/getFollows.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetFollows implements app.bsky.graph.getFollows 14 + func HandleGetFollows(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (follow ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Resolve actor to DID 42 + did, err := hydrator.ResolveDID(ctx, actorParam) 43 + if err != nil { 44 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 45 + "error": "ActorNotFound", 46 + "message": "actor not found", 47 + }) 48 + } 49 + 50 + // Get the subject actor info (the person whose follows we're listing) 51 + subjectInfo, err := hydrator.HydrateActor(ctx, did) 52 + if err != nil { 53 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 54 + "error": "ActorNotFound", 55 + "message": "failed to load actor", 56 + }) 57 + } 58 + 59 + // Query follows 60 + type followRow struct { 61 + ID uint 62 + SubjectDid string 63 + } 64 + var rows []followRow 65 + 66 + query := ` 67 + SELECT f.id, r.did as subject_did 68 + FROM follows f 69 + JOIN repos r ON r.id = f.subject 70 + WHERE f.author = (SELECT id FROM repos WHERE did = ?) 71 + ` 72 + if cursor > 0 { 73 + query += ` AND f.id < ?` 74 + } 75 + query += ` ORDER BY f.id DESC LIMIT ?` 76 + 77 + var queryArgs []interface{} 78 + queryArgs = append(queryArgs, did) 79 + if cursor > 0 { 80 + queryArgs = append(queryArgs, cursor) 81 + } 82 + queryArgs = append(queryArgs, limit) 83 + 84 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 85 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 86 + "error": "InternalError", 87 + "message": "failed to query follows", 88 + }) 89 + } 90 + 91 + // Hydrate followed actors 92 + follows := make([]interface{}, 0) 93 + for _, row := range rows { 94 + actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid) 95 + if err != nil { 96 + continue 97 + } 98 + follows = append(follows, views.ProfileView(actorInfo)) 99 + } 100 + 101 + // Generate next cursor 102 + var nextCursor string 103 + if len(rows) > 0 { 104 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 105 + } 106 + 107 + return c.JSON(http.StatusOK, map[string]interface{}{ 108 + "subject": views.ProfileView(subjectInfo), 109 + "follows": follows, 110 + "cursor": nextCursor, 111 + }) 112 + }
+41
xrpc/graph/getMutes.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandleGetMutes implements app.bsky.graph.getMutes 12 + // NOTE: Mutes are typically stored as user preferences/settings, not as repo records. 13 + // This implementation returns an empty list as mute tracking is not yet implemented 14 + // in the database schema. 15 + func HandleGetMutes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + // Get viewer from authentication 17 + viewer := c.Get("viewer") 18 + if viewer == nil { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + 25 + // TODO: Implement mute tracking in the database 26 + // Mutes are different from blocks - they're typically stored as preferences 27 + // rather than as repo records. Would need a new table like: 28 + // CREATE TABLE user_mutes ( 29 + // id SERIAL PRIMARY KEY, 30 + // actor_did TEXT NOT NULL, 31 + // muted_did TEXT NOT NULL, 32 + // created_at TIMESTAMP NOT NULL, 33 + // UNIQUE(actor_did, muted_did) 34 + // ); 35 + 36 + // For now, return empty list 37 + return c.JSON(http.StatusOK, map[string]interface{}{ 38 + "mutes": []interface{}{}, 39 + "cursor": "", 40 + }) 41 + }
+102
xrpc/graph/getRelationships.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandleGetRelationships implements app.bsky.graph.getRelationships 12 + func HandleGetRelationships(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 13 + actorParam := c.QueryParam("actor") 14 + if actorParam == "" { 15 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 16 + "error": "InvalidRequest", 17 + "message": "actor parameter is required", 18 + }) 19 + } 20 + 21 + // Parse others parameter (can be multiple) 22 + others := c.QueryParams()["others"] 23 + if len(others) == 0 { 24 + return c.JSON(http.StatusOK, map[string]interface{}{ 25 + "actor": actorParam, 26 + "relationships": []interface{}{}, 27 + }) 28 + } 29 + 30 + // Limit to reasonable batch size 31 + if len(others) > 30 { 32 + others = others[:30] 33 + } 34 + 35 + ctx := c.Request().Context() 36 + 37 + // Resolve actor to DID 38 + actorDID, err := hydrator.ResolveDID(ctx, actorParam) 39 + if err != nil { 40 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 41 + "error": "ActorNotFound", 42 + "message": "actor not found", 43 + }) 44 + } 45 + 46 + // Build relationships for each "other" actor 47 + relationships := make([]interface{}, 0, len(others)) 48 + 49 + for _, other := range others { 50 + // Resolve other to DID 51 + otherDID, err := hydrator.ResolveDID(ctx, other) 52 + if err != nil { 53 + // Actor not found 54 + relationships = append(relationships, map[string]interface{}{ 55 + "$type": "app.bsky.graph.defs#notFoundActor", 56 + "actor": other, 57 + "notFound": true, 58 + }) 59 + continue 60 + } 61 + 62 + // Check if actor follows other 63 + var following string 64 + err = db.Raw(` 65 + SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri 66 + FROM follows f 67 + JOIN repos r1 ON r1.id = f.author 68 + JOIN repos r2 ON r2.id = f.subject 69 + WHERE r1.did = ? AND r2.did = ? 70 + LIMIT 1 71 + `, actorDID, otherDID).Scan(&following).Error 72 + if err != nil { 73 + following = "" 74 + } 75 + 76 + // Check if other follows actor 77 + var followedBy string 78 + err = db.Raw(` 79 + SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri 80 + FROM follows f 81 + JOIN repos r1 ON r1.id = f.author 82 + JOIN repos r2 ON r2.id = f.subject 83 + WHERE r1.did = ? AND r2.did = ? 84 + LIMIT 1 85 + `, otherDID, actorDID).Scan(&followedBy).Error 86 + if err != nil { 87 + followedBy = "" 88 + } 89 + 90 + relationships = append(relationships, map[string]interface{}{ 91 + "$type": "app.bsky.graph.defs#relationship", 92 + "did": otherDID, 93 + "following": following, 94 + "followedBy": followedBy, 95 + }) 96 + } 97 + 98 + return c.JSON(http.StatusOK, map[string]interface{}{ 99 + "actor": actorDID, 100 + "relationships": relationships, 101 + }) 102 + }
+30
xrpc/identity.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/labstack/echo/v4" 9 + ) 10 + 11 + // handleResolveHandle implements com.atproto.identity.resolveHandle 12 + func (s *Server) handleResolveHandle(c echo.Context) error { 13 + handle := c.QueryParam("handle") 14 + if handle == "" { 15 + return XRPCError(c, http.StatusBadRequest, "InvalidRequest", "handle parameter is required") 16 + } 17 + 18 + // Clean up handle (remove @ prefix if present) 19 + handle = strings.TrimPrefix(handle, "@") 20 + 21 + // Resolve handle to DID 22 + resp, err := s.dir.LookupHandle(c.Request().Context(), syntax.Handle(handle)) 23 + if err != nil { 24 + return XRPCError(c, http.StatusBadRequest, "HandleNotFound", "handle not found") 25 + } 26 + 27 + return c.JSON(http.StatusOK, map[string]interface{}{ 28 + "did": resp.DID.String(), 29 + }) 30 + }
+17
xrpc/labeler/getServices.go
··· 1 + package labeler 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + // HandleGetServices implements app.bsky.labeler.getServices 10 + // Returns information about labeler services 11 + func HandleGetServices(c echo.Context) error { 12 + // For now, return empty views since we don't have labeler support 13 + // A full implementation would parse the "dids" query parameter 14 + return c.JSON(http.StatusOK, map[string]interface{}{ 15 + "views": []interface{}{}, 16 + }) 17 + }
+181
xrpc/notification/listNotifications.go
··· 1 + package notification 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleListNotifications implements app.bsky.notification.listNotifications 14 + func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + viewer := getUserDID(c) 16 + if viewer == "" { 17 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 18 + "error": "AuthenticationRequired", 19 + "message": "authentication required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (notification ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Query notifications for viewer with CIDs from source records 42 + type notifRow struct { 43 + ID uint 44 + Kind string 45 + AuthorDid string 46 + Source string 47 + SourceCid string 48 + CreatedAt string 49 + } 50 + var rows []notifRow 51 + 52 + // This query tries to fetch the CID from the source record 53 + // depending on the notification kind (like, repost, reply, etc.) 54 + query := ` 55 + SELECT 56 + n.id, 57 + n.kind, 58 + r.did as author_did, 59 + n.source, 60 + n.source_cid, 61 + n.created_at 62 + FROM notifications n 63 + JOIN repos r ON r.id = n.author 64 + LEFT JOIN repos r2 ON r2.id = n.author 65 + WHERE n.for = (SELECT id FROM repos WHERE did = ?) 66 + ` 67 + if cursor > 0 { 68 + query += ` AND n.id < ?` 69 + } 70 + query += ` ORDER BY n.created_at DESC LIMIT ?` 71 + 72 + var queryArgs []interface{} 73 + queryArgs = append(queryArgs, viewer) 74 + if cursor > 0 { 75 + queryArgs = append(queryArgs, cursor) 76 + } 77 + queryArgs = append(queryArgs, limit) 78 + 79 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 80 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 81 + "error": "InternalError", 82 + "message": "failed to query notifications", 83 + }) 84 + } 85 + 86 + // Hydrate notifications 87 + notifications := make([]interface{}, 0) 88 + for _, row := range rows { 89 + authorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 90 + if err != nil { 91 + continue 92 + } 93 + 94 + notif := map[string]interface{}{ 95 + "uri": row.Source, 96 + "author": views.ProfileView(authorInfo), 97 + "reason": mapNotifKind(row.Kind), 98 + "record": nil, // Could hydrate the source record here 99 + "isRead": false, 100 + "indexedAt": row.CreatedAt, 101 + "labels": []interface{}{}, 102 + } 103 + 104 + // Only include CID if we have one (required field) 105 + if row.SourceCid != "" { 106 + notif["cid"] = row.SourceCid 107 + } else { 108 + // Skip notifications without CIDs as they're invalid 109 + continue 110 + } 111 + 112 + notifications = append(notifications, notif) 113 + } 114 + 115 + // Generate next cursor 116 + var nextCursor string 117 + if len(rows) > 0 { 118 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 119 + } 120 + 121 + return c.JSON(http.StatusOK, map[string]interface{}{ 122 + "notifications": notifications, 123 + "cursor": nextCursor, 124 + }) 125 + } 126 + 127 + // HandleGetUnreadCount implements app.bsky.notification.getUnreadCount 128 + func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 129 + viewer := getUserDID(c) 130 + if viewer == "" { 131 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 132 + "error": "AuthenticationRequired", 133 + "message": "authentication required", 134 + }) 135 + } 136 + 137 + // For now, return 0 - we'd need to track read state in the database 138 + return c.JSON(http.StatusOK, map[string]interface{}{ 139 + "count": 0, 140 + }) 141 + } 142 + 143 + // HandleUpdateSeen implements app.bsky.notification.updateSeen 144 + func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 145 + viewer := getUserDID(c) 146 + if viewer == "" { 147 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 148 + "error": "AuthenticationRequired", 149 + "message": "authentication required", 150 + }) 151 + } 152 + 153 + // For now, just return success - we'd need to track seen timestamps in the database 154 + return c.JSON(http.StatusOK, map[string]interface{}{}) 155 + } 156 + 157 + func getUserDID(c echo.Context) string { 158 + did := c.Get("viewer") 159 + if did == nil { 160 + return "" 161 + } 162 + if s, ok := did.(string); ok { 163 + return s 164 + } 165 + return "" 166 + } 167 + 168 + func mapNotifKind(kind string) string { 169 + switch kind { 170 + case "reply": 171 + return "reply" 172 + case "like": 173 + return "like" 174 + case "repost": 175 + return "repost" 176 + case "mention": 177 + return "mention" 178 + default: 179 + return kind 180 + } 181 + }
+190
xrpc/repo/getRecord.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + cbg "github.com/whyrusleeping/cbor-gen" 8 + 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + "github.com/labstack/echo/v4" 11 + "github.com/whyrusleeping/konbini/hydration" 12 + "gorm.io/gorm" 13 + ) 14 + 15 + // HandleGetRecord implements com.atproto.repo.getRecord 16 + func HandleGetRecord(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 17 + repoParam := c.QueryParam("repo") 18 + collection := c.QueryParam("collection") 19 + rkey := c.QueryParam("rkey") 20 + cidParam := c.QueryParam("cid") 21 + 22 + if repoParam == "" || collection == "" || rkey == "" { 23 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 24 + "error": "InvalidRequest", 25 + "message": "repo, collection, and rkey parameters are required", 26 + }) 27 + } 28 + 29 + ctx := c.Request().Context() 30 + 31 + // Resolve repo to DID 32 + repoDID, err := hydrator.ResolveDID(ctx, repoParam) 33 + if err != nil { 34 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 35 + "error": "InvalidRequest", 36 + "message": fmt.Sprintf("could not find repo: %s", repoParam), 37 + }) 38 + } 39 + 40 + // Build URI 41 + uri := fmt.Sprintf("at://%s/%s/%s", repoDID, collection, rkey) 42 + 43 + // Query the record based on collection type 44 + var recordCID string 45 + var recordRaw []byte 46 + 47 + switch collection { 48 + case "app.bsky.feed.post": 49 + type postRecord struct { 50 + CID string 51 + Raw []byte 52 + } 53 + var post postRecord 54 + err = db.Raw(` 55 + SELECT COALESCE(p.cid, '') as cid, p.raw 56 + FROM posts p 57 + JOIN repos r ON r.id = p.author 58 + WHERE r.did = ? AND p.rkey = ? 59 + LIMIT 1 60 + `, repoDID, rkey).Scan(&post).Error 61 + if err != nil || len(post.Raw) == 0 { 62 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 63 + "error": "RecordNotFound", 64 + "message": fmt.Sprintf("could not locate record: %s", uri), 65 + }) 66 + } 67 + recordCID = post.CID // May be empty 68 + recordRaw = post.Raw 69 + 70 + case "app.bsky.actor.profile": 71 + type profileRecord struct { 72 + CID string 73 + Raw []byte 74 + } 75 + var profile profileRecord 76 + err = db.Raw(` 77 + SELECT p.cid, p.raw 78 + FROM profiles p 79 + JOIN repos r ON r.id = p.repo 80 + WHERE r.did = ? AND p.rkey = ? 81 + `, repoDID, rkey).Scan(&profile).Error 82 + if err != nil || profile.CID == "" { 83 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 84 + "error": "RecordNotFound", 85 + "message": fmt.Sprintf("could not locate record: %s", uri), 86 + }) 87 + } 88 + recordCID = profile.CID 89 + recordRaw = profile.Raw 90 + 91 + case "app.bsky.graph.follow": 92 + type followRecord struct { 93 + CID string 94 + Raw []byte 95 + } 96 + var follow followRecord 97 + err = db.Raw(` 98 + SELECT f.cid, f.raw 99 + FROM follows f 100 + JOIN repos r ON r.id = f.author 101 + WHERE r.did = ? AND f.rkey = ? 102 + `, repoDID, rkey).Scan(&follow).Error 103 + if err != nil || follow.CID == "" { 104 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 105 + "error": "RecordNotFound", 106 + "message": fmt.Sprintf("could not locate record: %s", uri), 107 + }) 108 + } 109 + recordCID = follow.CID 110 + recordRaw = follow.Raw 111 + 112 + case "app.bsky.feed.like": 113 + type likeRecord struct { 114 + CID string 115 + Raw []byte 116 + } 117 + var like likeRecord 118 + err = db.Raw(` 119 + SELECT l.cid, l.raw 120 + FROM likes l 121 + JOIN repos r ON r.id = l.author 122 + WHERE r.did = ? AND l.rkey = ? 123 + `, repoDID, rkey).Scan(&like).Error 124 + if err != nil || like.CID == "" { 125 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 126 + "error": "RecordNotFound", 127 + "message": fmt.Sprintf("could not locate record: %s", uri), 128 + }) 129 + } 130 + recordCID = like.CID 131 + recordRaw = like.Raw 132 + 133 + case "app.bsky.feed.repost": 134 + type repostRecord struct { 135 + CID string 136 + Raw []byte 137 + } 138 + var repost repostRecord 139 + err = db.Raw(` 140 + SELECT rp.cid, rp.raw 141 + FROM reposts rp 142 + JOIN repos r ON r.id = rp.author 143 + WHERE r.did = ? AND rp.rkey = ? 144 + `, repoDID, rkey).Scan(&repost).Error 145 + if err != nil || repost.CID == "" { 146 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 147 + "error": "RecordNotFound", 148 + "message": fmt.Sprintf("could not locate record: %s", uri), 149 + }) 150 + } 151 + recordCID = repost.CID 152 + recordRaw = repost.Raw 153 + 154 + default: 155 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 156 + "error": "InvalidRequest", 157 + "message": fmt.Sprintf("unsupported collection: %s", collection), 158 + }) 159 + } 160 + 161 + // Check CID if provided 162 + if cidParam != "" && recordCID != cidParam { 163 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 164 + "error": "RecordNotFound", 165 + "message": fmt.Sprintf("could not locate record: %s", uri), 166 + }) 167 + } 168 + 169 + // Decode the CBOR record 170 + // For now, return a placeholder - full CBOR decoding would require 171 + // type-specific unmarshalers for each collection type 172 + var value interface{} 173 + if len(recordRaw) > 0 { 174 + rec, err := lexutil.CborDecodeValue(recordRaw) 175 + if err != nil { 176 + return err 177 + } 178 + 179 + value = rec 180 + } 181 + 182 + // Suppress unused import warning 183 + _ = cbg.CborNull 184 + 185 + return c.JSON(http.StatusOK, map[string]interface{}{ 186 + "uri": uri, 187 + "cid": recordCID, 188 + "value": value, 189 + }) 190 + }
+207
xrpc/server.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/atproto/identity" 8 + "github.com/labstack/echo/v4" 9 + "github.com/labstack/echo/v4/middleware" 10 + "github.com/whyrusleeping/konbini/hydration" 11 + "github.com/whyrusleeping/konbini/xrpc/actor" 12 + "github.com/whyrusleeping/konbini/xrpc/feed" 13 + "github.com/whyrusleeping/konbini/xrpc/graph" 14 + "github.com/whyrusleeping/konbini/xrpc/labeler" 15 + "github.com/whyrusleeping/konbini/xrpc/notification" 16 + "github.com/whyrusleeping/konbini/xrpc/repo" 17 + "github.com/whyrusleeping/konbini/xrpc/unspecced" 18 + "gorm.io/gorm" 19 + ) 20 + 21 + // Server represents the XRPC API server 22 + type Server struct { 23 + e *echo.Echo 24 + db *gorm.DB 25 + dir identity.Directory 26 + backend Backend 27 + hydrator *hydration.Hydrator 28 + } 29 + 30 + // Backend interface for data access 31 + type Backend interface { 32 + // Add methods as needed for data access 33 + 34 + TrackMissingActor(did string) 35 + } 36 + 37 + // NewServer creates a new XRPC server 38 + func NewServer(db *gorm.DB, dir identity.Directory, backend Backend) *Server { 39 + e := echo.New() 40 + e.HidePort = true 41 + e.HideBanner = true 42 + 43 + // CORS middleware 44 + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 45 + AllowOrigins: []string{"*"}, 46 + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, 47 + AllowHeaders: []string{"*"}, 48 + })) 49 + 50 + // Logging middleware 51 + e.Use(middleware.Logger()) 52 + e.Use(middleware.Recover()) 53 + 54 + s := &Server{ 55 + e: e, 56 + db: db, 57 + dir: dir, 58 + backend: backend, 59 + hydrator: hydration.NewHydrator(db, dir), 60 + } 61 + 62 + s.hydrator.SetMissingActorCallback(backend.TrackMissingActor) 63 + 64 + // Register XRPC endpoints 65 + s.registerEndpoints() 66 + 67 + return s 68 + } 69 + 70 + // Start starts the XRPC server 71 + func (s *Server) Start(addr string) error { 72 + slog.Info("starting XRPC server", "addr", addr) 73 + return s.e.Start(addr) 74 + } 75 + 76 + // registerEndpoints registers all XRPC endpoints 77 + func (s *Server) registerEndpoints() { 78 + // XRPC endpoints follow the pattern: /xrpc/<namespace>.<method> 79 + xrpcGroup := s.e.Group("/xrpc") 80 + 81 + // com.atproto.identity.* 82 + xrpcGroup.GET("/com.atproto.identity.resolveHandle", s.handleResolveHandle) 83 + 84 + // com.atproto.repo.* 85 + xrpcGroup.GET("/com.atproto.repo.getRecord", func(c echo.Context) error { 86 + return repo.HandleGetRecord(c, s.db, s.hydrator) 87 + }) 88 + 89 + // app.bsky.actor.* 90 + xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error { 91 + return actor.HandleGetProfile(c, s.hydrator) 92 + }) 93 + xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error { 94 + return actor.HandleGetProfiles(c, s.db, s.hydrator) 95 + }) 96 + xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error { 97 + return actor.HandleGetPreferences(c, s.db, s.hydrator) 98 + }, s.requireAuth) 99 + xrpcGroup.POST("/app.bsky.actor.putPreferences", func(c echo.Context) error { 100 + return actor.HandlePutPreferences(c, s.db, s.hydrator) 101 + }, s.requireAuth) 102 + xrpcGroup.GET("/app.bsky.actor.searchActors", s.handleSearchActors) 103 + xrpcGroup.GET("/app.bsky.actor.searchActorsTypeahead", s.handleSearchActorsTypeahead) 104 + 105 + // app.bsky.feed.* 106 + xrpcGroup.GET("/app.bsky.feed.getTimeline", func(c echo.Context) error { 107 + return feed.HandleGetTimeline(c, s.db, s.hydrator) 108 + }, s.requireAuth) 109 + xrpcGroup.GET("/app.bsky.feed.getAuthorFeed", func(c echo.Context) error { 110 + return feed.HandleGetAuthorFeed(c, s.db, s.hydrator) 111 + }) 112 + xrpcGroup.GET("/app.bsky.feed.getPostThread", func(c echo.Context) error { 113 + return feed.HandleGetPostThread(c, s.db, s.hydrator) 114 + }) 115 + xrpcGroup.GET("/app.bsky.feed.getPosts", func(c echo.Context) error { 116 + return feed.HandleGetPosts(c, s.hydrator) 117 + }) 118 + xrpcGroup.GET("/app.bsky.feed.getLikes", func(c echo.Context) error { 119 + return feed.HandleGetLikes(c, s.db, s.hydrator) 120 + }) 121 + xrpcGroup.GET("/app.bsky.feed.getRepostedBy", func(c echo.Context) error { 122 + return feed.HandleGetRepostedBy(c, s.db, s.hydrator) 123 + }) 124 + xrpcGroup.GET("/app.bsky.feed.getActorLikes", func(c echo.Context) error { 125 + return feed.HandleGetActorLikes(c, s.db, s.hydrator) 126 + }, s.requireAuth) 127 + 128 + // app.bsky.graph.* 129 + xrpcGroup.GET("/app.bsky.graph.getFollows", func(c echo.Context) error { 130 + return graph.HandleGetFollows(c, s.db, s.hydrator) 131 + }) 132 + xrpcGroup.GET("/app.bsky.graph.getFollowers", func(c echo.Context) error { 133 + return graph.HandleGetFollowers(c, s.db, s.hydrator) 134 + }) 135 + xrpcGroup.GET("/app.bsky.graph.getBlocks", func(c echo.Context) error { 136 + return graph.HandleGetBlocks(c, s.db, s.hydrator) 137 + }, s.requireAuth) 138 + xrpcGroup.GET("/app.bsky.graph.getMutes", func(c echo.Context) error { 139 + return graph.HandleGetMutes(c, s.db, s.hydrator) 140 + }, s.requireAuth) 141 + xrpcGroup.GET("/app.bsky.graph.getRelationships", func(c echo.Context) error { 142 + return graph.HandleGetRelationships(c, s.db, s.hydrator) 143 + }) 144 + xrpcGroup.GET("/app.bsky.graph.getLists", s.handleGetLists) 145 + xrpcGroup.GET("/app.bsky.graph.getList", s.handleGetList) 146 + 147 + // app.bsky.notification.* 148 + xrpcGroup.GET("/app.bsky.notification.listNotifications", func(c echo.Context) error { 149 + return notification.HandleListNotifications(c, s.db, s.hydrator) 150 + }, s.requireAuth) 151 + xrpcGroup.GET("/app.bsky.notification.getUnreadCount", func(c echo.Context) error { 152 + return notification.HandleGetUnreadCount(c, s.db, s.hydrator) 153 + }, s.requireAuth) 154 + xrpcGroup.POST("/app.bsky.notification.updateSeen", func(c echo.Context) error { 155 + return notification.HandleUpdateSeen(c, s.db, s.hydrator) 156 + }, s.requireAuth) 157 + 158 + // app.bsky.labeler.* 159 + xrpcGroup.GET("/app.bsky.labeler.getServices", func(c echo.Context) error { 160 + return labeler.HandleGetServices(c) 161 + }) 162 + 163 + // app.bsky.unspecced.* 164 + xrpcGroup.GET("/app.bsky.unspecced.getConfig", func(c echo.Context) error { 165 + return unspecced.HandleGetConfig(c) 166 + }) 167 + xrpcGroup.GET("/app.bsky.unspecced.getTrendingTopics", func(c echo.Context) error { 168 + return unspecced.HandleGetTrendingTopics(c) 169 + }) 170 + } 171 + 172 + // XRPCError creates a properly formatted XRPC error response 173 + func XRPCError(c echo.Context, statusCode int, errType, message string) error { 174 + return c.JSON(statusCode, map[string]interface{}{ 175 + "error": errType, 176 + "message": message, 177 + }) 178 + } 179 + 180 + // getUserDID extracts the viewer DID from the request context 181 + // Returns empty string if not authenticated 182 + func getUserDID(c echo.Context) string { 183 + did := c.Get("viewer") 184 + if did == nil { 185 + return "" 186 + } 187 + if s, ok := did.(string); ok { 188 + return s 189 + } 190 + return "" 191 + } 192 + 193 + func (s *Server) handleSearchActors(c echo.Context) error { 194 + return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented") 195 + } 196 + 197 + func (s *Server) handleSearchActorsTypeahead(c echo.Context) error { 198 + return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented") 199 + } 200 + 201 + func (s *Server) handleGetLists(c echo.Context) error { 202 + return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented") 203 + } 204 + 205 + func (s *Server) handleGetList(c echo.Context) error { 206 + return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented") 207 + }
+16
xrpc/unspecced/getConfig.go
··· 1 + package unspecced 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + // HandleGetConfig implements app.bsky.unspecced.getConfig 10 + // Returns basic configuration for the app 11 + func HandleGetConfig(c echo.Context) error { 12 + return c.JSON(http.StatusOK, map[string]interface{}{ 13 + "checkEmailConfirmed": false, 14 + "liveNow": []any{}, 15 + }) 16 + }
+16
xrpc/unspecced/getTrendingTopics.go
··· 1 + package unspecced 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + // HandleGetTrendingTopics implements app.bsky.unspecced.getTrendingTopics 10 + // Returns trending topics (empty for now) 11 + func HandleGetTrendingTopics(c echo.Context) error { 12 + return c.JSON(http.StatusOK, map[string]interface{}{ 13 + "topics": []interface{}{}, 14 + "suggested": []interface{}{}, 15 + }) 16 + }