+4
-4
events.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+4
-3
models.go
+4
pgbackend.go
+4
pgbackend.go
+100
views/actor.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}