+1
-1
Dockerfile
+1
-1
Dockerfile
+19
LICENSE-MIT
+19
LICENSE-MIT
···
1
+
MIT License
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a copy
4
+
of this software and associated documentation files (the "Software"), to deal
5
+
in the Software without restriction, including without limitation the rights
6
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+
copies of the Software, and to permit persons to whom the Software is
8
+
furnished to do so, subject to the following conditions:
9
+
10
+
The above copyright notice and this permission notice shall be included in all
11
+
copies or substantial portions of the Software.
12
+
13
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+
SOFTWARE.
+135
-1
README.md
+135
-1
README.md
···
19
19
### Prerequisites
20
20
21
21
- Docker and Docker Compose installed
22
-
- Your Bluesky DID (find it at https://bsky.app/settings/account)
22
+
- Creating an app password (via: https://bsky.app/settings/app-passwords)
23
23
24
24
### Setup
25
25
···
109
109
110
110
The frontend will be available at http://localhost:3000 and will connect to the API at http://localhost:4444.
111
111
112
+
## Running the Bluesky App against Konbini
113
+
114
+
Konbini implements a large portion of the app.bsky.\* appview endpoints that
115
+
are required for pointing the main app to it and having it work reasonably
116
+
well.
117
+
118
+
To accomplish this you will need a few things:
119
+
120
+
### Service DID
121
+
122
+
You will need a DID, preferably a did:web for your appview that points at a
123
+
public endpoint where your appview is accessible via https.
124
+
I'll get into the https proxy next, but for the did, I've just pointed a domain
125
+
I own (in my case appview1.bluesky.day) to a VPS, and used caddy to host a file
126
+
at `/.well-known/did.json`.
127
+
That file should look like this:
128
+
129
+
```json
130
+
{
131
+
"@context": [
132
+
"https://www.w3.org/ns/did/v1",
133
+
"https://w3id.org/security/multikey/v1"
134
+
],
135
+
"id": "did:web:appview1.bluesky.day",
136
+
"verificationMethod": [
137
+
{
138
+
"id": "did:web:api.bsky.app#atproto",
139
+
"type": "Multikey",
140
+
"controller": "did:web:api.bsky.app",
141
+
"publicKeyMultibase": "zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"
142
+
}
143
+
],
144
+
"service": [
145
+
{
146
+
"id": "#bsky_notif",
147
+
"type": "BskyNotificationService",
148
+
"serviceEndpoint": "YOUR APPVIEW HTTPS URL"
149
+
},
150
+
{
151
+
"id": "#bsky_appview",
152
+
"type": "BskyAppView",
153
+
"serviceEndpoint": "YOUR APPVIEW HTTPS URL"
154
+
}
155
+
]
156
+
}
157
+
```
158
+
159
+
The verificationMethod isn't used but i'm not sure if _something_ is required
160
+
there or not, so i'm just leaving that there, it works on my machine.
161
+
162
+
### HTTPS Endpoint
163
+
164
+
I've been using ngrok to proxy traffic from a publicly accessible https url to my appview.
165
+
You can simply run `ngrok http 4446` and it will give you an https url that you
166
+
can then put in your DID doc above.
167
+
168
+
### The Social App
169
+
170
+
Now, clone and build the social app:
171
+
172
+
```
173
+
git clone https://github.com/bluesky-social/social-app
174
+
cd social-app
175
+
yarn
176
+
```
177
+
178
+
And then set this environment variable that tells it to use your appview:
179
+
180
+
```
181
+
export EXPO_PUBLIC_BLUESKY_PROXY_DID=did:web:YOURDIDWEB
182
+
```
183
+
184
+
And finally run the app:
185
+
186
+
```
187
+
yarn web
188
+
```
189
+
190
+
This takes a while on first load since its building everything.
191
+
After that, load the localhost url it gives you and it _should_ work.
192
+
193
+
## Selective Backfill
194
+
195
+
If you'd like to backfill a particular repo, just hit the following endpoint:
196
+
197
+
```
198
+
curl http://localhost:4444/rescan/<DID OR HANDLE>
199
+
200
+
```
201
+
202
+
It will take a minute but it should pull all records from that user.
203
+
204
+
## Upstream Firehose Configuration
205
+
206
+
Konbini supports both standard firehose endpoints as well as jetstream. If
207
+
bandwidth and CPU usage is a concern, and you trust the jetstream endpoint,
208
+
then it may be worth trying that out.
209
+
210
+
The configuration file is formatted as follows:
211
+
212
+
```json
213
+
{
214
+
"backends": [
215
+
{
216
+
"type": "jetstream",
217
+
"host": "jetstream1.us-west.bsky.network"
218
+
}
219
+
]
220
+
}
221
+
```
222
+
223
+
The default (implicit) configuration file looks like this:
224
+
225
+
```json
226
+
{
227
+
"backends": [
228
+
{
229
+
"type": "firehose",
230
+
"host": "bsky.network"
231
+
}
232
+
]
233
+
}
234
+
```
235
+
236
+
Note that this is an array of backends, you can specify multiple upstreams, and
237
+
konbini will read from all of them. The main intended purpose of this is to be
238
+
able to subscribe directly to PDSs. PDSs currently only support the full
239
+
firehose endpoint, not jetstream, so be sure to specify a type of "firehose"
240
+
for individual PDS endpoints.
241
+
112
242
## License
113
243
114
244
MIT (whyrusleeping)
245
+
246
+
```
247
+
248
+
```
+536
backend/backend.go
+536
backend/backend.go
···
1
+
package backend
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"strings"
8
+
"sync"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/bluesky-social/indigo/atproto/identity"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/bluesky-social/indigo/util"
16
+
"github.com/bluesky-social/indigo/xrpc"
17
+
lru "github.com/hashicorp/golang-lru/v2"
18
+
"github.com/jackc/pgx/v5"
19
+
"github.com/jackc/pgx/v5/pgconn"
20
+
"github.com/jackc/pgx/v5/pgxpool"
21
+
. "github.com/whyrusleeping/konbini/models"
22
+
"github.com/whyrusleeping/market/models"
23
+
"gorm.io/gorm"
24
+
"gorm.io/gorm/clause"
25
+
"gorm.io/gorm/logger"
26
+
)
27
+
28
+
// PostgresBackend handles database operations
29
+
type PostgresBackend struct {
30
+
db *gorm.DB
31
+
pgx *pgxpool.Pool
32
+
33
+
dir identity.Directory
34
+
35
+
client *xrpc.Client
36
+
37
+
mydid string
38
+
myrepo *models.Repo
39
+
40
+
relevantDids map[string]bool
41
+
rdLk sync.Mutex
42
+
43
+
revCache *lru.TwoQueueCache[uint, string]
44
+
45
+
repoCache *lru.TwoQueueCache[string, *Repo]
46
+
reposLk sync.Mutex
47
+
48
+
didByIDCache *lru.TwoQueueCache[uint, string]
49
+
50
+
postInfoCache *lru.TwoQueueCache[string, cachedPostInfo]
51
+
52
+
missingRecords chan MissingRecord
53
+
}
54
+
55
+
type cachedPostInfo struct {
56
+
ID uint
57
+
Author uint
58
+
}
59
+
60
+
// NewPostgresBackend creates a new PostgresBackend
61
+
func NewPostgresBackend(mydid string, db *gorm.DB, pgx *pgxpool.Pool, client *xrpc.Client, dir identity.Directory) (*PostgresBackend, error) {
62
+
rc, _ := lru.New2Q[string, *Repo](1_000_000)
63
+
pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000)
64
+
revc, _ := lru.New2Q[uint, string](1_000_000)
65
+
dbic, _ := lru.New2Q[uint, string](1_000_000)
66
+
67
+
b := &PostgresBackend{
68
+
client: client,
69
+
mydid: mydid,
70
+
db: db,
71
+
pgx: pgx,
72
+
relevantDids: make(map[string]bool),
73
+
repoCache: rc,
74
+
postInfoCache: pc,
75
+
revCache: revc,
76
+
didByIDCache: dbic,
77
+
dir: dir,
78
+
79
+
missingRecords: make(chan MissingRecord, 1000),
80
+
}
81
+
82
+
r, err := b.GetOrCreateRepo(context.TODO(), mydid)
83
+
if err != nil {
84
+
return nil, err
85
+
}
86
+
87
+
b.myrepo = r
88
+
89
+
go b.missingRecordFetcher()
90
+
return b, nil
91
+
}
92
+
93
+
// TrackMissingRecord implements the RecordTracker interface
94
+
func (b *PostgresBackend) TrackMissingRecord(identifier string, wait bool) {
95
+
mr := MissingRecord{
96
+
Type: mrTypeFromIdent(identifier),
97
+
Identifier: identifier,
98
+
Wait: wait,
99
+
}
100
+
101
+
b.addMissingRecord(context.TODO(), mr)
102
+
}
103
+
104
+
func mrTypeFromIdent(ident string) MissingRecordType {
105
+
if strings.HasPrefix(ident, "did:") {
106
+
return MissingRecordTypeProfile
107
+
}
108
+
109
+
puri, _ := syntax.ParseATURI(ident)
110
+
switch puri.Collection().String() {
111
+
case "app.bsky.feed.post":
112
+
return MissingRecordTypePost
113
+
case "app.bsky.feed.generator":
114
+
return MissingRecordTypeFeedGenerator
115
+
default:
116
+
return MissingRecordTypeUnknown
117
+
}
118
+
119
+
}
120
+
121
+
// DidToID converts a DID to a database ID
122
+
func (b *PostgresBackend) DidToID(ctx context.Context, did string) (uint, error) {
123
+
r, err := b.GetOrCreateRepo(ctx, did)
124
+
if err != nil {
125
+
return 0, err
126
+
}
127
+
return r.ID, nil
128
+
}
129
+
130
+
func (b *PostgresBackend) GetOrCreateRepo(ctx context.Context, did string) (*Repo, error) {
131
+
r, ok := b.repoCache.Get(did)
132
+
if !ok {
133
+
b.reposLk.Lock()
134
+
135
+
r, ok = b.repoCache.Get(did)
136
+
if !ok {
137
+
r = &Repo{}
138
+
r.Did = did
139
+
b.repoCache.Add(did, r)
140
+
}
141
+
142
+
b.reposLk.Unlock()
143
+
}
144
+
145
+
r.Lk.Lock()
146
+
defer r.Lk.Unlock()
147
+
if r.Setup {
148
+
return r, nil
149
+
}
150
+
151
+
row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did)
152
+
153
+
err := row.Scan(&r.ID, &r.CreatedAt, &r.Did)
154
+
if err == nil {
155
+
// found it!
156
+
r.Setup = true
157
+
return r, nil
158
+
}
159
+
160
+
if err != pgx.ErrNoRows {
161
+
return nil, err
162
+
}
163
+
164
+
r.Did = did
165
+
if err := b.db.Create(r).Error; err != nil {
166
+
return nil, err
167
+
}
168
+
169
+
r.Setup = true
170
+
171
+
return r, nil
172
+
}
173
+
174
+
func (b *PostgresBackend) GetOrCreateList(ctx context.Context, uri string) (*List, error) {
175
+
puri, err := util.ParseAtUri(uri)
176
+
if err != nil {
177
+
return nil, err
178
+
}
179
+
180
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
181
+
if err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
// TODO: needs upsert treatment when we actually find the list
186
+
var list List
187
+
if err := b.db.FirstOrCreate(&list, map[string]any{
188
+
"author": r.ID,
189
+
"rkey": puri.Rkey,
190
+
}).Error; err != nil {
191
+
return nil, err
192
+
}
193
+
return &list, nil
194
+
}
195
+
196
+
func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) {
197
+
// getPostByUri implicitly fills the cache
198
+
p, err := b.postInfoForUri(ctx, uri)
199
+
if err != nil {
200
+
return 0, err
201
+
}
202
+
203
+
return p.ID, nil
204
+
}
205
+
206
+
func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) {
207
+
v, ok := b.postInfoCache.Get(uri)
208
+
if ok {
209
+
return v, nil
210
+
}
211
+
212
+
// getPostByUri implicitly fills the cache
213
+
p, err := b.getOrCreatePostBare(ctx, uri)
214
+
if err != nil {
215
+
return cachedPostInfo{}, err
216
+
}
217
+
218
+
return cachedPostInfo{ID: p.ID, Author: p.Author}, nil
219
+
}
220
+
221
+
func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) {
222
+
var p Post
223
+
q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2"
224
+
if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil {
225
+
if errors.Is(err, pgx.ErrNoRows) {
226
+
return nil, nil
227
+
}
228
+
return nil, err
229
+
}
230
+
231
+
return &p, nil
232
+
}
233
+
234
+
func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) {
235
+
puri, err := util.ParseAtUri(uri)
236
+
if err != nil {
237
+
return nil, err
238
+
}
239
+
240
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
241
+
if err != nil {
242
+
return nil, err
243
+
}
244
+
245
+
post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
246
+
if err != nil {
247
+
return nil, err
248
+
}
249
+
250
+
if post == nil {
251
+
post = &Post{
252
+
Rkey: puri.Rkey,
253
+
Author: r.ID,
254
+
NotFound: true,
255
+
}
256
+
257
+
err := b.pgx.QueryRow(ctx, "INSERT INTO posts (rkey, author, not_found) VALUES ($1, $2, $3) RETURNING id", puri.Rkey, r.ID, true).Scan(&post.ID)
258
+
if err != nil {
259
+
pgErr, ok := err.(*pgconn.PgError)
260
+
if !ok || pgErr.Code != "23505" {
261
+
return nil, err
262
+
}
263
+
264
+
out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
265
+
if err != nil {
266
+
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
267
+
}
268
+
if out == nil {
269
+
return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey)
270
+
}
271
+
272
+
post = out
273
+
}
274
+
275
+
}
276
+
277
+
b.postInfoCache.Add(uri, cachedPostInfo{
278
+
ID: post.ID,
279
+
Author: post.Author,
280
+
})
281
+
282
+
return post, nil
283
+
}
284
+
285
+
func (b *PostgresBackend) GetPostByUri(ctx context.Context, uri string, fields string) (*Post, error) {
286
+
puri, err := util.ParseAtUri(uri)
287
+
if err != nil {
288
+
return nil, err
289
+
}
290
+
291
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
292
+
if err != nil {
293
+
return nil, err
294
+
}
295
+
296
+
q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?"
297
+
298
+
var post Post
299
+
if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil {
300
+
return nil, err
301
+
}
302
+
303
+
if post.ID == 0 {
304
+
post.Rkey = puri.Rkey
305
+
post.Author = r.ID
306
+
post.NotFound = true
307
+
308
+
if err := b.db.Session(&gorm.Session{
309
+
Logger: logger.Default.LogMode(logger.Silent),
310
+
}).Create(&post).Error; err != nil {
311
+
if !errors.Is(err, gorm.ErrDuplicatedKey) {
312
+
return nil, err
313
+
}
314
+
if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil {
315
+
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
316
+
}
317
+
}
318
+
319
+
}
320
+
321
+
b.postInfoCache.Add(uri, cachedPostInfo{
322
+
ID: post.ID,
323
+
Author: post.Author,
324
+
})
325
+
326
+
return &post, nil
327
+
}
328
+
329
+
func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) {
330
+
lrev, ok := b.revCache.Get(rr.ID)
331
+
if ok {
332
+
return lrev, nil
333
+
}
334
+
335
+
var rev string
336
+
if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil {
337
+
if errors.Is(err, pgx.ErrNoRows) {
338
+
return "", nil
339
+
}
340
+
return "", err
341
+
}
342
+
343
+
if rev != "" {
344
+
b.revCache.Add(rr.ID, rev)
345
+
}
346
+
return rev, nil
347
+
}
348
+
349
+
func (b *PostgresBackend) AddRelevantDid(did string) {
350
+
b.rdLk.Lock()
351
+
defer b.rdLk.Unlock()
352
+
b.relevantDids[did] = true
353
+
}
354
+
355
+
func (b *PostgresBackend) DidIsRelevant(did string) bool {
356
+
b.rdLk.Lock()
357
+
defer b.rdLk.Unlock()
358
+
return b.relevantDids[did]
359
+
}
360
+
361
+
func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool {
362
+
for _, id := range idents {
363
+
if strings.HasPrefix(id, "did:") {
364
+
if b.DidIsRelevant(id) {
365
+
return true
366
+
}
367
+
} else if strings.HasPrefix(id, "at://") {
368
+
puri, err := syntax.ParseATURI(id)
369
+
if err != nil {
370
+
continue
371
+
}
372
+
373
+
if b.DidIsRelevant(puri.Authority().String()) {
374
+
return true
375
+
}
376
+
}
377
+
}
378
+
379
+
return false
380
+
}
381
+
382
+
func (b *PostgresBackend) GetRelevantDids() []string {
383
+
b.rdLk.Lock()
384
+
var out []string
385
+
for k := range b.relevantDids {
386
+
out = append(out, k)
387
+
}
388
+
b.rdLk.Unlock()
389
+
return out
390
+
}
391
+
392
+
func (b *PostgresBackend) GetRepoByID(ctx context.Context, id uint) (*models.Repo, error) {
393
+
var r models.Repo
394
+
if err := b.db.Find(&r, "id = ?", id).Error; err != nil {
395
+
return nil, err
396
+
}
397
+
398
+
return &r, nil
399
+
}
400
+
401
+
func (b *PostgresBackend) DidFromID(ctx context.Context, uid uint) (string, error) {
402
+
val, ok := b.didByIDCache.Get(uid)
403
+
if ok {
404
+
return val, nil
405
+
}
406
+
407
+
r, err := b.GetRepoByID(ctx, uid)
408
+
if err != nil {
409
+
return "", err
410
+
}
411
+
412
+
b.didByIDCache.Add(uid, r.Did)
413
+
return r.Did, nil
414
+
}
415
+
416
+
func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) {
417
+
var id uint
418
+
var notfound bool
419
+
if err := b.pgx.QueryRow(ctx, "SELECT id, not_found FROM posts WHERE author = $1 AND rkey = $2", repo.ID, rkey).Scan(&id, ¬found); err != nil {
420
+
if errors.Is(err, pgx.ErrNoRows) {
421
+
return false, nil
422
+
}
423
+
return false, err
424
+
}
425
+
426
+
if id != 0 && !notfound {
427
+
return true, nil
428
+
}
429
+
430
+
return false, nil
431
+
}
432
+
433
+
func (b *PostgresBackend) LoadRelevantDids() error {
434
+
ctx := context.TODO()
435
+
436
+
if err := b.ensureFollowsScraped(ctx, b.mydid); err != nil {
437
+
return fmt.Errorf("failed to scrape follows: %w", err)
438
+
}
439
+
440
+
r, err := b.GetOrCreateRepo(ctx, b.mydid)
441
+
if err != nil {
442
+
return err
443
+
}
444
+
445
+
var dids []string
446
+
if err := b.db.Raw("select did from follows left join repos on follows.subject = repos.id where follows.author = ?", r.ID).Scan(&dids).Error; err != nil {
447
+
return err
448
+
}
449
+
450
+
b.relevantDids[b.mydid] = true
451
+
for _, d := range dids {
452
+
fmt.Println("adding did: ", d)
453
+
b.relevantDids[d] = true
454
+
}
455
+
456
+
return nil
457
+
}
458
+
459
+
type SyncInfo struct {
460
+
Repo uint `gorm:"index"`
461
+
FollowsSynced bool
462
+
Rev string
463
+
}
464
+
465
+
func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error {
466
+
r, err := b.GetOrCreateRepo(ctx, user)
467
+
if err != nil {
468
+
return err
469
+
}
470
+
471
+
var si SyncInfo
472
+
if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil {
473
+
return err
474
+
}
475
+
476
+
// not found
477
+
if si.Repo == 0 {
478
+
if err := b.db.Create(&SyncInfo{
479
+
Repo: r.ID,
480
+
}).Error; err != nil {
481
+
return err
482
+
}
483
+
}
484
+
485
+
if si.FollowsSynced {
486
+
return nil
487
+
}
488
+
489
+
var follows []Follow
490
+
var cursor string
491
+
for {
492
+
resp, err := atproto.RepoListRecords(ctx, b.client, "app.bsky.graph.follow", cursor, 100, b.mydid, false)
493
+
if err != nil {
494
+
return err
495
+
}
496
+
497
+
for _, rec := range resp.Records {
498
+
if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok {
499
+
fr, err := b.GetOrCreateRepo(ctx, fol.Subject)
500
+
if err != nil {
501
+
return err
502
+
}
503
+
504
+
puri, err := syntax.ParseATURI(rec.Uri)
505
+
if err != nil {
506
+
return err
507
+
}
508
+
509
+
follows = append(follows, Follow{
510
+
Created: time.Now(),
511
+
Indexed: time.Now(),
512
+
Rkey: puri.RecordKey().String(),
513
+
Author: r.ID,
514
+
Subject: fr.ID,
515
+
})
516
+
}
517
+
}
518
+
519
+
if resp.Cursor == nil || len(resp.Records) == 0 {
520
+
break
521
+
}
522
+
cursor = *resp.Cursor
523
+
}
524
+
525
+
if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil {
526
+
return err
527
+
}
528
+
529
+
if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil {
530
+
return err
531
+
}
532
+
533
+
fmt.Println("Got follows: ", len(follows))
534
+
535
+
return nil
536
+
}
+1208
backend/events.go
+1208
backend/events.go
···
1
+
package backend
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log/slog"
9
+
"strings"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/api/bsky"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
lexutil "github.com/bluesky-social/indigo/lex/util"
16
+
"github.com/bluesky-social/indigo/repo"
17
+
jsmodels "github.com/bluesky-social/jetstream/pkg/models"
18
+
"github.com/ipfs/go-cid"
19
+
"github.com/jackc/pgx/v5/pgconn"
20
+
"github.com/prometheus/client_golang/prometheus"
21
+
"github.com/prometheus/client_golang/prometheus/promauto"
22
+
23
+
. "github.com/whyrusleeping/konbini/models"
24
+
)
25
+
26
+
var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{
27
+
Name: "handle_op_duration",
28
+
Help: "A histogram of op handling durations",
29
+
Buckets: prometheus.ExponentialBuckets(1, 2, 15),
30
+
}, []string{"op", "collection"})
31
+
32
+
func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error {
33
+
r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks))
34
+
if err != nil {
35
+
return fmt.Errorf("failed to read event repo: %w", err)
36
+
}
37
+
38
+
for _, op := range evt.Ops {
39
+
switch op.Action {
40
+
case "create":
41
+
c, rec, err := r.GetRecordBytes(ctx, op.Path)
42
+
if err != nil {
43
+
return err
44
+
}
45
+
if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
46
+
return fmt.Errorf("create record failed: %w", err)
47
+
}
48
+
case "update":
49
+
c, rec, err := r.GetRecordBytes(ctx, op.Path)
50
+
if err != nil {
51
+
return err
52
+
}
53
+
if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
54
+
return fmt.Errorf("update record failed: %w", err)
55
+
}
56
+
case "delete":
57
+
if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil {
58
+
return fmt.Errorf("delete record failed: %w", err)
59
+
}
60
+
}
61
+
}
62
+
63
+
// TODO: sync with the Since field to make sure we don't miss events we care about
64
+
/*
65
+
if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil {
66
+
return fmt.Errorf("failed to update rev: %w", err)
67
+
}
68
+
*/
69
+
70
+
return nil
71
+
}
72
+
73
+
func cborBytesFromEvent(evt *jsmodels.Event) ([]byte, error) {
74
+
val, err := lexutil.NewFromType(evt.Commit.Collection)
75
+
if err != nil {
76
+
return nil, fmt.Errorf("failed to load event record type: %w", err)
77
+
}
78
+
79
+
if err := json.Unmarshal(evt.Commit.Record, val); err != nil {
80
+
return nil, err
81
+
}
82
+
83
+
cval, ok := val.(lexutil.CBOR)
84
+
if !ok {
85
+
return nil, fmt.Errorf("decoded type was not cbor marshalable")
86
+
}
87
+
88
+
buf := new(bytes.Buffer)
89
+
if err := cval.MarshalCBOR(buf); err != nil {
90
+
return nil, fmt.Errorf("failed to marshal event to cbor: %w", err)
91
+
}
92
+
93
+
rec := buf.Bytes()
94
+
return rec, nil
95
+
}
96
+
97
+
func (b *PostgresBackend) HandleEventJetstream(ctx context.Context, evt *jsmodels.Event) error {
98
+
99
+
path := evt.Commit.Collection + "/" + evt.Commit.RKey
100
+
switch evt.Commit.Operation {
101
+
case jsmodels.CommitOperationCreate:
102
+
rec, err := cborBytesFromEvent(evt)
103
+
if err != nil {
104
+
return err
105
+
}
106
+
107
+
c, err := cid.Decode(evt.Commit.CID)
108
+
if err != nil {
109
+
return err
110
+
}
111
+
112
+
if err := b.HandleCreate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil {
113
+
return fmt.Errorf("create record failed: %w", err)
114
+
}
115
+
case jsmodels.CommitOperationUpdate:
116
+
rec, err := cborBytesFromEvent(evt)
117
+
if err != nil {
118
+
return err
119
+
}
120
+
121
+
c, err := cid.Decode(evt.Commit.CID)
122
+
if err != nil {
123
+
return err
124
+
}
125
+
126
+
if err := b.HandleUpdate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil {
127
+
return fmt.Errorf("update record failed: %w", err)
128
+
}
129
+
case jsmodels.CommitOperationDelete:
130
+
if err := b.HandleDelete(ctx, evt.Did, evt.Commit.Rev, path); err != nil {
131
+
return fmt.Errorf("delete record failed: %w", err)
132
+
}
133
+
}
134
+
135
+
return nil
136
+
}
137
+
138
+
func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
139
+
start := time.Now()
140
+
141
+
rr, err := b.GetOrCreateRepo(ctx, repo)
142
+
if err != nil {
143
+
return fmt.Errorf("get user failed: %w", err)
144
+
}
145
+
146
+
lrev, err := b.revForRepo(rr)
147
+
if err != nil {
148
+
return err
149
+
}
150
+
if lrev != "" {
151
+
if rev < lrev {
152
+
slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
153
+
return nil
154
+
}
155
+
}
156
+
157
+
parts := strings.Split(path, "/")
158
+
if len(parts) != 2 {
159
+
return fmt.Errorf("invalid path in HandleCreate: %q", path)
160
+
}
161
+
col := parts[0]
162
+
rkey := parts[1]
163
+
164
+
defer func() {
165
+
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
166
+
}()
167
+
168
+
if rkey == "" {
169
+
fmt.Printf("messed up path: %q\n", rkey)
170
+
}
171
+
172
+
switch col {
173
+
case "app.bsky.feed.post":
174
+
if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
175
+
return err
176
+
}
177
+
case "app.bsky.feed.like":
178
+
if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
179
+
return err
180
+
}
181
+
case "app.bsky.feed.repost":
182
+
if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
183
+
return err
184
+
}
185
+
case "app.bsky.graph.follow":
186
+
if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
187
+
return err
188
+
}
189
+
case "app.bsky.graph.block":
190
+
if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
191
+
return err
192
+
}
193
+
case "app.bsky.graph.list":
194
+
if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
195
+
return err
196
+
}
197
+
case "app.bsky.graph.listitem":
198
+
if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
199
+
return err
200
+
}
201
+
case "app.bsky.graph.listblock":
202
+
if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
203
+
return err
204
+
}
205
+
case "app.bsky.actor.profile":
206
+
if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
207
+
return err
208
+
}
209
+
case "app.bsky.feed.generator":
210
+
if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
211
+
return err
212
+
}
213
+
case "app.bsky.feed.threadgate":
214
+
if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
215
+
return err
216
+
}
217
+
case "chat.bsky.actor.declaration":
218
+
if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
219
+
return err
220
+
}
221
+
case "app.bsky.feed.postgate":
222
+
if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil {
223
+
return err
224
+
}
225
+
case "app.bsky.graph.starterpack":
226
+
if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil {
227
+
return err
228
+
}
229
+
default:
230
+
slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev)
231
+
}
232
+
233
+
b.revCache.Add(rr.ID, rev)
234
+
return nil
235
+
}
236
+
237
+
func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
238
+
exists, err := b.checkPostExists(ctx, repo, rkey)
239
+
if err != nil {
240
+
return err
241
+
}
242
+
243
+
// still technically a race condition if two creates for the same post happen concurrently... probably fine
244
+
if exists {
245
+
return nil
246
+
}
247
+
248
+
var rec bsky.FeedPost
249
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
250
+
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
251
+
slog.Warn("skipping post with malformed data", "uri", uri, "error", err)
252
+
return nil // Skip this post rather than failing the entire event
253
+
}
254
+
255
+
reldids := []string{repo.Did}
256
+
// care about a post if its in a thread of a user we are interested in
257
+
if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
258
+
reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri)
259
+
}
260
+
// TODO: maybe also care if its mentioning a user we care about or quoting a user we care about?
261
+
if !b.anyRelevantIdents(reldids...) {
262
+
return nil
263
+
}
264
+
265
+
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
266
+
slog.Warn("adding post", "uri", uri)
267
+
268
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
269
+
if err != nil {
270
+
return fmt.Errorf("invalid timestamp: %w", err)
271
+
}
272
+
273
+
p := Post{
274
+
Created: created.Time(),
275
+
Indexed: time.Now(),
276
+
Author: repo.ID,
277
+
Rkey: rkey,
278
+
Raw: recb,
279
+
Cid: cc.String(),
280
+
}
281
+
282
+
if rec.Reply != nil && rec.Reply.Parent != nil {
283
+
if rec.Reply.Root == nil {
284
+
return fmt.Errorf("post reply had nil root")
285
+
}
286
+
287
+
pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri)
288
+
if err != nil {
289
+
return fmt.Errorf("getting reply parent: %w", err)
290
+
}
291
+
292
+
p.ReplyTo = pinfo.ID
293
+
p.ReplyToUsr = pinfo.Author
294
+
295
+
thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri)
296
+
if err != nil {
297
+
return fmt.Errorf("getting thread root: %w", err)
298
+
}
299
+
300
+
p.InThread = thread
301
+
302
+
r, err := b.GetOrCreateRepo(ctx, b.mydid)
303
+
if err != nil {
304
+
return err
305
+
}
306
+
307
+
if p.ReplyToUsr == r.ID {
308
+
if err := b.AddNotification(ctx, r.ID, p.Author, uri, cc, NotifKindReply); err != nil {
309
+
slog.Warn("failed to create notification", "uri", uri, "error", err)
310
+
}
311
+
}
312
+
}
313
+
314
+
if rec.Embed != nil {
315
+
var rpref string
316
+
if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil {
317
+
rpref = rec.Embed.EmbedRecord.Record.Uri
318
+
}
319
+
if rec.Embed.EmbedRecordWithMedia != nil &&
320
+
rec.Embed.EmbedRecordWithMedia.Record != nil &&
321
+
rec.Embed.EmbedRecordWithMedia.Record.Record != nil {
322
+
rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri
323
+
}
324
+
325
+
if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") {
326
+
rp, err := b.postIDForUri(ctx, rpref)
327
+
if err != nil {
328
+
return fmt.Errorf("getting quote subject: %w", err)
329
+
}
330
+
331
+
p.Reposting = rp
332
+
}
333
+
}
334
+
335
+
if err := b.doPostCreate(ctx, &p); err != nil {
336
+
return err
337
+
}
338
+
339
+
// Check for mentions and create notifications
340
+
if rec.Facets != nil {
341
+
for _, facet := range rec.Facets {
342
+
for _, feature := range facet.Features {
343
+
if feature.RichtextFacet_Mention != nil {
344
+
mentionDid := feature.RichtextFacet_Mention.Did
345
+
// This is a mention
346
+
mentionedRepo, err := b.GetOrCreateRepo(ctx, mentionDid)
347
+
if err != nil {
348
+
slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err)
349
+
continue
350
+
}
351
+
352
+
// Create notification if the mentioned user is the current user
353
+
if mentionedRepo.ID == b.myrepo.ID {
354
+
if err := b.AddNotification(ctx, b.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil {
355
+
slog.Warn("failed to create mention notification", "uri", uri, "error", err)
356
+
}
357
+
}
358
+
}
359
+
}
360
+
}
361
+
}
362
+
363
+
b.postInfoCache.Add(uri, cachedPostInfo{
364
+
ID: p.ID,
365
+
Author: p.Author,
366
+
})
367
+
368
+
return nil
369
+
}
370
+
371
+
func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error {
372
+
/*
373
+
if err := b.db.Clauses(clause.OnConflict{
374
+
Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}},
375
+
DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}),
376
+
}).Create(p).Error; err != nil {
377
+
return err
378
+
}
379
+
*/
380
+
381
+
query := `
382
+
INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread)
383
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
384
+
ON CONFLICT (author, rkey)
385
+
DO UPDATE SET
386
+
cid = $3,
387
+
not_found = $4,
388
+
raw = $5,
389
+
created = $6,
390
+
indexed = $7,
391
+
reposting = $8,
392
+
reply_to = $9,
393
+
reply_to_usr = $10,
394
+
in_thread = $11
395
+
RETURNING id
396
+
`
397
+
398
+
// Execute the query with parameters from the Post struct
399
+
if err := b.pgx.QueryRow(
400
+
ctx,
401
+
query,
402
+
p.Author,
403
+
p.Rkey,
404
+
p.Cid,
405
+
p.NotFound,
406
+
p.Raw,
407
+
p.Created,
408
+
p.Indexed,
409
+
p.Reposting,
410
+
p.ReplyTo,
411
+
p.ReplyToUsr,
412
+
p.InThread,
413
+
).Scan(&p.ID); err != nil {
414
+
return err
415
+
}
416
+
417
+
return nil
418
+
}
419
+
420
+
func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
421
+
var rec bsky.FeedLike
422
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
423
+
return err
424
+
}
425
+
426
+
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
427
+
return nil
428
+
}
429
+
430
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
431
+
if err != nil {
432
+
return fmt.Errorf("invalid timestamp: %w", err)
433
+
}
434
+
435
+
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
436
+
if err != nil {
437
+
return fmt.Errorf("getting like subject: %w", err)
438
+
}
439
+
440
+
if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID, cc.String()); err != nil {
441
+
pgErr, ok := err.(*pgconn.PgError)
442
+
if ok && pgErr.Code == "23505" {
443
+
return nil
444
+
}
445
+
return err
446
+
}
447
+
448
+
// Create notification if the liked post belongs to the current user
449
+
if pinfo.Author == b.myrepo.ID {
450
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey)
451
+
if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil {
452
+
slog.Warn("failed to create like notification", "uri", uri, "error", err)
453
+
}
454
+
}
455
+
456
+
return nil
457
+
}
458
+
459
+
func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
460
+
var rec bsky.FeedRepost
461
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
462
+
return err
463
+
}
464
+
465
+
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
466
+
return nil
467
+
}
468
+
469
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
470
+
if err != nil {
471
+
return fmt.Errorf("invalid timestamp: %w", err)
472
+
}
473
+
474
+
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
475
+
if err != nil {
476
+
return fmt.Errorf("getting repost subject: %w", err)
477
+
}
478
+
479
+
if _, err := b.pgx.Exec(ctx, `INSERT INTO "reposts" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID); err != nil {
480
+
pgErr, ok := err.(*pgconn.PgError)
481
+
if ok && pgErr.Code == "23505" {
482
+
return nil
483
+
}
484
+
return err
485
+
}
486
+
487
+
// Create notification if the reposted post belongs to the current user
488
+
if pinfo.Author == b.myrepo.ID {
489
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey)
490
+
if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil {
491
+
slog.Warn("failed to create repost notification", "uri", uri, "error", err)
492
+
}
493
+
}
494
+
495
+
return nil
496
+
}
497
+
498
+
func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
499
+
var rec bsky.GraphFollow
500
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
501
+
return err
502
+
}
503
+
504
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
505
+
return nil
506
+
}
507
+
508
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
509
+
if err != nil {
510
+
return fmt.Errorf("invalid timestamp: %w", err)
511
+
}
512
+
513
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
514
+
if err != nil {
515
+
return err
516
+
}
517
+
518
+
if _, err := b.pgx.Exec(ctx, "INSERT INTO follows (created, indexed, author, rkey, subject) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", created.Time(), time.Now(), repo.ID, rkey, subj.ID); err != nil {
519
+
return err
520
+
}
521
+
522
+
return nil
523
+
}
524
+
525
+
func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
526
+
var rec bsky.GraphBlock
527
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
528
+
return err
529
+
}
530
+
531
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
532
+
return nil
533
+
}
534
+
535
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
536
+
if err != nil {
537
+
return fmt.Errorf("invalid timestamp: %w", err)
538
+
}
539
+
540
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
541
+
if err != nil {
542
+
return err
543
+
}
544
+
545
+
if err := b.db.Create(&Block{
546
+
Created: created.Time(),
547
+
Indexed: time.Now(),
548
+
Author: repo.ID,
549
+
Rkey: rkey,
550
+
Subject: subj.ID,
551
+
}).Error; err != nil {
552
+
return err
553
+
}
554
+
555
+
return nil
556
+
}
557
+
558
+
func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
559
+
var rec bsky.GraphList
560
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
561
+
return err
562
+
}
563
+
564
+
if !b.anyRelevantIdents(repo.Did) {
565
+
return nil
566
+
}
567
+
568
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
569
+
if err != nil {
570
+
return fmt.Errorf("invalid timestamp: %w", err)
571
+
}
572
+
573
+
if err := b.db.Create(&List{
574
+
Created: created.Time(),
575
+
Indexed: time.Now(),
576
+
Author: repo.ID,
577
+
Rkey: rkey,
578
+
Raw: recb,
579
+
}).Error; err != nil {
580
+
return err
581
+
}
582
+
583
+
return nil
584
+
}
585
+
586
+
func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
587
+
var rec bsky.GraphListitem
588
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
589
+
return err
590
+
}
591
+
if !b.anyRelevantIdents(repo.Did) {
592
+
return nil
593
+
}
594
+
595
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
596
+
if err != nil {
597
+
return fmt.Errorf("invalid timestamp: %w", err)
598
+
}
599
+
600
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
601
+
if err != nil {
602
+
return err
603
+
}
604
+
605
+
list, err := b.GetOrCreateList(ctx, rec.List)
606
+
if err != nil {
607
+
return err
608
+
}
609
+
610
+
if err := b.db.Create(&ListItem{
611
+
Created: created.Time(),
612
+
Indexed: time.Now(),
613
+
Author: repo.ID,
614
+
Rkey: rkey,
615
+
Subject: subj.ID,
616
+
List: list.ID,
617
+
}).Error; err != nil {
618
+
return err
619
+
}
620
+
621
+
return nil
622
+
}
623
+
624
+
func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
625
+
var rec bsky.GraphListblock
626
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
627
+
return err
628
+
}
629
+
630
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
631
+
return nil
632
+
}
633
+
634
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
635
+
if err != nil {
636
+
return fmt.Errorf("invalid timestamp: %w", err)
637
+
}
638
+
639
+
list, err := b.GetOrCreateList(ctx, rec.Subject)
640
+
if err != nil {
641
+
return err
642
+
}
643
+
644
+
if err := b.db.Create(&ListBlock{
645
+
Created: created.Time(),
646
+
Indexed: time.Now(),
647
+
Author: repo.ID,
648
+
Rkey: rkey,
649
+
List: list.ID,
650
+
}).Error; err != nil {
651
+
return err
652
+
}
653
+
654
+
return nil
655
+
}
656
+
657
+
func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
658
+
if !b.anyRelevantIdents(repo.Did) {
659
+
return nil
660
+
}
661
+
662
+
if err := b.db.Create(&Profile{
663
+
//Created: created.Time(),
664
+
Indexed: time.Now(),
665
+
Repo: repo.ID,
666
+
Raw: recb,
667
+
Rev: rev,
668
+
}).Error; err != nil {
669
+
return err
670
+
}
671
+
672
+
return nil
673
+
}
674
+
675
+
func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
676
+
if !b.anyRelevantIdents(repo.Did) {
677
+
return nil
678
+
}
679
+
680
+
if err := b.db.Create(&Profile{
681
+
Indexed: time.Now(),
682
+
Repo: repo.ID,
683
+
Raw: recb,
684
+
Rev: rev,
685
+
}).Error; err != nil {
686
+
return err
687
+
}
688
+
689
+
return nil
690
+
}
691
+
692
+
func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
693
+
if !b.anyRelevantIdents(repo.Did) {
694
+
return nil
695
+
}
696
+
697
+
var rec bsky.FeedGenerator
698
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
699
+
return err
700
+
}
701
+
702
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
703
+
if err != nil {
704
+
return fmt.Errorf("invalid timestamp: %w", err)
705
+
}
706
+
707
+
if err := b.db.Create(&FeedGenerator{
708
+
Created: created.Time(),
709
+
Indexed: time.Now(),
710
+
Author: repo.ID,
711
+
Rkey: rkey,
712
+
Did: rec.Did,
713
+
Raw: recb,
714
+
}).Error; err != nil {
715
+
return err
716
+
}
717
+
718
+
return nil
719
+
}
720
+
721
+
func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
722
+
if !b.anyRelevantIdents(repo.Did) {
723
+
return nil
724
+
}
725
+
var rec bsky.FeedThreadgate
726
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
727
+
return err
728
+
}
729
+
730
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
731
+
if err != nil {
732
+
return fmt.Errorf("invalid timestamp: %w", err)
733
+
}
734
+
735
+
pid, err := b.postIDForUri(ctx, rec.Post)
736
+
if err != nil {
737
+
return err
738
+
}
739
+
740
+
if err := b.db.Create(&ThreadGate{
741
+
Created: created.Time(),
742
+
Indexed: time.Now(),
743
+
Author: repo.ID,
744
+
Rkey: rkey,
745
+
Post: pid,
746
+
}).Error; err != nil {
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
}
752
+
753
+
func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
754
+
// TODO: maybe track these?
755
+
return nil
756
+
}
757
+
758
+
func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
759
+
if !b.anyRelevantIdents(repo.Did) {
760
+
return nil
761
+
}
762
+
var rec bsky.FeedPostgate
763
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
764
+
return err
765
+
}
766
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
767
+
if err != nil {
768
+
return fmt.Errorf("invalid timestamp: %w", err)
769
+
}
770
+
771
+
refPost, err := b.postInfoForUri(ctx, rec.Post)
772
+
if err != nil {
773
+
return err
774
+
}
775
+
776
+
if err := b.db.Create(&PostGate{
777
+
Created: created.Time(),
778
+
Indexed: time.Now(),
779
+
Author: repo.ID,
780
+
Rkey: rkey,
781
+
Subject: refPost.ID,
782
+
Raw: recb,
783
+
}).Error; err != nil {
784
+
return err
785
+
}
786
+
787
+
return nil
788
+
}
789
+
790
+
func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
791
+
if !b.anyRelevantIdents(repo.Did) {
792
+
return nil
793
+
}
794
+
var rec bsky.GraphStarterpack
795
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
796
+
return err
797
+
}
798
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
799
+
if err != nil {
800
+
return fmt.Errorf("invalid timestamp: %w", err)
801
+
}
802
+
803
+
list, err := b.GetOrCreateList(ctx, rec.List)
804
+
if err != nil {
805
+
return err
806
+
}
807
+
808
+
if err := b.db.Create(&StarterPack{
809
+
Created: created.Time(),
810
+
Indexed: time.Now(),
811
+
Author: repo.ID,
812
+
Rkey: rkey,
813
+
Raw: recb,
814
+
List: list.ID,
815
+
}).Error; err != nil {
816
+
return err
817
+
}
818
+
819
+
return nil
820
+
}
821
+
822
+
func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
823
+
start := time.Now()
824
+
825
+
rr, err := b.GetOrCreateRepo(ctx, repo)
826
+
if err != nil {
827
+
return fmt.Errorf("get user failed: %w", err)
828
+
}
829
+
830
+
lrev, err := b.revForRepo(rr)
831
+
if err != nil {
832
+
return err
833
+
}
834
+
if lrev != "" {
835
+
if rev < lrev {
836
+
//slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
837
+
return nil
838
+
}
839
+
}
840
+
841
+
parts := strings.Split(path, "/")
842
+
if len(parts) != 2 {
843
+
return fmt.Errorf("invalid path in HandleCreate: %q", path)
844
+
}
845
+
col := parts[0]
846
+
rkey := parts[1]
847
+
848
+
defer func() {
849
+
handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds()))
850
+
}()
851
+
852
+
if rkey == "" {
853
+
fmt.Printf("messed up path: %q\n", rkey)
854
+
}
855
+
856
+
switch col {
857
+
/*
858
+
case "app.bsky.feed.post":
859
+
if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
860
+
return err
861
+
}
862
+
case "app.bsky.feed.like":
863
+
if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
864
+
return err
865
+
}
866
+
case "app.bsky.feed.repost":
867
+
if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
868
+
return err
869
+
}
870
+
case "app.bsky.graph.follow":
871
+
if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
872
+
return err
873
+
}
874
+
case "app.bsky.graph.block":
875
+
if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
876
+
return err
877
+
}
878
+
case "app.bsky.graph.list":
879
+
if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
880
+
return err
881
+
}
882
+
case "app.bsky.graph.listitem":
883
+
if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
884
+
return err
885
+
}
886
+
case "app.bsky.graph.listblock":
887
+
if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
888
+
return err
889
+
}
890
+
*/
891
+
case "app.bsky.actor.profile":
892
+
if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
893
+
return err
894
+
}
895
+
/*
896
+
case "app.bsky.feed.generator":
897
+
if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
898
+
return err
899
+
}
900
+
case "app.bsky.feed.threadgate":
901
+
if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
902
+
return err
903
+
}
904
+
case "chat.bsky.actor.declaration":
905
+
if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
906
+
return err
907
+
}
908
+
*/
909
+
default:
910
+
slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev)
911
+
}
912
+
913
+
return nil
914
+
}
915
+
916
+
func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error {
917
+
start := time.Now()
918
+
919
+
rr, err := b.GetOrCreateRepo(ctx, repo)
920
+
if err != nil {
921
+
return fmt.Errorf("get user failed: %w", err)
922
+
}
923
+
924
+
lrev, ok := b.revCache.Get(rr.ID)
925
+
if ok {
926
+
if rev < lrev {
927
+
//slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev)
928
+
return nil
929
+
}
930
+
}
931
+
932
+
parts := strings.Split(path, "/")
933
+
if len(parts) != 2 {
934
+
return fmt.Errorf("invalid path in HandleDelete: %q", path)
935
+
}
936
+
col := parts[0]
937
+
rkey := parts[1]
938
+
939
+
defer func() {
940
+
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
941
+
}()
942
+
943
+
switch col {
944
+
case "app.bsky.feed.post":
945
+
if err := b.HandleDeletePost(ctx, rr, rkey); err != nil {
946
+
return err
947
+
}
948
+
case "app.bsky.feed.like":
949
+
if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil {
950
+
return err
951
+
}
952
+
case "app.bsky.feed.repost":
953
+
if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil {
954
+
return err
955
+
}
956
+
case "app.bsky.graph.follow":
957
+
if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil {
958
+
return err
959
+
}
960
+
case "app.bsky.graph.block":
961
+
if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil {
962
+
return err
963
+
}
964
+
case "app.bsky.graph.list":
965
+
if err := b.HandleDeleteList(ctx, rr, rkey); err != nil {
966
+
return err
967
+
}
968
+
case "app.bsky.graph.listitem":
969
+
if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil {
970
+
return err
971
+
}
972
+
case "app.bsky.graph.listblock":
973
+
if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil {
974
+
return err
975
+
}
976
+
case "app.bsky.actor.profile":
977
+
if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil {
978
+
return err
979
+
}
980
+
case "app.bsky.feed.generator":
981
+
if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil {
982
+
return err
983
+
}
984
+
case "app.bsky.feed.threadgate":
985
+
if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil {
986
+
return err
987
+
}
988
+
default:
989
+
slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev)
990
+
}
991
+
992
+
b.revCache.Add(rr.ID, rev)
993
+
return nil
994
+
}
995
+
996
+
func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error {
997
+
var p Post
998
+
if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
999
+
return err
1000
+
}
1001
+
1002
+
if p.ID == 0 {
1003
+
//slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey)
1004
+
return nil
1005
+
}
1006
+
1007
+
if err := b.db.Delete(&Post{}, p.ID).Error; err != nil {
1008
+
return err
1009
+
}
1010
+
1011
+
return nil
1012
+
}
1013
+
1014
+
func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error {
1015
+
var like Like
1016
+
if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1017
+
return err
1018
+
}
1019
+
1020
+
if like.ID == 0 {
1021
+
//slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey)
1022
+
return nil
1023
+
}
1024
+
1025
+
if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil {
1026
+
return err
1027
+
}
1028
+
1029
+
return nil
1030
+
}
1031
+
1032
+
func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error {
1033
+
var repost Repost
1034
+
if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1035
+
return err
1036
+
}
1037
+
1038
+
if repost.ID == 0 {
1039
+
//return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey)
1040
+
return nil
1041
+
}
1042
+
1043
+
if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil {
1044
+
return err
1045
+
}
1046
+
1047
+
return nil
1048
+
}
1049
+
1050
+
func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error {
1051
+
var follow Follow
1052
+
if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1053
+
return err
1054
+
}
1055
+
1056
+
if follow.ID == 0 {
1057
+
//slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey)
1058
+
return nil
1059
+
}
1060
+
1061
+
if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil {
1062
+
return err
1063
+
}
1064
+
1065
+
return nil
1066
+
}
1067
+
1068
+
func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error {
1069
+
var block Block
1070
+
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1071
+
return err
1072
+
}
1073
+
1074
+
if block.ID == 0 {
1075
+
//slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey)
1076
+
return nil
1077
+
}
1078
+
1079
+
if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil {
1080
+
return err
1081
+
}
1082
+
1083
+
return nil
1084
+
}
1085
+
1086
+
func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error {
1087
+
var list List
1088
+
if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1089
+
return err
1090
+
}
1091
+
1092
+
if list.ID == 0 {
1093
+
return nil
1094
+
//return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey)
1095
+
}
1096
+
1097
+
if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil {
1098
+
return err
1099
+
}
1100
+
1101
+
return nil
1102
+
}
1103
+
1104
+
func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error {
1105
+
var item ListItem
1106
+
if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1107
+
return err
1108
+
}
1109
+
1110
+
if item.ID == 0 {
1111
+
return nil
1112
+
//return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey)
1113
+
}
1114
+
1115
+
if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil {
1116
+
return err
1117
+
}
1118
+
1119
+
return nil
1120
+
}
1121
+
1122
+
func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error {
1123
+
var block ListBlock
1124
+
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1125
+
return err
1126
+
}
1127
+
1128
+
if block.ID == 0 {
1129
+
return nil
1130
+
//return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey)
1131
+
}
1132
+
1133
+
if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil {
1134
+
return err
1135
+
}
1136
+
1137
+
return nil
1138
+
}
1139
+
1140
+
func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error {
1141
+
var feedgen FeedGenerator
1142
+
if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1143
+
return err
1144
+
}
1145
+
1146
+
if feedgen.ID == 0 {
1147
+
return nil
1148
+
//return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey)
1149
+
}
1150
+
1151
+
if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil {
1152
+
return err
1153
+
}
1154
+
1155
+
return nil
1156
+
}
1157
+
1158
+
func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error {
1159
+
var threadgate ThreadGate
1160
+
if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1161
+
return err
1162
+
}
1163
+
1164
+
if threadgate.ID == 0 {
1165
+
return nil
1166
+
//return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey)
1167
+
}
1168
+
1169
+
if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil {
1170
+
return err
1171
+
}
1172
+
1173
+
return nil
1174
+
}
1175
+
1176
+
func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error {
1177
+
var profile Profile
1178
+
if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil {
1179
+
return err
1180
+
}
1181
+
1182
+
if profile.ID == 0 {
1183
+
return nil
1184
+
}
1185
+
1186
+
if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil {
1187
+
return err
1188
+
}
1189
+
1190
+
return nil
1191
+
}
1192
+
1193
+
const (
1194
+
NotifKindReply = "reply"
1195
+
NotifKindLike = "like"
1196
+
NotifKindMention = "mention"
1197
+
NotifKindRepost = "repost"
1198
+
)
1199
+
1200
+
func (b *PostgresBackend) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error {
1201
+
return b.db.Create(&Notification{
1202
+
For: forUser,
1203
+
Author: author,
1204
+
Source: recordUri,
1205
+
SourceCid: recordCid.String(),
1206
+
Kind: kind,
1207
+
}).Error
1208
+
}
+12
backend/interface.go
+12
backend/interface.go
···
1
+
package backend
2
+
3
+
// RecordTracker is an interface for tracking missing records that need to be fetched
4
+
type RecordTracker interface {
5
+
// TrackMissingRecord queues a missing record for fetching
6
+
// identifier can be:
7
+
// - A DID (e.g., "did:plc:...") for actors/profiles
8
+
// - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.post/...") for posts
9
+
// - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.generator/...") for feed generators
10
+
// wait: if true, blocks until the record is fetched
11
+
TrackMissingRecord(identifier string, wait bool)
12
+
}
+211
backend/missing.go
+211
backend/missing.go
···
1
+
package backend
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/api/bsky"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
xrpclib "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/ipfs/go-cid"
14
+
)
15
+
16
+
type MissingRecordType string
17
+
18
+
const (
19
+
MissingRecordTypeProfile MissingRecordType = "profile"
20
+
MissingRecordTypePost MissingRecordType = "post"
21
+
MissingRecordTypeFeedGenerator MissingRecordType = "feedgenerator"
22
+
MissingRecordTypeUnknown MissingRecordType = "unknown"
23
+
)
24
+
25
+
type MissingRecord struct {
26
+
Type MissingRecordType
27
+
Identifier string // DID for profiles, AT-URI for posts/feedgens
28
+
Wait bool
29
+
30
+
waitch chan struct{}
31
+
}
32
+
33
+
func (b *PostgresBackend) addMissingRecord(ctx context.Context, rec MissingRecord) {
34
+
if rec.Wait {
35
+
rec.waitch = make(chan struct{})
36
+
}
37
+
38
+
select {
39
+
case b.missingRecords <- rec:
40
+
case <-ctx.Done():
41
+
}
42
+
43
+
if rec.Wait {
44
+
select {
45
+
case <-rec.waitch:
46
+
case <-ctx.Done():
47
+
}
48
+
}
49
+
}
50
+
51
+
func (b *PostgresBackend) missingRecordFetcher() {
52
+
for rec := range b.missingRecords {
53
+
var err error
54
+
switch rec.Type {
55
+
case MissingRecordTypeProfile:
56
+
err = b.fetchMissingProfile(context.TODO(), rec.Identifier)
57
+
case MissingRecordTypePost:
58
+
err = b.fetchMissingPost(context.TODO(), rec.Identifier)
59
+
case MissingRecordTypeFeedGenerator:
60
+
err = b.fetchMissingFeedGenerator(context.TODO(), rec.Identifier)
61
+
default:
62
+
slog.Error("unknown missing record type", "type", rec.Type)
63
+
continue
64
+
}
65
+
66
+
if err != nil {
67
+
slog.Warn("failed to fetch missing record", "type", rec.Type, "identifier", rec.Identifier, "error", err)
68
+
}
69
+
70
+
if rec.Wait {
71
+
close(rec.waitch)
72
+
}
73
+
}
74
+
}
75
+
76
+
func (b *PostgresBackend) fetchMissingProfile(ctx context.Context, did string) error {
77
+
b.AddRelevantDid(did)
78
+
79
+
repo, err := b.GetOrCreateRepo(ctx, did)
80
+
if err != nil {
81
+
return err
82
+
}
83
+
84
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
85
+
if err != nil {
86
+
return err
87
+
}
88
+
89
+
c := &xrpclib.Client{
90
+
Host: resp.PDSEndpoint(),
91
+
}
92
+
93
+
rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self")
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
prof, ok := rec.Value.Val.(*bsky.ActorProfile)
99
+
if !ok {
100
+
return fmt.Errorf("record we got back wasnt a profile somehow")
101
+
}
102
+
103
+
buf := new(bytes.Buffer)
104
+
if err := prof.MarshalCBOR(buf); err != nil {
105
+
return err
106
+
}
107
+
108
+
cc, err := cid.Decode(*rec.Cid)
109
+
if err != nil {
110
+
return err
111
+
}
112
+
113
+
return b.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc)
114
+
}
115
+
116
+
func (b *PostgresBackend) fetchMissingPost(ctx context.Context, uri string) error {
117
+
puri, err := syntax.ParseATURI(uri)
118
+
if err != nil {
119
+
return fmt.Errorf("invalid AT URI: %s", uri)
120
+
}
121
+
122
+
did := puri.Authority().String()
123
+
collection := puri.Collection().String()
124
+
rkey := puri.RecordKey().String()
125
+
126
+
b.AddRelevantDid(did)
127
+
128
+
repo, err := b.GetOrCreateRepo(ctx, did)
129
+
if err != nil {
130
+
return err
131
+
}
132
+
133
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
134
+
if err != nil {
135
+
return err
136
+
}
137
+
138
+
c := &xrpclib.Client{
139
+
Host: resp.PDSEndpoint(),
140
+
}
141
+
142
+
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
143
+
if err != nil {
144
+
return err
145
+
}
146
+
147
+
post, ok := rec.Value.Val.(*bsky.FeedPost)
148
+
if !ok {
149
+
return fmt.Errorf("record we got back wasn't a post somehow")
150
+
}
151
+
152
+
buf := new(bytes.Buffer)
153
+
if err := post.MarshalCBOR(buf); err != nil {
154
+
return err
155
+
}
156
+
157
+
cc, err := cid.Decode(*rec.Cid)
158
+
if err != nil {
159
+
return err
160
+
}
161
+
162
+
return b.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc)
163
+
}
164
+
165
+
func (b *PostgresBackend) fetchMissingFeedGenerator(ctx context.Context, uri string) error {
166
+
puri, err := syntax.ParseATURI(uri)
167
+
if err != nil {
168
+
return fmt.Errorf("invalid AT URI: %s", uri)
169
+
}
170
+
171
+
did := puri.Authority().String()
172
+
collection := puri.Collection().String()
173
+
rkey := puri.RecordKey().String()
174
+
b.AddRelevantDid(did)
175
+
176
+
repo, err := b.GetOrCreateRepo(ctx, did)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
182
+
if err != nil {
183
+
return err
184
+
}
185
+
186
+
c := &xrpclib.Client{
187
+
Host: resp.PDSEndpoint(),
188
+
}
189
+
190
+
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
191
+
if err != nil {
192
+
return err
193
+
}
194
+
195
+
feedGen, ok := rec.Value.Val.(*bsky.FeedGenerator)
196
+
if !ok {
197
+
return fmt.Errorf("record we got back wasn't a feed generator somehow")
198
+
}
199
+
200
+
buf := new(bytes.Buffer)
201
+
if err := feedGen.MarshalCBOR(buf); err != nil {
202
+
return err
203
+
}
204
+
205
+
cc, err := cid.Decode(*rec.Cid)
206
+
if err != nil {
207
+
return err
208
+
}
209
+
210
+
return b.HandleCreateFeedGenerator(ctx, repo, rkey, buf.Bytes(), cc)
211
+
}
+2
-4
docker-compose.yml
+2
-4
docker-compose.yml
···
1
-
version: '3.8'
2
-
3
1
services:
4
2
postgres:
5
3
image: postgres:15-alpine
···
25
23
container_name: konbini-backend
26
24
environment:
27
25
- DATABASE_URL=postgres://konbini:konbini_password@postgres:5432/konbini?sslmode=disable
28
-
- BSKY_HANDLE=${BSKY_HANDLE}
29
-
- BSKY_PASSWORD=${BSKY_PASSWORD}
26
+
- BSKY_HANDLE=${BSKY_HANDLE:?}
27
+
- BSKY_PASSWORD=${BSKY_PASSWORD:?}
30
28
ports:
31
29
- "4444:4444"
32
30
depends_on:
-1125
events.go
-1125
events.go
···
1
-
package main
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
"log/slog"
8
-
"strings"
9
-
"sync"
10
-
"time"
11
-
12
-
"github.com/bluesky-social/indigo/api/atproto"
13
-
"github.com/bluesky-social/indigo/api/bsky"
14
-
"github.com/bluesky-social/indigo/atproto/syntax"
15
-
"github.com/bluesky-social/indigo/repo"
16
-
lru "github.com/hashicorp/golang-lru/v2"
17
-
"github.com/ipfs/go-cid"
18
-
"github.com/jackc/pgx/v5/pgconn"
19
-
"github.com/jackc/pgx/v5/pgxpool"
20
-
"gorm.io/gorm"
21
-
)
22
-
23
-
type PostgresBackend struct {
24
-
db *gorm.DB
25
-
pgx *pgxpool.Pool
26
-
s *Server
27
-
28
-
relevantDids map[string]bool
29
-
rdLk sync.Mutex
30
-
31
-
revCache *lru.TwoQueueCache[uint, string]
32
-
33
-
repoCache *lru.TwoQueueCache[string, *Repo]
34
-
reposLk sync.Mutex
35
-
36
-
postInfoCache *lru.TwoQueueCache[string, cachedPostInfo]
37
-
}
38
-
39
-
func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error {
40
-
r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks))
41
-
if err != nil {
42
-
return fmt.Errorf("failed to read event repo: %w", err)
43
-
}
44
-
45
-
for _, op := range evt.Ops {
46
-
switch op.Action {
47
-
case "create":
48
-
c, rec, err := r.GetRecordBytes(ctx, op.Path)
49
-
if err != nil {
50
-
return err
51
-
}
52
-
if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
53
-
return fmt.Errorf("create record failed: %w", err)
54
-
}
55
-
case "update":
56
-
c, rec, err := r.GetRecordBytes(ctx, op.Path)
57
-
if err != nil {
58
-
return err
59
-
}
60
-
if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
61
-
return fmt.Errorf("update record failed: %w", err)
62
-
}
63
-
case "delete":
64
-
if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil {
65
-
return fmt.Errorf("delete record failed: %w", err)
66
-
}
67
-
}
68
-
}
69
-
70
-
// TODO: sync with the Since field to make sure we don't miss events we care about
71
-
/*
72
-
if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil {
73
-
return fmt.Errorf("failed to update rev: %w", err)
74
-
}
75
-
*/
76
-
77
-
return nil
78
-
}
79
-
80
-
func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
81
-
start := time.Now()
82
-
83
-
rr, err := b.getOrCreateRepo(ctx, repo)
84
-
if err != nil {
85
-
return fmt.Errorf("get user failed: %w", err)
86
-
}
87
-
88
-
lrev, err := b.revForRepo(rr)
89
-
if err != nil {
90
-
return err
91
-
}
92
-
if lrev != "" {
93
-
if rev < lrev {
94
-
slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
95
-
return nil
96
-
}
97
-
}
98
-
99
-
parts := strings.Split(path, "/")
100
-
if len(parts) != 2 {
101
-
return fmt.Errorf("invalid path in HandleCreate: %q", path)
102
-
}
103
-
col := parts[0]
104
-
rkey := parts[1]
105
-
106
-
defer func() {
107
-
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
108
-
}()
109
-
110
-
if rkey == "" {
111
-
fmt.Printf("messed up path: %q\n", rkey)
112
-
}
113
-
114
-
switch col {
115
-
case "app.bsky.feed.post":
116
-
if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
117
-
return err
118
-
}
119
-
case "app.bsky.feed.like":
120
-
if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
121
-
return err
122
-
}
123
-
case "app.bsky.feed.repost":
124
-
if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
125
-
return err
126
-
}
127
-
case "app.bsky.graph.follow":
128
-
if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
129
-
return err
130
-
}
131
-
case "app.bsky.graph.block":
132
-
if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
133
-
return err
134
-
}
135
-
case "app.bsky.graph.list":
136
-
if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
137
-
return err
138
-
}
139
-
case "app.bsky.graph.listitem":
140
-
if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
141
-
return err
142
-
}
143
-
case "app.bsky.graph.listblock":
144
-
if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
145
-
return err
146
-
}
147
-
case "app.bsky.actor.profile":
148
-
if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
149
-
return err
150
-
}
151
-
case "app.bsky.feed.generator":
152
-
if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
153
-
return err
154
-
}
155
-
case "app.bsky.feed.threadgate":
156
-
if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
157
-
return err
158
-
}
159
-
case "chat.bsky.actor.declaration":
160
-
if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
161
-
return err
162
-
}
163
-
case "app.bsky.feed.postgate":
164
-
if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil {
165
-
return err
166
-
}
167
-
case "app.bsky.graph.starterpack":
168
-
if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil {
169
-
return err
170
-
}
171
-
default:
172
-
slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev)
173
-
}
174
-
175
-
b.revCache.Add(rr.ID, rev)
176
-
return nil
177
-
}
178
-
179
-
func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
180
-
exists, err := b.checkPostExists(ctx, repo, rkey)
181
-
if err != nil {
182
-
return err
183
-
}
184
-
185
-
// still technically a race condition if two creates for the same post happen concurrently... probably fine
186
-
if exists {
187
-
return nil
188
-
}
189
-
190
-
var rec bsky.FeedPost
191
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
192
-
return err
193
-
}
194
-
195
-
reldids := []string{repo.Did}
196
-
// care about a post if its in a thread of a user we are interested in
197
-
if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
198
-
reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri)
199
-
}
200
-
// TODO: maybe also care if its mentioning a user we care about or quoting a user we care about?
201
-
if !b.anyRelevantIdents(reldids...) {
202
-
return nil
203
-
}
204
-
205
-
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
206
-
slog.Warn("adding post", "uri", uri)
207
-
208
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
209
-
if err != nil {
210
-
return fmt.Errorf("invalid timestamp: %w", err)
211
-
}
212
-
213
-
p := Post{
214
-
Created: created.Time(),
215
-
Indexed: time.Now(),
216
-
Author: repo.ID,
217
-
Rkey: rkey,
218
-
Raw: recb,
219
-
Cid: cc.String(),
220
-
}
221
-
222
-
if rec.Reply != nil && rec.Reply.Parent != nil {
223
-
if rec.Reply.Root == nil {
224
-
return fmt.Errorf("post reply had nil root")
225
-
}
226
-
227
-
pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri)
228
-
if err != nil {
229
-
return fmt.Errorf("getting reply parent: %w", err)
230
-
}
231
-
232
-
p.ReplyTo = pinfo.ID
233
-
p.ReplyToUsr = pinfo.Author
234
-
235
-
thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri)
236
-
if err != nil {
237
-
return fmt.Errorf("getting thread root: %w", err)
238
-
}
239
-
240
-
p.InThread = thread
241
-
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 {
244
-
slog.Warn("failed to create notification", "uri", uri, "error", err)
245
-
}
246
-
}
247
-
}
248
-
249
-
if rec.Embed != nil {
250
-
var rpref string
251
-
if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil {
252
-
rpref = rec.Embed.EmbedRecord.Record.Uri
253
-
}
254
-
if rec.Embed.EmbedRecordWithMedia != nil &&
255
-
rec.Embed.EmbedRecordWithMedia.Record != nil &&
256
-
rec.Embed.EmbedRecordWithMedia.Record.Record != nil {
257
-
rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri
258
-
}
259
-
260
-
if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") {
261
-
rp, err := b.postIDForUri(ctx, rpref)
262
-
if err != nil {
263
-
return fmt.Errorf("getting quote subject: %w", err)
264
-
}
265
-
266
-
p.Reposting = rp
267
-
}
268
-
}
269
-
270
-
if err := b.doPostCreate(ctx, &p); err != nil {
271
-
return err
272
-
}
273
-
274
-
// Check for mentions and create notifications
275
-
if rec.Facets != nil {
276
-
for _, facet := range rec.Facets {
277
-
for _, feature := range facet.Features {
278
-
if feature.RichtextFacet_Mention != nil {
279
-
mentionDid := feature.RichtextFacet_Mention.Did
280
-
// This is a mention
281
-
mentionedRepo, err := b.getOrCreateRepo(ctx, mentionDid)
282
-
if err != nil {
283
-
slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err)
284
-
continue
285
-
}
286
-
287
-
// Create notification if the mentioned user is the current user
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 {
290
-
slog.Warn("failed to create mention notification", "uri", uri, "error", err)
291
-
}
292
-
}
293
-
}
294
-
}
295
-
}
296
-
}
297
-
298
-
b.postInfoCache.Add(uri, cachedPostInfo{
299
-
ID: p.ID,
300
-
Author: p.Author,
301
-
})
302
-
303
-
return nil
304
-
}
305
-
306
-
func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error {
307
-
/*
308
-
if err := b.db.Clauses(clause.OnConflict{
309
-
Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}},
310
-
DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}),
311
-
}).Create(p).Error; err != nil {
312
-
return err
313
-
}
314
-
*/
315
-
316
-
query := `
317
-
INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread)
318
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
319
-
ON CONFLICT (author, rkey)
320
-
DO UPDATE SET
321
-
cid = $3,
322
-
not_found = $4,
323
-
raw = $5,
324
-
created = $6,
325
-
indexed = $7,
326
-
reposting = $8,
327
-
reply_to = $9,
328
-
reply_to_usr = $10,
329
-
in_thread = $11
330
-
RETURNING id
331
-
`
332
-
333
-
// Execute the query with parameters from the Post struct
334
-
if err := b.pgx.QueryRow(
335
-
ctx,
336
-
query,
337
-
p.Author,
338
-
p.Rkey,
339
-
p.Cid,
340
-
p.NotFound,
341
-
p.Raw,
342
-
p.Created,
343
-
p.Indexed,
344
-
p.Reposting,
345
-
p.ReplyTo,
346
-
p.ReplyToUsr,
347
-
p.InThread,
348
-
).Scan(&p.ID); err != nil {
349
-
return err
350
-
}
351
-
352
-
return nil
353
-
}
354
-
355
-
func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
356
-
var rec bsky.FeedLike
357
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
358
-
return err
359
-
}
360
-
361
-
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
362
-
return nil
363
-
}
364
-
365
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
366
-
if err != nil {
367
-
return fmt.Errorf("invalid timestamp: %w", err)
368
-
}
369
-
370
-
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
371
-
if err != nil {
372
-
return fmt.Errorf("getting like subject: %w", err)
373
-
}
374
-
375
-
if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID, cc.String()); err != nil {
376
-
pgErr, ok := err.(*pgconn.PgError)
377
-
if ok && pgErr.Code == "23505" {
378
-
return nil
379
-
}
380
-
return err
381
-
}
382
-
383
-
// Create notification if the liked post belongs to the current user
384
-
if pinfo.Author == b.s.myrepo.ID {
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 {
387
-
slog.Warn("failed to create like notification", "uri", uri, "error", err)
388
-
}
389
-
}
390
-
391
-
return nil
392
-
}
393
-
394
-
func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
395
-
var rec bsky.FeedRepost
396
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
397
-
return err
398
-
}
399
-
400
-
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
401
-
return nil
402
-
}
403
-
404
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
405
-
if err != nil {
406
-
return fmt.Errorf("invalid timestamp: %w", err)
407
-
}
408
-
409
-
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
410
-
if err != nil {
411
-
return fmt.Errorf("getting repost subject: %w", err)
412
-
}
413
-
414
-
if _, err := b.pgx.Exec(ctx, `INSERT INTO "reposts" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID); err != nil {
415
-
pgErr, ok := err.(*pgconn.PgError)
416
-
if ok && pgErr.Code == "23505" {
417
-
return nil
418
-
}
419
-
return err
420
-
}
421
-
422
-
// Create notification if the reposted post belongs to the current user
423
-
if pinfo.Author == b.s.myrepo.ID {
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 {
426
-
slog.Warn("failed to create repost notification", "uri", uri, "error", err)
427
-
}
428
-
}
429
-
430
-
return nil
431
-
}
432
-
433
-
func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
434
-
var rec bsky.GraphFollow
435
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
436
-
return err
437
-
}
438
-
439
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
440
-
return nil
441
-
}
442
-
443
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
444
-
if err != nil {
445
-
return fmt.Errorf("invalid timestamp: %w", err)
446
-
}
447
-
448
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
449
-
if err != nil {
450
-
return err
451
-
}
452
-
453
-
if _, err := b.pgx.Exec(ctx, "INSERT INTO follows (created, indexed, author, rkey, subject) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", created.Time(), time.Now(), repo.ID, rkey, subj.ID); err != nil {
454
-
return err
455
-
}
456
-
457
-
return nil
458
-
}
459
-
460
-
func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
461
-
var rec bsky.GraphBlock
462
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
463
-
return err
464
-
}
465
-
466
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
467
-
return nil
468
-
}
469
-
470
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
471
-
if err != nil {
472
-
return fmt.Errorf("invalid timestamp: %w", err)
473
-
}
474
-
475
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
476
-
if err != nil {
477
-
return err
478
-
}
479
-
480
-
if err := b.db.Create(&Block{
481
-
Created: created.Time(),
482
-
Indexed: time.Now(),
483
-
Author: repo.ID,
484
-
Rkey: rkey,
485
-
Subject: subj.ID,
486
-
}).Error; err != nil {
487
-
return err
488
-
}
489
-
490
-
return nil
491
-
}
492
-
493
-
func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
494
-
var rec bsky.GraphList
495
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
496
-
return err
497
-
}
498
-
499
-
if !b.anyRelevantIdents(repo.Did) {
500
-
return nil
501
-
}
502
-
503
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
504
-
if err != nil {
505
-
return fmt.Errorf("invalid timestamp: %w", err)
506
-
}
507
-
508
-
if err := b.db.Create(&List{
509
-
Created: created.Time(),
510
-
Indexed: time.Now(),
511
-
Author: repo.ID,
512
-
Rkey: rkey,
513
-
Raw: recb,
514
-
}).Error; err != nil {
515
-
return err
516
-
}
517
-
518
-
return nil
519
-
}
520
-
521
-
func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
522
-
var rec bsky.GraphListitem
523
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
524
-
return err
525
-
}
526
-
if !b.anyRelevantIdents(repo.Did) {
527
-
return nil
528
-
}
529
-
530
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
531
-
if err != nil {
532
-
return fmt.Errorf("invalid timestamp: %w", err)
533
-
}
534
-
535
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
536
-
if err != nil {
537
-
return err
538
-
}
539
-
540
-
list, err := b.getOrCreateList(ctx, rec.List)
541
-
if err != nil {
542
-
return err
543
-
}
544
-
545
-
if err := b.db.Create(&ListItem{
546
-
Created: created.Time(),
547
-
Indexed: time.Now(),
548
-
Author: repo.ID,
549
-
Rkey: rkey,
550
-
Subject: subj.ID,
551
-
List: list.ID,
552
-
}).Error; err != nil {
553
-
return err
554
-
}
555
-
556
-
return nil
557
-
}
558
-
559
-
func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
560
-
var rec bsky.GraphListblock
561
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
562
-
return err
563
-
}
564
-
565
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
566
-
return nil
567
-
}
568
-
569
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
570
-
if err != nil {
571
-
return fmt.Errorf("invalid timestamp: %w", err)
572
-
}
573
-
574
-
list, err := b.getOrCreateList(ctx, rec.Subject)
575
-
if err != nil {
576
-
return err
577
-
}
578
-
579
-
if err := b.db.Create(&ListBlock{
580
-
Created: created.Time(),
581
-
Indexed: time.Now(),
582
-
Author: repo.ID,
583
-
Rkey: rkey,
584
-
List: list.ID,
585
-
}).Error; err != nil {
586
-
return err
587
-
}
588
-
589
-
return nil
590
-
}
591
-
592
-
func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
593
-
if !b.anyRelevantIdents(repo.Did) {
594
-
return nil
595
-
}
596
-
597
-
if err := b.db.Create(&Profile{
598
-
//Created: created.Time(),
599
-
Indexed: time.Now(),
600
-
Repo: repo.ID,
601
-
Raw: recb,
602
-
Rev: rev,
603
-
}).Error; err != nil {
604
-
return err
605
-
}
606
-
607
-
return nil
608
-
}
609
-
610
-
func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
611
-
if !b.anyRelevantIdents(repo.Did) {
612
-
return nil
613
-
}
614
-
615
-
if err := b.db.Create(&Profile{
616
-
Indexed: time.Now(),
617
-
Repo: repo.ID,
618
-
Raw: recb,
619
-
Rev: rev,
620
-
}).Error; err != nil {
621
-
return err
622
-
}
623
-
624
-
return nil
625
-
}
626
-
627
-
func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
628
-
if !b.anyRelevantIdents(repo.Did) {
629
-
return nil
630
-
}
631
-
632
-
var rec bsky.FeedGenerator
633
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
634
-
return err
635
-
}
636
-
637
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
638
-
if err != nil {
639
-
return fmt.Errorf("invalid timestamp: %w", err)
640
-
}
641
-
642
-
if err := b.db.Create(&FeedGenerator{
643
-
Created: created.Time(),
644
-
Indexed: time.Now(),
645
-
Author: repo.ID,
646
-
Rkey: rkey,
647
-
Did: rec.Did,
648
-
}).Error; err != nil {
649
-
return err
650
-
}
651
-
652
-
return nil
653
-
}
654
-
655
-
func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
656
-
if !b.anyRelevantIdents(repo.Did) {
657
-
return nil
658
-
}
659
-
var rec bsky.FeedThreadgate
660
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
661
-
return err
662
-
}
663
-
664
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
665
-
if err != nil {
666
-
return fmt.Errorf("invalid timestamp: %w", err)
667
-
}
668
-
669
-
pid, err := b.postIDForUri(ctx, rec.Post)
670
-
if err != nil {
671
-
return err
672
-
}
673
-
674
-
if err := b.db.Create(&ThreadGate{
675
-
Created: created.Time(),
676
-
Indexed: time.Now(),
677
-
Author: repo.ID,
678
-
Rkey: rkey,
679
-
Post: pid,
680
-
}).Error; err != nil {
681
-
return err
682
-
}
683
-
684
-
return nil
685
-
}
686
-
687
-
func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
688
-
// TODO: maybe track these?
689
-
return nil
690
-
}
691
-
692
-
func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
693
-
if !b.anyRelevantIdents(repo.Did) {
694
-
return nil
695
-
}
696
-
var rec bsky.FeedPostgate
697
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
698
-
return err
699
-
}
700
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
701
-
if err != nil {
702
-
return fmt.Errorf("invalid timestamp: %w", err)
703
-
}
704
-
705
-
refPost, err := b.postInfoForUri(ctx, rec.Post)
706
-
if err != nil {
707
-
return err
708
-
}
709
-
710
-
if err := b.db.Create(&PostGate{
711
-
Created: created.Time(),
712
-
Indexed: time.Now(),
713
-
Author: repo.ID,
714
-
Rkey: rkey,
715
-
Subject: refPost.ID,
716
-
Raw: recb,
717
-
}).Error; err != nil {
718
-
return err
719
-
}
720
-
721
-
return nil
722
-
}
723
-
724
-
func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
725
-
if !b.anyRelevantIdents(repo.Did) {
726
-
return nil
727
-
}
728
-
var rec bsky.GraphStarterpack
729
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
730
-
return err
731
-
}
732
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
733
-
if err != nil {
734
-
return fmt.Errorf("invalid timestamp: %w", err)
735
-
}
736
-
737
-
list, err := b.getOrCreateList(ctx, rec.List)
738
-
if err != nil {
739
-
return err
740
-
}
741
-
742
-
if err := b.db.Create(&StarterPack{
743
-
Created: created.Time(),
744
-
Indexed: time.Now(),
745
-
Author: repo.ID,
746
-
Rkey: rkey,
747
-
Raw: recb,
748
-
List: list.ID,
749
-
}).Error; err != nil {
750
-
return err
751
-
}
752
-
753
-
return nil
754
-
}
755
-
756
-
func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
757
-
start := time.Now()
758
-
759
-
rr, err := b.getOrCreateRepo(ctx, repo)
760
-
if err != nil {
761
-
return fmt.Errorf("get user failed: %w", err)
762
-
}
763
-
764
-
lrev, err := b.revForRepo(rr)
765
-
if err != nil {
766
-
return err
767
-
}
768
-
if lrev != "" {
769
-
if rev < lrev {
770
-
//slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
771
-
return nil
772
-
}
773
-
}
774
-
775
-
parts := strings.Split(path, "/")
776
-
if len(parts) != 2 {
777
-
return fmt.Errorf("invalid path in HandleCreate: %q", path)
778
-
}
779
-
col := parts[0]
780
-
rkey := parts[1]
781
-
782
-
defer func() {
783
-
handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds()))
784
-
}()
785
-
786
-
if rkey == "" {
787
-
fmt.Printf("messed up path: %q\n", rkey)
788
-
}
789
-
790
-
switch col {
791
-
/*
792
-
case "app.bsky.feed.post":
793
-
if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
794
-
return err
795
-
}
796
-
case "app.bsky.feed.like":
797
-
if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
798
-
return err
799
-
}
800
-
case "app.bsky.feed.repost":
801
-
if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
802
-
return err
803
-
}
804
-
case "app.bsky.graph.follow":
805
-
if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
806
-
return err
807
-
}
808
-
case "app.bsky.graph.block":
809
-
if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
810
-
return err
811
-
}
812
-
case "app.bsky.graph.list":
813
-
if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
814
-
return err
815
-
}
816
-
case "app.bsky.graph.listitem":
817
-
if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
818
-
return err
819
-
}
820
-
case "app.bsky.graph.listblock":
821
-
if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
822
-
return err
823
-
}
824
-
*/
825
-
case "app.bsky.actor.profile":
826
-
if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
827
-
return err
828
-
}
829
-
/*
830
-
case "app.bsky.feed.generator":
831
-
if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
832
-
return err
833
-
}
834
-
case "app.bsky.feed.threadgate":
835
-
if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
836
-
return err
837
-
}
838
-
case "chat.bsky.actor.declaration":
839
-
if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
840
-
return err
841
-
}
842
-
*/
843
-
default:
844
-
slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev)
845
-
}
846
-
847
-
return nil
848
-
}
849
-
850
-
func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error {
851
-
start := time.Now()
852
-
853
-
rr, err := b.getOrCreateRepo(ctx, repo)
854
-
if err != nil {
855
-
return fmt.Errorf("get user failed: %w", err)
856
-
}
857
-
858
-
lrev, ok := b.revCache.Get(rr.ID)
859
-
if ok {
860
-
if rev < lrev {
861
-
//slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev)
862
-
return nil
863
-
}
864
-
}
865
-
866
-
parts := strings.Split(path, "/")
867
-
if len(parts) != 2 {
868
-
return fmt.Errorf("invalid path in HandleDelete: %q", path)
869
-
}
870
-
col := parts[0]
871
-
rkey := parts[1]
872
-
873
-
defer func() {
874
-
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
875
-
}()
876
-
877
-
switch col {
878
-
case "app.bsky.feed.post":
879
-
if err := b.HandleDeletePost(ctx, rr, rkey); err != nil {
880
-
return err
881
-
}
882
-
case "app.bsky.feed.like":
883
-
if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil {
884
-
return err
885
-
}
886
-
case "app.bsky.feed.repost":
887
-
if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil {
888
-
return err
889
-
}
890
-
case "app.bsky.graph.follow":
891
-
if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil {
892
-
return err
893
-
}
894
-
case "app.bsky.graph.block":
895
-
if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil {
896
-
return err
897
-
}
898
-
case "app.bsky.graph.list":
899
-
if err := b.HandleDeleteList(ctx, rr, rkey); err != nil {
900
-
return err
901
-
}
902
-
case "app.bsky.graph.listitem":
903
-
if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil {
904
-
return err
905
-
}
906
-
case "app.bsky.graph.listblock":
907
-
if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil {
908
-
return err
909
-
}
910
-
case "app.bsky.actor.profile":
911
-
if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil {
912
-
return err
913
-
}
914
-
case "app.bsky.feed.generator":
915
-
if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil {
916
-
return err
917
-
}
918
-
case "app.bsky.feed.threadgate":
919
-
if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil {
920
-
return err
921
-
}
922
-
default:
923
-
slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev)
924
-
}
925
-
926
-
b.revCache.Add(rr.ID, rev)
927
-
return nil
928
-
}
929
-
930
-
func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error {
931
-
var p Post
932
-
if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
933
-
return err
934
-
}
935
-
936
-
if p.ID == 0 {
937
-
//slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey)
938
-
return nil
939
-
}
940
-
941
-
if err := b.db.Delete(&Post{}, p.ID).Error; err != nil {
942
-
return err
943
-
}
944
-
945
-
return nil
946
-
}
947
-
948
-
func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error {
949
-
var like Like
950
-
if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
951
-
return err
952
-
}
953
-
954
-
if like.ID == 0 {
955
-
//slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey)
956
-
return nil
957
-
}
958
-
959
-
if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil {
960
-
return err
961
-
}
962
-
963
-
return nil
964
-
}
965
-
966
-
func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error {
967
-
var repost Repost
968
-
if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
969
-
return err
970
-
}
971
-
972
-
if repost.ID == 0 {
973
-
//return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey)
974
-
return nil
975
-
}
976
-
977
-
if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil {
978
-
return err
979
-
}
980
-
981
-
return nil
982
-
}
983
-
984
-
func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error {
985
-
var follow Follow
986
-
if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
987
-
return err
988
-
}
989
-
990
-
if follow.ID == 0 {
991
-
//slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey)
992
-
return nil
993
-
}
994
-
995
-
if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil {
996
-
return err
997
-
}
998
-
999
-
return nil
1000
-
}
1001
-
1002
-
func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error {
1003
-
var block Block
1004
-
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1005
-
return err
1006
-
}
1007
-
1008
-
if block.ID == 0 {
1009
-
//slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey)
1010
-
return nil
1011
-
}
1012
-
1013
-
if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil {
1014
-
return err
1015
-
}
1016
-
1017
-
return nil
1018
-
}
1019
-
1020
-
func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error {
1021
-
var list List
1022
-
if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1023
-
return err
1024
-
}
1025
-
1026
-
if list.ID == 0 {
1027
-
return nil
1028
-
//return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey)
1029
-
}
1030
-
1031
-
if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil {
1032
-
return err
1033
-
}
1034
-
1035
-
return nil
1036
-
}
1037
-
1038
-
func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error {
1039
-
var item ListItem
1040
-
if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1041
-
return err
1042
-
}
1043
-
1044
-
if item.ID == 0 {
1045
-
return nil
1046
-
//return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey)
1047
-
}
1048
-
1049
-
if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil {
1050
-
return err
1051
-
}
1052
-
1053
-
return nil
1054
-
}
1055
-
1056
-
func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error {
1057
-
var block ListBlock
1058
-
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1059
-
return err
1060
-
}
1061
-
1062
-
if block.ID == 0 {
1063
-
return nil
1064
-
//return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey)
1065
-
}
1066
-
1067
-
if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil {
1068
-
return err
1069
-
}
1070
-
1071
-
return nil
1072
-
}
1073
-
1074
-
func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error {
1075
-
var feedgen FeedGenerator
1076
-
if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1077
-
return err
1078
-
}
1079
-
1080
-
if feedgen.ID == 0 {
1081
-
return nil
1082
-
//return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey)
1083
-
}
1084
-
1085
-
if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil {
1086
-
return err
1087
-
}
1088
-
1089
-
return nil
1090
-
}
1091
-
1092
-
func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error {
1093
-
var threadgate ThreadGate
1094
-
if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1095
-
return err
1096
-
}
1097
-
1098
-
if threadgate.ID == 0 {
1099
-
return nil
1100
-
//return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey)
1101
-
}
1102
-
1103
-
if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil {
1104
-
return err
1105
-
}
1106
-
1107
-
return nil
1108
-
}
1109
-
1110
-
func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error {
1111
-
var profile Profile
1112
-
if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil {
1113
-
return err
1114
-
}
1115
-
1116
-
if profile.ID == 0 {
1117
-
return nil
1118
-
}
1119
-
1120
-
if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil {
1121
-
return err
1122
-
}
1123
-
1124
-
return nil
1125
-
}
+1
-1
frontend/Dockerfile
+1
-1
frontend/Dockerfile
+42
frontend/src/components/PostView.tsx
+42
frontend/src/components/PostView.tsx
···
3
3
import { PostResponse } from '../types';
4
4
import { ApiClient } from '../api';
5
5
import { PostCard } from './PostCard';
6
+
import { EngagementModal } from './EngagementModal';
6
7
import './PostView.css';
7
8
8
9
export const PostView: React.FC = () => {
···
11
12
const [threadPosts, setThreadPosts] = useState<PostResponse[]>([]);
12
13
const [loading, setLoading] = useState(true);
13
14
const [error, setError] = useState<string | null>(null);
15
+
const [showEngagementModal, setShowEngagementModal] = useState<'likes' | 'reposts' | 'replies' | null>(null);
14
16
15
17
useEffect(() => {
16
18
// Scroll to top when navigating to a post
···
96
98
<PostCard postResponse={mainPost} showThreadIndicator={false} />
97
99
</div>
98
100
101
+
{mainPost.counts && (mainPost.counts.likes > 0 || mainPost.counts.reposts > 0 || mainPost.counts.replies > 0) && (
102
+
<div className="post-engagement-detail">
103
+
{mainPost.counts.likes > 0 && (
104
+
<button
105
+
className="engagement-detail-item"
106
+
onClick={() => setShowEngagementModal('likes')}
107
+
>
108
+
<span className="engagement-detail-count">{mainPost.counts.likes}</span>
109
+
<span className="engagement-detail-label">{mainPost.counts.likes === 1 ? 'Like' : 'Likes'}</span>
110
+
</button>
111
+
)}
112
+
{mainPost.counts.reposts > 0 && (
113
+
<button
114
+
className="engagement-detail-item"
115
+
onClick={() => setShowEngagementModal('reposts')}
116
+
>
117
+
<span className="engagement-detail-count">{mainPost.counts.reposts}</span>
118
+
<span className="engagement-detail-label">{mainPost.counts.reposts === 1 ? 'Repost' : 'Reposts'}</span>
119
+
</button>
120
+
)}
121
+
{mainPost.counts.replies > 0 && (
122
+
<button
123
+
className="engagement-detail-item"
124
+
onClick={() => setShowEngagementModal('replies')}
125
+
>
126
+
<span className="engagement-detail-count">{mainPost.counts.replies}</span>
127
+
<span className="engagement-detail-label">{mainPost.counts.replies === 1 ? 'Reply' : 'Replies'}</span>
128
+
</button>
129
+
)}
130
+
</div>
131
+
)}
132
+
99
133
{threadPosts.length > 0 && (
100
134
<div className="thread-replies">
101
135
<div className="replies-header">
···
109
143
</div>
110
144
)}
111
145
</div>
146
+
147
+
{showEngagementModal && (
148
+
<EngagementModal
149
+
postId={mainPost.id}
150
+
type={showEngagementModal}
151
+
onClose={() => setShowEngagementModal(null)}
152
+
/>
153
+
)}
112
154
</div>
113
155
);
114
156
};
+19
-9
go.mod
+19
-9
go.mod
···
3
3
go 1.25.1
4
4
5
5
require (
6
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f
6
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
7
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1
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
14
+
github.com/lestrrat-go/jwx/v2 v2.0.12
15
+
github.com/multiformats/go-multihash v0.2.3
12
16
github.com/prometheus/client_golang v1.19.1
13
17
github.com/urfave/cli/v2 v2.27.7
18
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
14
19
github.com/whyrusleeping/market v0.0.0-20250711215409-cc684a207f15
20
+
go.opentelemetry.io/otel v1.34.0
21
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0
22
+
go.opentelemetry.io/otel/sdk v1.34.0
15
23
gorm.io/gorm v1.31.0
16
24
)
17
25
···
23
31
github.com/cespare/xxhash/v2 v2.3.0 // indirect
24
32
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
25
33
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
34
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
26
35
github.com/felixge/httpsnoop v1.0.4 // indirect
27
36
github.com/go-logr/logr v1.4.2 // indirect
28
37
github.com/go-logr/stdr v1.2.2 // indirect
38
+
github.com/go-redis/cache/v9 v9.0.0 // indirect
29
39
github.com/goccy/go-json v0.10.5 // indirect
30
40
github.com/gogo/protobuf v1.3.2 // indirect
31
41
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
···
51
61
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
52
62
github.com/ipfs/go-peertaskqueue v0.8.1 // indirect
53
63
github.com/ipfs/go-verifcid v0.0.3 // indirect
54
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 // indirect
64
+
github.com/ipld/go-car v0.6.2 // indirect
55
65
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
56
66
github.com/ipld/go-ipld-prime v0.21.0 // indirect
57
67
github.com/jackc/pgpassfile v1.0.0 // indirect
···
60
70
github.com/jbenet/goprocess v0.1.4 // indirect
61
71
github.com/jinzhu/inflection v1.0.0 // indirect
62
72
github.com/jinzhu/now v1.1.5 // indirect
73
+
github.com/klauspost/compress v1.17.9 // indirect
63
74
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
64
-
github.com/labstack/gommon v0.4.1 // indirect
65
75
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
66
76
github.com/lestrrat-go/httpcc v1.0.1 // indirect
67
77
github.com/lestrrat-go/httprc v1.0.4 // indirect
68
78
github.com/lestrrat-go/iter v1.0.2 // indirect
69
-
github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect
70
79
github.com/lestrrat-go/option v1.0.1 // indirect
71
80
github.com/libp2p/go-libp2p v0.25.1 // indirect
72
81
github.com/mattn/go-colorable v0.1.13 // indirect
···
78
87
github.com/multiformats/go-base36 v0.2.0 // indirect
79
88
github.com/multiformats/go-multiaddr v0.8.0 // indirect
80
89
github.com/multiformats/go-multibase v0.2.0 // indirect
81
-
github.com/multiformats/go-multihash v0.2.3 // indirect
82
90
github.com/multiformats/go-varint v0.0.7 // indirect
83
91
github.com/opentracing/opentracing-go v1.2.0 // indirect
84
92
github.com/orandin/slog-gorm v1.3.2 // indirect
85
93
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
86
94
github.com/prometheus/client_model v0.6.1 // indirect
87
-
github.com/prometheus/common v0.48.0 // indirect
88
-
github.com/prometheus/procfs v0.12.0 // indirect
95
+
github.com/prometheus/common v0.54.0 // indirect
96
+
github.com/prometheus/procfs v0.15.1 // indirect
97
+
github.com/redis/go-redis/v9 v9.3.0 // indirect
89
98
github.com/russross/blackfriday/v2 v2.1.0 // indirect
90
99
github.com/segmentio/asm v1.2.0 // indirect
91
100
github.com/spaolacci/murmur3 v1.1.0 // indirect
92
101
github.com/valyala/bytebufferpool v1.0.0 // indirect
93
102
github.com/valyala/fasttemplate v1.2.2 // indirect
94
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
103
+
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
104
+
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
105
+
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
95
106
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
96
107
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
97
108
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
98
109
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
99
110
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
100
111
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
101
-
go.opentelemetry.io/otel v1.34.0 // indirect
102
112
go.opentelemetry.io/otel/metric v1.34.0 // indirect
103
113
go.opentelemetry.io/otel/trace v1.34.0 // indirect
104
114
go.uber.org/atomic v1.11.0 // indirect
+155
-8
go.sum
+155
-8
go.sum
···
6
6
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
7
7
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
8
8
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
9
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f h1:FugOoTzh0nCMTWGqNGsjttFWVPcwxaaGD3p/nE9V8qY=
10
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
9
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
10
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
11
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1 h1:ovcRKN1iXZnY5WApVg+0Hw2RkwMH0ziA7lSAA8vellU=
12
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1/go.mod h1:5PtGi4r/PjEVBBl+0xWuQn4mBEjr9h6xsfDBADS6cHs=
11
13
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
12
14
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
15
+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
16
+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
17
+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
18
+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
13
19
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
14
20
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
21
+
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
22
+
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15
23
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
16
24
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
25
+
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
26
+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
27
+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
17
28
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
18
29
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
19
30
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
31
+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
20
32
github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0=
21
33
github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis=
22
34
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
···
25
37
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
26
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
27
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
40
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
41
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
28
42
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
29
43
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
30
44
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
31
45
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
46
+
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
47
+
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
48
+
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
49
+
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
32
50
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
51
+
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
33
52
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
34
53
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
35
54
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
36
55
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
56
+
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
57
+
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
37
58
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
38
59
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
60
+
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
39
61
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
40
62
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
41
63
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
···
44
66
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
45
67
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
46
68
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
69
+
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
70
+
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
71
+
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
72
+
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
73
+
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
74
+
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
75
+
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
76
+
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
77
+
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
78
+
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
79
+
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
80
+
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
81
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
82
+
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
83
+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
47
84
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
48
85
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
49
86
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
50
87
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
88
+
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
51
89
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
52
90
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
53
91
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
···
67
105
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
68
106
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
69
107
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
108
+
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
70
109
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
71
110
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
111
+
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
72
112
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
73
113
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
74
114
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
···
120
160
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
121
161
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
122
162
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
123
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
124
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4/go.mod h1:6nkFF8OmR5wLKBzRKi7/YFJpyYR7+oEn1DX+mMWnlLA=
163
+
github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc=
164
+
github.com/ipld/go-car v0.6.2/go.mod h1:oEGXdwp6bmxJCZ+rARSkDliTeYnVzv3++eXajZ+Bmr8=
125
165
github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4=
126
166
github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo=
127
167
github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc=
···
149
189
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
150
190
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
151
191
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
192
+
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
193
+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
194
+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
152
195
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
153
196
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
154
197
github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8=
155
198
github.com/koron/go-ssdp v0.0.3/go.mod h1:b2MxI6yh02pKrsyNoQUsk4+YNikaGhe4894J+Q5lDvA=
156
199
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
200
+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
157
201
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
158
202
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
159
203
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
···
229
273
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
230
274
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
231
275
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
276
+
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
277
+
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
278
+
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
279
+
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
280
+
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
281
+
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
282
+
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
283
+
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
284
+
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
285
+
github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU=
286
+
github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk=
287
+
github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0=
288
+
github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo=
289
+
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
290
+
github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo=
291
+
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
292
+
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
293
+
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
294
+
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
295
+
github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo=
296
+
github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc=
297
+
github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM=
298
+
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
299
+
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
300
+
github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y=
301
+
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
232
302
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
233
303
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
234
304
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
···
244
314
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
245
315
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
246
316
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
247
-
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
248
-
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
249
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
250
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
317
+
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
318
+
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
319
+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
320
+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
321
+
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
322
+
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
323
+
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
251
324
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
325
+
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
252
326
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
253
327
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
254
328
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
···
265
339
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
266
340
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
267
341
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
342
+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
268
343
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
269
344
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
270
345
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
271
346
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
347
+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
272
348
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
273
349
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
274
350
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
275
351
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
352
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
276
353
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
277
354
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
278
355
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
···
283
360
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
284
361
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
285
362
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
363
+
github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI=
364
+
github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q=
365
+
github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
366
+
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
367
+
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
368
+
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
369
+
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
286
370
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
287
371
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
288
372
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
···
300
384
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
301
385
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
302
386
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
387
+
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
303
388
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
304
389
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
305
390
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
···
311
396
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
312
397
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
313
398
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
399
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
400
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
314
401
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
315
402
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
403
+
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
404
+
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
316
405
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
317
406
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
318
407
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
336
425
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
337
426
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
338
427
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
428
+
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
339
429
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
340
430
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
341
431
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
···
346
436
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
347
437
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
348
438
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
439
+
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
349
440
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
441
+
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
442
+
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
350
443
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
351
444
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
352
445
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
446
+
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
353
447
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
354
448
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
355
449
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
356
450
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
451
+
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
357
452
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
358
453
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
359
454
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
455
+
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
456
+
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
457
+
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
458
+
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
360
459
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
460
+
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
461
+
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
462
+
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
463
+
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
361
464
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
362
465
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
363
466
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
364
467
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
468
+
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
365
469
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
366
470
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
367
471
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
370
474
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
371
475
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
372
476
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
477
+
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
373
478
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
374
479
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
480
+
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
481
+
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
482
+
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
483
+
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
484
+
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
375
485
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
376
486
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
487
+
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
377
488
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
489
+
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
378
490
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
379
491
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
380
492
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
493
+
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
494
+
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
495
+
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
381
496
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
497
+
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
382
498
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
383
499
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
384
500
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
501
+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
502
+
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
503
+
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
504
+
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
385
505
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
386
506
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
387
507
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
390
510
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
391
511
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
392
512
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
513
+
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
514
+
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
515
+
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
516
+
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
393
517
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
394
518
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
395
519
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
396
520
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
397
521
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
522
+
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
398
523
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
524
+
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
525
+
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
526
+
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
399
527
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
400
528
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
401
529
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
···
411
539
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
412
540
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
413
541
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
542
+
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
414
543
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
415
544
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
545
+
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
416
546
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
547
+
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
548
+
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
417
549
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
418
550
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
419
551
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
···
423
555
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
424
556
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
425
557
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
558
+
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
559
+
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
560
+
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
561
+
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
562
+
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
563
+
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
564
+
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
565
+
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
566
+
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
426
567
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
427
568
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
428
569
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
429
570
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
571
+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
430
572
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
431
573
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
432
574
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
575
+
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
576
+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
577
+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
433
578
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
579
+
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
434
580
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
581
+
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
435
582
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
436
583
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
437
584
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+157
-87
handlers.go
+157
-87
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"
18
18
"github.com/whyrusleeping/market/models"
19
+
20
+
"github.com/whyrusleeping/konbini/backend"
21
+
. "github.com/whyrusleeping/konbini/models"
19
22
)
20
23
21
24
func (s *Server) runApiServer() error {
···
23
26
e := echo.New()
24
27
e.Use(middleware.CORS())
25
28
e.GET("/debug", s.handleGetDebugInfo)
29
+
e.GET("/reldids", s.handleGetRelevantDids)
30
+
e.GET("/rescan/:did", s.handleRescanDid)
26
31
27
32
views := e.Group("/api")
28
33
views.GET("/me", s.handleGetMe)
···
50
55
})
51
56
}
52
57
58
+
func (s *Server) handleGetRelevantDids(e echo.Context) error {
59
+
return e.JSON(200, map[string]any{
60
+
"dids": s.backend.GetRelevantDids(),
61
+
})
62
+
}
63
+
64
+
func (s *Server) handleRescanDid(e echo.Context) error {
65
+
didparam := e.Param("did")
66
+
67
+
ctx := e.Request().Context()
68
+
did, err := s.resolveAccountIdent(ctx, didparam)
69
+
if err != nil {
70
+
return err
71
+
}
72
+
73
+
if err := s.rescanRepo(ctx, did); err != nil {
74
+
return err
75
+
}
76
+
77
+
return e.JSON(200, map[string]any{"status": "ok"})
78
+
}
79
+
53
80
func (s *Server) handleGetMe(e echo.Context) error {
54
81
ctx := e.Request().Context()
55
82
···
79
106
80
107
postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
81
108
82
-
p, err := s.backend.getPostByUri(ctx, postUri, "*")
109
+
p, err := s.backend.GetPostByUri(ctx, postUri, "*")
83
110
if err != nil {
84
111
return err
85
112
}
···
108
135
return err
109
136
}
110
137
111
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
138
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
112
139
if err != nil {
113
140
return err
114
141
}
115
142
116
143
var profile models.Profile
117
-
if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil {
144
+
if err := s.db.Find(&profile, "repo = ?", r.ID).Error; err != nil {
118
145
return err
119
146
}
120
147
121
148
if profile.Raw == nil || len(profile.Raw) == 0 {
122
-
s.addMissingProfile(ctx, accdid)
149
+
s.backend.TrackMissingRecord(accdid, false)
123
150
return e.JSON(404, map[string]any{
124
151
"error": "missing profile info for user",
125
152
})
···
143
170
return err
144
171
}
145
172
146
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
173
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
147
174
if err != nil {
148
175
return err
149
176
}
···
162
189
}
163
190
164
191
var dbposts []models.Post
165
-
if err := s.backend.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil {
192
+
if err := s.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil {
166
193
return err
167
194
}
168
195
···
232
259
func (s *Server) handleGetFollowingFeed(e echo.Context) error {
233
260
ctx := e.Request().Context()
234
261
235
-
myr, err := s.backend.getOrCreateRepo(ctx, s.mydid)
262
+
myr, err := s.backend.GetOrCreateRepo(ctx, s.mydid)
236
263
if err != nil {
237
264
return err
238
265
}
···
250
277
tcursor = t
251
278
}
252
279
var dbposts []models.Post
253
-
if err := s.backend.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil {
280
+
if err := s.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil {
254
281
return err
255
282
}
256
283
···
270
297
271
298
func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) {
272
299
var profile models.Profile
273
-
if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil {
300
+
if err := s.db.Find(&profile, "repo = ?", r.ID).Error; err != nil {
274
301
return nil, err
275
302
}
276
303
···
280
307
}
281
308
282
309
if profile.Raw == nil || len(profile.Raw) == 0 {
283
-
s.addMissingProfile(ctx, r.Did)
310
+
s.backend.TrackMissingRecord(r.Did, false)
284
311
return &authorInfo{
285
312
Handle: resp.Handle.String(),
286
313
Did: r.Did,
···
307
334
308
335
go func() {
309
336
defer wg.Done()
310
-
if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil {
337
+
if err := s.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil {
311
338
slog.Error("failed to get likes count", "post", pid, "error", err)
312
339
}
313
340
}()
314
341
315
342
go func() {
316
343
defer wg.Done()
317
-
if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil {
344
+
if err := s.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil {
318
345
slog.Error("failed to get reposts count", "post", pid, "error", err)
319
346
}
320
347
}()
321
348
322
349
go func() {
323
350
defer wg.Done()
324
-
if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil {
351
+
if err := s.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil {
325
352
slog.Error("failed to get replies count", "post", pid, "error", err)
326
353
}
327
354
}()
···
340
367
go func(ix int) {
341
368
defer wg.Done()
342
369
p := dbposts[ix]
343
-
r, err := s.backend.getRepoByID(ctx, p.Author)
370
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
344
371
if err != nil {
345
372
fmt.Println("failed to get repo: ", err)
346
373
posts[ix] = postResponse{
···
352
379
353
380
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey)
354
381
if len(p.Raw) == 0 || p.NotFound {
355
-
s.addMissingPost(ctx, uri)
382
+
s.backend.TrackMissingRecord(uri, false)
356
383
posts[ix] = postResponse{
357
384
Uri: uri,
358
385
Missing: true,
···
408
435
409
436
func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike {
410
437
var like Like
411
-
if err := s.backend.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil {
438
+
if err := s.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil {
412
439
slog.Error("failed to lookup like", "error", err)
413
440
return nil
414
441
}
···
437
464
view.Langs = fp.Langs
438
465
}
439
466
440
-
// Hydrate embed if present
441
467
if fp.Embed != nil {
442
-
slog.Info("processing embed", "hasImages", fp.Embed.EmbedImages != nil, "hasExternal", fp.Embed.EmbedExternal != nil, "hasRecord", fp.Embed.EmbedRecord != nil)
443
-
if fp.Embed.EmbedImages != nil {
444
-
view.Embed = fp.Embed.EmbedImages
445
-
} else if fp.Embed.EmbedExternal != nil {
446
-
view.Embed = fp.Embed.EmbedExternal
447
-
} else if fp.Embed.EmbedRecord != nil {
448
-
// Hydrate quoted post
449
-
quotedURI := fp.Embed.EmbedRecord.Record.Uri
450
-
quotedCid := fp.Embed.EmbedRecord.Record.Cid
451
-
slog.Info("hydrating quoted post", "uri", quotedURI, "cid", quotedCid)
468
+
view.Embed = s.hydrateEmbed(ctx, fp.Embed)
469
+
}
452
470
453
-
quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*")
454
-
if err != nil {
455
-
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
456
-
}
457
-
if err == nil && quotedPost != nil && quotedPost.Raw != nil && len(quotedPost.Raw) > 0 && !quotedPost.NotFound {
458
-
slog.Info("found quoted post, hydrating")
459
-
var quotedFP bsky.FeedPost
460
-
if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err == nil {
461
-
quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author)
462
-
if err == nil {
463
-
quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo)
464
-
if err == nil {
465
-
view.Embed = map[string]interface{}{
466
-
"$type": "app.bsky.embed.record",
467
-
"record": &embedRecordView{
468
-
Type: "app.bsky.embed.record#viewRecord",
469
-
Uri: quotedURI,
470
-
Cid: quotedCid,
471
-
Author: quotedAuthor,
472
-
Value: "edFP,
473
-
},
474
-
}
475
-
}
476
-
}
477
-
}
478
-
}
471
+
return view
472
+
}
479
473
480
-
// Fallback if hydration failed - show basic info
481
-
if view.Embed == nil {
482
-
slog.Info("quoted post not in database, using fallback")
483
-
view.Embed = map[string]interface{}{
484
-
"$type": "app.bsky.embed.record",
485
-
"record": map[string]interface{}{
486
-
"uri": quotedURI,
487
-
"cid": quotedCid,
488
-
},
489
-
}
490
-
}
474
+
func (s *Server) hydrateEmbed(ctx context.Context, embed *bsky.FeedPost_Embed) interface{} {
475
+
switch {
476
+
case embed.EmbedImages != nil:
477
+
return embed.EmbedImages
478
+
case embed.EmbedExternal != nil:
479
+
return embed.EmbedExternal
480
+
case embed.EmbedRecord != nil:
481
+
return s.hydrateQuotedPost(ctx, embed.EmbedRecord)
482
+
case embed.EmbedRecordWithMedia != nil:
483
+
return s.hydrateRecordWithMedia(ctx, embed.EmbedRecordWithMedia)
484
+
default:
485
+
return nil
486
+
}
487
+
}
488
+
489
+
func (s *Server) hydrateRecordWithMedia(ctx context.Context, rwm *bsky.EmbedRecordWithMedia) interface{} {
490
+
result := map[string]interface{}{
491
+
"$type": "app.bsky.embed.recordWithMedia",
492
+
}
493
+
494
+
// Hydrate media
495
+
if rwm.Media != nil {
496
+
if rwm.Media.EmbedImages != nil {
497
+
result["media"] = rwm.Media.EmbedImages
498
+
} else if rwm.Media.EmbedExternal != nil {
499
+
result["media"] = rwm.Media.EmbedExternal
491
500
}
492
501
}
493
502
494
-
return view
503
+
// Hydrate record
504
+
if rwm.Record != nil {
505
+
result["record"] = s.hydrateQuotedPost(ctx, rwm.Record)
506
+
}
507
+
508
+
return result
509
+
}
510
+
511
+
func (s *Server) hydrateQuotedPost(ctx context.Context, embedRecord *bsky.EmbedRecord) interface{} {
512
+
quotedURI := embedRecord.Record.Uri
513
+
quotedCid := embedRecord.Record.Cid
514
+
515
+
quotedPost, err := s.backend.GetPostByUri(ctx, quotedURI, "*")
516
+
if err != nil {
517
+
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
518
+
s.backend.TrackMissingRecord(quotedURI, false)
519
+
return s.buildQuoteFallback(quotedURI, quotedCid)
520
+
}
521
+
522
+
if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound {
523
+
s.backend.TrackMissingRecord(quotedURI, false)
524
+
return s.buildQuoteFallback(quotedURI, quotedCid)
525
+
}
526
+
527
+
var quotedFP bsky.FeedPost
528
+
if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err != nil {
529
+
slog.Warn("failed to unmarshal quoted post", "error", err)
530
+
return s.buildQuoteFallback(quotedURI, quotedCid)
531
+
}
532
+
533
+
quotedRepo, err := s.backend.GetRepoByID(ctx, quotedPost.Author)
534
+
if err != nil {
535
+
slog.Warn("failed to get quoted post author", "error", err)
536
+
return s.buildQuoteFallback(quotedURI, quotedCid)
537
+
}
538
+
539
+
quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo)
540
+
if err != nil {
541
+
slog.Warn("failed to get quoted post author info", "error", err)
542
+
return s.buildQuoteFallback(quotedURI, quotedCid)
543
+
}
544
+
545
+
return map[string]interface{}{
546
+
"$type": "app.bsky.embed.record",
547
+
"record": &embedRecordView{
548
+
Type: "app.bsky.embed.record#viewRecord",
549
+
Uri: quotedURI,
550
+
Cid: quotedCid,
551
+
Author: quotedAuthor,
552
+
Value: "edFP,
553
+
},
554
+
}
555
+
}
556
+
557
+
func (s *Server) buildQuoteFallback(uri, cid string) map[string]interface{} {
558
+
return map[string]interface{}{
559
+
"$type": "app.bsky.embed.record",
560
+
"record": map[string]interface{}{
561
+
"uri": uri,
562
+
"cid": cid,
563
+
},
564
+
}
495
565
}
496
566
497
567
func (s *Server) handleGetThread(e echo.Context) error {
···
507
577
508
578
// Get the requested post to find the thread root
509
579
var requestedPost models.Post
510
-
if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
580
+
if err := s.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
511
581
return err
512
582
}
513
583
···
526
596
// Get all posts in this thread
527
597
var dbposts []models.Post
528
598
query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC"
529
-
if err := s.backend.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil {
599
+
if err := s.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil {
530
600
return err
531
601
}
532
602
533
603
// Build response for each post
534
604
posts := []postResponse{}
535
605
for _, p := range dbposts {
536
-
r, err := s.backend.getRepoByID(ctx, p.Author)
606
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
537
607
if err != nil {
538
608
return err
539
609
}
···
607
677
608
678
// Get all likes for this post
609
679
var likes []models.Like
610
-
if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil {
680
+
if err := s.db.Find(&likes, "subject = ?", postID).Error; err != nil {
611
681
return err
612
682
}
613
683
614
684
users := []engagementUser{}
615
685
for _, like := range likes {
616
-
r, err := s.backend.getRepoByID(ctx, like.Author)
686
+
r, err := s.backend.GetRepoByID(ctx, like.Author)
617
687
if err != nil {
618
688
slog.Error("failed to get repo for like author", "error", err)
619
689
continue
···
628
698
629
699
// Get profile if available
630
700
var profile models.Profile
631
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
701
+
s.db.Find(&profile, "repo = ?", r.ID)
632
702
633
703
var prof *bsky.ActorProfile
634
704
if len(profile.Raw) > 0 {
···
637
707
prof = &p
638
708
}
639
709
} else {
640
-
s.addMissingProfile(ctx, r.Did)
710
+
s.backend.TrackMissingRecord(r.Did, false)
641
711
}
642
712
643
713
users = append(users, engagementUser{
···
667
737
668
738
// Get all reposts for this post
669
739
var reposts []models.Repost
670
-
if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
740
+
if err := s.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
671
741
return err
672
742
}
673
743
674
744
users := []engagementUser{}
675
745
for _, repost := range reposts {
676
-
r, err := s.backend.getRepoByID(ctx, repost.Author)
746
+
r, err := s.backend.GetRepoByID(ctx, repost.Author)
677
747
if err != nil {
678
748
slog.Error("failed to get repo for repost author", "error", err)
679
749
continue
···
688
758
689
759
// Get profile if available
690
760
var profile models.Profile
691
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
761
+
s.db.Find(&profile, "repo = ?", r.ID)
692
762
693
763
var prof *bsky.ActorProfile
694
764
if len(profile.Raw) > 0 {
···
697
767
prof = &p
698
768
}
699
769
} else {
700
-
s.addMissingProfile(ctx, r.Did)
770
+
s.backend.TrackMissingRecord(r.Did, false)
701
771
}
702
772
703
773
users = append(users, engagementUser{
···
727
797
728
798
// Get all replies to this post
729
799
var replies []models.Post
730
-
if err := s.backend.db.Find(&replies, "reply_to = ?", postID).Error; err != nil {
800
+
if err := s.db.Find(&replies, "reply_to = ?", postID).Error; err != nil {
731
801
return err
732
802
}
733
803
···
741
811
}
742
812
seen[reply.Author] = true
743
813
744
-
r, err := s.backend.getRepoByID(ctx, reply.Author)
814
+
r, err := s.backend.GetRepoByID(ctx, reply.Author)
745
815
if err != nil {
746
816
slog.Error("failed to get repo for reply author", "error", err)
747
817
continue
···
756
826
757
827
// Get profile if available
758
828
var profile models.Profile
759
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
829
+
s.db.Find(&profile, "repo = ?", r.ID)
760
830
761
831
var prof *bsky.ActorProfile
762
832
if len(profile.Raw) > 0 {
···
765
835
prof = &p
766
836
}
767
837
} else {
768
-
s.addMissingProfile(ctx, r.Did)
838
+
s.backend.TrackMissingRecord(r.Did, false)
769
839
}
770
840
771
841
users = append(users, engagementUser{
···
819
889
}
820
890
821
891
var resp createRecordResponse
822
-
if err := s.client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil {
892
+
if err := s.client.Do(ctx, xrpclib.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil {
823
893
slog.Error("failed to create record", "error", err)
824
894
return e.JSON(500, map[string]any{
825
895
"error": "failed to create record",
···
863
933
query := `SELECT * FROM notifications WHERE "for" = ?`
864
934
if cursorID > 0 {
865
935
query += ` AND id < ?`
866
-
if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(¬ifications).Error; err != nil {
936
+
if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(¬ifications).Error; err != nil {
867
937
return err
868
938
}
869
939
} else {
870
-
if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(¬ifications).Error; err != nil {
940
+
if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(¬ifications).Error; err != nil {
871
941
return err
872
942
}
873
943
}
···
876
946
results := []notificationResponse{}
877
947
for _, notif := range notifications {
878
948
// Get author info
879
-
author, err := s.backend.getRepoByID(ctx, notif.Author)
949
+
author, err := s.backend.GetRepoByID(ctx, notif.Author)
880
950
if err != nil {
881
951
slog.Error("failed to get repo for notification author", "error", err)
882
952
continue
···
897
967
}
898
968
899
969
// Try to get source post preview for reply/mention notifications
900
-
if notif.Kind == NotifKindReply || notif.Kind == NotifKindMention {
970
+
if notif.Kind == backend.NotifKindReply || notif.Kind == backend.NotifKindMention {
901
971
// Parse URI to get post
902
-
p, err := s.backend.getPostByUri(ctx, notif.Source, "*")
972
+
p, err := s.backend.GetPostByUri(ctx, notif.Source, "*")
903
973
if err == nil && p.Raw != nil && len(p.Raw) > 0 {
904
974
var fp bsky.FeedPost
905
975
if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err == nil {
+263
hydration/actor.go
+263
hydration/actor.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"strings"
9
+
"sync"
10
+
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/whyrusleeping/market/models"
14
+
)
15
+
16
+
// ActorInfo contains hydrated actor information
17
+
type ActorInfo struct {
18
+
DID string
19
+
Handle string
20
+
Profile *bsky.ActorProfile
21
+
}
22
+
23
+
// HydrateActor hydrates full actor information
24
+
func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) {
25
+
ctx, span := tracer.Start(ctx, "hydrateActor")
26
+
defer span.End()
27
+
28
+
// Look up handle
29
+
resp, err := h.dir.LookupDID(ctx, syntax.DID(did))
30
+
if err != nil {
31
+
return nil, fmt.Errorf("failed to lookup DID: %w", err)
32
+
}
33
+
34
+
info := &ActorInfo{
35
+
DID: did,
36
+
Handle: resp.Handle.String(),
37
+
}
38
+
39
+
// Load profile from database
40
+
var dbProfile struct {
41
+
Repo uint
42
+
Raw []byte
43
+
}
44
+
err = h.db.Raw("SELECT repo, raw FROM profiles WHERE repo = (SELECT id FROM repos WHERE did = ?)", did).
45
+
Scan(&dbProfile).Error
46
+
if err != nil {
47
+
slog.Error("failed to fetch user profile", "error", err)
48
+
} else {
49
+
if len(dbProfile.Raw) > 0 {
50
+
var profile bsky.ActorProfile
51
+
if err := profile.UnmarshalCBOR(bytes.NewReader(dbProfile.Raw)); err == nil {
52
+
info.Profile = &profile
53
+
}
54
+
} else {
55
+
h.addMissingActor(did)
56
+
}
57
+
}
58
+
59
+
return info, nil
60
+
}
61
+
62
+
type ActorInfoDetailed struct {
63
+
ActorInfo
64
+
FollowCount int64
65
+
FollowerCount int64
66
+
PostCount int64
67
+
ViewerState *bsky.ActorDefs_ViewerState
68
+
}
69
+
70
+
func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) {
71
+
act, err := h.HydrateActor(ctx, did)
72
+
if err != nil {
73
+
return nil, err
74
+
}
75
+
76
+
actd := ActorInfoDetailed{
77
+
ActorInfo: *act,
78
+
}
79
+
80
+
var wg sync.WaitGroup
81
+
wg.Go(func() {
82
+
c, err := h.getFollowCountForUser(ctx, did)
83
+
if err != nil {
84
+
slog.Error("failed to get follow count", "did", did, "error", err)
85
+
}
86
+
actd.FollowCount = c
87
+
})
88
+
wg.Go(func() {
89
+
c, err := h.getFollowerCountForUser(ctx, did)
90
+
if err != nil {
91
+
slog.Error("failed to get follower count", "did", did, "error", err)
92
+
}
93
+
actd.FollowerCount = c
94
+
})
95
+
wg.Go(func() {
96
+
c, err := h.getPostCountForUser(ctx, did)
97
+
if err != nil {
98
+
slog.Error("failed to get post count", "did", did, "error", err)
99
+
}
100
+
actd.PostCount = c
101
+
})
102
+
103
+
if viewer != "" {
104
+
wg.Go(func() {
105
+
vs, err := h.getProfileViewerState(ctx, did, viewer)
106
+
if err != nil {
107
+
slog.Error("failed to get viewer state", "did", did, "viewer", viewer, "error", err)
108
+
}
109
+
actd.ViewerState = vs
110
+
})
111
+
}
112
+
113
+
wg.Wait()
114
+
115
+
return &actd, nil
116
+
}
117
+
118
+
func (h *Hydrator) getProfileViewerState(ctx context.Context, did, viewer string) (*bsky.ActorDefs_ViewerState, error) {
119
+
vs := &bsky.ActorDefs_ViewerState{}
120
+
121
+
var wg sync.WaitGroup
122
+
123
+
// Check if viewer is blocked by the target account
124
+
wg.Go(func() {
125
+
blockedBy, err := h.getBlockPair(ctx, did, viewer)
126
+
if err != nil {
127
+
slog.Error("failed to get blockedBy relationship", "did", did, "viewer", viewer, "error", err)
128
+
return
129
+
}
130
+
131
+
if blockedBy != nil {
132
+
v := true
133
+
vs.BlockedBy = &v
134
+
}
135
+
})
136
+
137
+
// Check if viewer is blocking the target account
138
+
wg.Go(func() {
139
+
blocking, err := h.getBlockPair(ctx, viewer, did)
140
+
if err != nil {
141
+
slog.Error("failed to get blocking relationship", "did", did, "viewer", viewer, "error", err)
142
+
return
143
+
}
144
+
145
+
if blocking != nil {
146
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.block/%s", viewer, blocking.Rkey)
147
+
vs.Blocking = &uri
148
+
}
149
+
})
150
+
151
+
// Check if viewer is following the target account
152
+
wg.Go(func() {
153
+
following, err := h.getFollowPair(ctx, viewer, did)
154
+
if err != nil {
155
+
slog.Error("failed to get following relationship", "did", did, "viewer", viewer, "error", err)
156
+
return
157
+
}
158
+
159
+
if following != nil {
160
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", viewer, following.Rkey)
161
+
vs.Following = &uri
162
+
}
163
+
})
164
+
165
+
// Check if target account is following the viewer
166
+
wg.Go(func() {
167
+
followedBy, err := h.getFollowPair(ctx, did, viewer)
168
+
if err != nil {
169
+
slog.Error("failed to get followedBy relationship", "did", did, "viewer", viewer, "error", err)
170
+
return
171
+
}
172
+
173
+
if followedBy != nil {
174
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", did, followedBy.Rkey)
175
+
vs.FollowedBy = &uri
176
+
}
177
+
})
178
+
179
+
wg.Wait()
180
+
181
+
return vs, nil
182
+
}
183
+
184
+
func (h *Hydrator) getBlockPair(ctx context.Context, a, b string) (*models.Block, error) {
185
+
var blk models.Block
186
+
if err := h.db.Raw("SELECT * FROM blocks WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&blk).Error; err != nil {
187
+
return nil, err
188
+
}
189
+
if blk.ID == 0 {
190
+
return nil, nil
191
+
}
192
+
193
+
return &blk, nil
194
+
}
195
+
196
+
func (h *Hydrator) getFollowPair(ctx context.Context, a, b string) (*models.Follow, error) {
197
+
var fol models.Follow
198
+
if err := h.db.Raw("SELECT * FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&fol).Error; err != nil {
199
+
return nil, err
200
+
}
201
+
if fol.ID == 0 {
202
+
return nil, nil
203
+
}
204
+
205
+
return &fol, nil
206
+
}
207
+
208
+
func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) {
209
+
var count int64
210
+
if err := h.db.Raw("SELECT count(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
211
+
return 0, err
212
+
}
213
+
214
+
return count, nil
215
+
}
216
+
217
+
func (h *Hydrator) getFollowerCountForUser(ctx context.Context, did string) (int64, error) {
218
+
var count int64
219
+
if err := h.db.Raw("SELECT count(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
220
+
return 0, err
221
+
}
222
+
223
+
return count, nil
224
+
}
225
+
226
+
func (h *Hydrator) getPostCountForUser(ctx context.Context, did string) (int64, error) {
227
+
var count int64
228
+
if err := h.db.Raw("SELECT count(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
229
+
return 0, err
230
+
}
231
+
232
+
return count, nil
233
+
}
234
+
235
+
// HydrateActors hydrates multiple actors
236
+
func (h *Hydrator) HydrateActors(ctx context.Context, dids []string) (map[string]*ActorInfo, error) {
237
+
result := make(map[string]*ActorInfo, len(dids))
238
+
for _, did := range dids {
239
+
info, err := h.HydrateActor(ctx, did)
240
+
if err != nil {
241
+
// Skip actors that fail to hydrate rather than failing the whole batch
242
+
continue
243
+
}
244
+
result[did] = info
245
+
}
246
+
return result, nil
247
+
}
248
+
249
+
// ResolveDID resolves a handle or DID to a DID
250
+
func (h *Hydrator) ResolveDID(ctx context.Context, actor string) (string, error) {
251
+
// If it's already a DID, return it
252
+
if strings.HasPrefix(actor, "did:") {
253
+
return actor, nil
254
+
}
255
+
256
+
// Otherwise, resolve the handle
257
+
resp, err := h.dir.LookupHandle(ctx, syntax.Handle(actor))
258
+
if err != nil {
259
+
return "", fmt.Errorf("failed to resolve handle: %w", err)
260
+
}
261
+
262
+
return resp.DID.String(), nil
263
+
}
+47
hydration/hydrator.go
+47
hydration/hydrator.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"github.com/bluesky-social/indigo/atproto/identity"
5
+
"github.com/whyrusleeping/konbini/backend"
6
+
"gorm.io/gorm"
7
+
)
8
+
9
+
// Hydrator handles data hydration from the database
10
+
type Hydrator struct {
11
+
db *gorm.DB
12
+
dir identity.Directory
13
+
backend *backend.PostgresBackend
14
+
}
15
+
16
+
// NewHydrator creates a new Hydrator
17
+
func NewHydrator(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Hydrator {
18
+
return &Hydrator{
19
+
db: db,
20
+
dir: dir,
21
+
backend: backend,
22
+
}
23
+
}
24
+
25
+
// AddMissingRecord reports a missing record that needs to be fetched
26
+
func (h *Hydrator) AddMissingRecord(identifier string, wait bool) {
27
+
if h.backend != nil {
28
+
h.backend.TrackMissingRecord(identifier, wait)
29
+
}
30
+
}
31
+
32
+
// addMissingActor is a convenience method for adding missing actors
33
+
func (h *Hydrator) addMissingActor(did string) {
34
+
h.AddMissingRecord(did, false)
35
+
}
36
+
37
+
// HydrateCtx contains context for hydration operations
38
+
type HydrateCtx struct {
39
+
Viewer string
40
+
}
41
+
42
+
// NewHydrateCtx creates a new hydration context
43
+
func NewHydrateCtx(viewer string) *HydrateCtx {
44
+
return &HydrateCtx{
45
+
Viewer: viewer,
46
+
}
47
+
}
+501
hydration/post.go
+501
hydration/post.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"sync"
9
+
10
+
"github.com/bluesky-social/indigo/api/bsky"
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
"github.com/whyrusleeping/market/models"
13
+
"go.opentelemetry.io/otel"
14
+
)
15
+
16
+
var tracer = otel.Tracer("hydrator")
17
+
18
+
// PostInfo contains hydrated post information
19
+
type PostInfo struct {
20
+
ID uint
21
+
URI string
22
+
Cid string
23
+
Post *bsky.FeedPost
24
+
Author string // DID
25
+
ReplyTo uint
26
+
ReplyToUsr uint
27
+
InThread uint
28
+
LikeCount int
29
+
RepostCount int
30
+
ReplyCount int
31
+
ViewerLike string // URI of viewer's like, if any
32
+
33
+
EmbedInfo *bsky.FeedDefs_PostView_Embed
34
+
}
35
+
36
+
const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu"
37
+
38
+
// HydratePost hydrates a single post by URI
39
+
func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) {
40
+
ctx, span := tracer.Start(ctx, "hydratePost")
41
+
defer span.End()
42
+
43
+
p, err := h.backend.GetPostByUri(ctx, uri, "*")
44
+
if err != nil {
45
+
return nil, err
46
+
}
47
+
48
+
return h.HydratePostDB(ctx, uri, p, viewerDID)
49
+
}
50
+
51
+
func (h *Hydrator) HydratePostDB(ctx context.Context, uri string, dbPost *models.Post, viewerDID string) (*PostInfo, error) {
52
+
autoFetch, _ := ctx.Value("auto-fetch").(bool)
53
+
54
+
authorDid := extractDIDFromURI(uri)
55
+
r, err := h.backend.GetOrCreateRepo(ctx, authorDid)
56
+
if err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
if dbPost.NotFound || len(dbPost.Raw) == 0 {
61
+
if autoFetch {
62
+
h.AddMissingRecord(uri, true)
63
+
if err := h.db.Raw(`SELECT * FROM posts WHERE author = ? AND rkey = ?`, r.ID, extractRkeyFromURI(uri)).Scan(&dbPost).Error; err != nil {
64
+
return nil, fmt.Errorf("failed to query post: %w", err)
65
+
}
66
+
if dbPost.NotFound || len(dbPost.Raw) == 0 {
67
+
return nil, fmt.Errorf("post not found")
68
+
}
69
+
} else {
70
+
return nil, fmt.Errorf("post not found")
71
+
}
72
+
}
73
+
74
+
// Unmarshal post record
75
+
var feedPost bsky.FeedPost
76
+
if err := feedPost.UnmarshalCBOR(bytes.NewReader(dbPost.Raw)); err != nil {
77
+
return nil, fmt.Errorf("failed to unmarshal post: %w", err)
78
+
}
79
+
80
+
var wg sync.WaitGroup
81
+
82
+
authorDID := r.Did
83
+
84
+
// Get engagement counts
85
+
var likes, reposts, replies int
86
+
wg.Go(func() {
87
+
_, span := tracer.Start(ctx, "likeCounts")
88
+
defer span.End()
89
+
h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes)
90
+
})
91
+
wg.Go(func() {
92
+
_, span := tracer.Start(ctx, "repostCounts")
93
+
defer span.End()
94
+
h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts)
95
+
})
96
+
wg.Go(func() {
97
+
_, span := tracer.Start(ctx, "replyCounts")
98
+
defer span.End()
99
+
h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies)
100
+
})
101
+
102
+
// Check if viewer liked this post
103
+
var likeRkey string
104
+
if viewerDID != "" {
105
+
wg.Go(func() {
106
+
_, span := tracer.Start(ctx, "viewerLikeState")
107
+
defer span.End()
108
+
h.db.Raw(`
109
+
SELECT l.rkey FROM likes l
110
+
WHERE l.subject = ?
111
+
AND l.author = (SELECT id FROM repos WHERE did = ?)
112
+
`, dbPost.ID, viewerDID).Scan(&likeRkey)
113
+
})
114
+
}
115
+
116
+
var ei *bsky.FeedDefs_PostView_Embed
117
+
if feedPost.Embed != nil {
118
+
wg.Go(func() {
119
+
ei = h.formatEmbed(ctx, feedPost.Embed, authorDID, viewerDID)
120
+
})
121
+
}
122
+
123
+
wg.Wait()
124
+
125
+
info := &PostInfo{
126
+
ID: dbPost.ID,
127
+
URI: uri,
128
+
Cid: dbPost.Cid,
129
+
Post: &feedPost,
130
+
Author: authorDID,
131
+
ReplyTo: dbPost.ReplyTo,
132
+
ReplyToUsr: dbPost.ReplyToUsr,
133
+
InThread: dbPost.InThread,
134
+
LikeCount: likes,
135
+
RepostCount: reposts,
136
+
ReplyCount: replies,
137
+
EmbedInfo: ei,
138
+
}
139
+
140
+
if likeRkey != "" {
141
+
info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey)
142
+
}
143
+
144
+
if info.Cid == "" {
145
+
slog.Error("MISSING CID", "uri", uri)
146
+
info.Cid = fakeCid
147
+
}
148
+
149
+
// Hydrate embed
150
+
151
+
return info, nil
152
+
}
153
+
154
+
// HydratePosts hydrates multiple posts
155
+
func (h *Hydrator) HydratePosts(ctx context.Context, uris []string, viewerDID string) (map[string]*PostInfo, error) {
156
+
result := make(map[string]*PostInfo, len(uris))
157
+
for _, uri := range uris {
158
+
info, err := h.HydratePost(ctx, uri, viewerDID)
159
+
if err != nil {
160
+
// Skip posts that fail to hydrate
161
+
continue
162
+
}
163
+
result[uri] = info
164
+
}
165
+
return result, nil
166
+
}
167
+
168
+
// Helper functions to extract DID and rkey from AT URI
169
+
func extractDIDFromURI(uri string) string {
170
+
// URI format: at://did:plc:xxx/collection/rkey
171
+
if len(uri) < 5 || uri[:5] != "at://" {
172
+
return ""
173
+
}
174
+
parts := []rune(uri[5:])
175
+
for i, r := range parts {
176
+
if r == '/' {
177
+
return string(parts[:i])
178
+
}
179
+
}
180
+
return string(parts)
181
+
}
182
+
183
+
func extractRkeyFromURI(uri string) string {
184
+
// URI format: at://did:plc:xxx/collection/rkey
185
+
if len(uri) < 5 || uri[:5] != "at://" {
186
+
return ""
187
+
}
188
+
// Find last slash
189
+
for i := len(uri) - 1; i >= 5; i-- {
190
+
if uri[i] == '/' {
191
+
return uri[i+1:]
192
+
}
193
+
}
194
+
return ""
195
+
}
196
+
197
+
func (h *Hydrator) formatEmbed(ctx context.Context, embed *bsky.FeedPost_Embed, authorDID string, viewerDID string) *bsky.FeedDefs_PostView_Embed {
198
+
if embed == nil {
199
+
return nil
200
+
}
201
+
_, span := tracer.Start(ctx, "formatEmbed")
202
+
defer span.End()
203
+
204
+
result := &bsky.FeedDefs_PostView_Embed{}
205
+
206
+
// Handle images
207
+
if embed.EmbedImages != nil {
208
+
viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedImages.Images))
209
+
for i, img := range embed.EmbedImages.Images {
210
+
// Convert blob to CDN URLs
211
+
fullsize := ""
212
+
thumb := ""
213
+
if img.Image != nil {
214
+
// CDN URL format for feed images
215
+
cid := img.Image.Ref.String()
216
+
fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid)
217
+
thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
218
+
}
219
+
220
+
viewImages[i] = &bsky.EmbedImages_ViewImage{
221
+
Alt: img.Alt,
222
+
AspectRatio: img.AspectRatio,
223
+
Fullsize: fullsize,
224
+
Thumb: thumb,
225
+
}
226
+
}
227
+
result.EmbedImages_View = &bsky.EmbedImages_View{
228
+
LexiconTypeID: "app.bsky.embed.images#view",
229
+
Images: viewImages,
230
+
}
231
+
return result
232
+
}
233
+
234
+
// Handle external links
235
+
if embed.EmbedExternal != nil && embed.EmbedExternal.External != nil {
236
+
// Convert blob thumb to CDN URL if present
237
+
var thumbURL *string
238
+
if embed.EmbedExternal.External.Thumb != nil {
239
+
// CDN URL for external link thumbnails
240
+
cid := embed.EmbedExternal.External.Thumb.Ref.String()
241
+
url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
242
+
thumbURL = &url
243
+
}
244
+
245
+
result.EmbedExternal_View = &bsky.EmbedExternal_View{
246
+
LexiconTypeID: "app.bsky.embed.external#view",
247
+
External: &bsky.EmbedExternal_ViewExternal{
248
+
Uri: embed.EmbedExternal.External.Uri,
249
+
Title: embed.EmbedExternal.External.Title,
250
+
Description: embed.EmbedExternal.External.Description,
251
+
Thumb: thumbURL,
252
+
},
253
+
}
254
+
return result
255
+
}
256
+
257
+
// Handle video
258
+
if embed.EmbedVideo != nil && embed.EmbedVideo.Video != nil {
259
+
cid := embed.EmbedVideo.Video.Ref.String()
260
+
// URL-encode the DID (replace : with %3A)
261
+
encodedDID := ""
262
+
for _, ch := range authorDID {
263
+
if ch == ':' {
264
+
encodedDID += "%3A"
265
+
} else {
266
+
encodedDID += string(ch)
267
+
}
268
+
}
269
+
270
+
playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid)
271
+
thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid)
272
+
273
+
result.EmbedVideo_View = &bsky.EmbedVideo_View{
274
+
LexiconTypeID: "app.bsky.embed.video#view",
275
+
Cid: cid,
276
+
Playlist: playlist,
277
+
Thumbnail: &thumbnail,
278
+
Alt: embed.EmbedVideo.Alt,
279
+
AspectRatio: embed.EmbedVideo.AspectRatio,
280
+
}
281
+
return result
282
+
}
283
+
284
+
// Handle record (quote posts, etc.)
285
+
if embed.EmbedRecord != nil && embed.EmbedRecord.Record != nil {
286
+
rec := embed.EmbedRecord.Record
287
+
288
+
result.EmbedRecord_View = &bsky.EmbedRecord_View{
289
+
LexiconTypeID: "app.bsky.embed.record#view",
290
+
Record: h.hydrateEmbeddedRecord(ctx, rec.Uri, viewerDID),
291
+
}
292
+
return result
293
+
}
294
+
295
+
// Handle record with media (quote post with images/external)
296
+
if embed.EmbedRecordWithMedia != nil {
297
+
recordView := &bsky.EmbedRecordWithMedia_View{
298
+
LexiconTypeID: "app.bsky.embed.recordWithMedia#view",
299
+
}
300
+
301
+
// Hydrate the record part
302
+
if embed.EmbedRecordWithMedia.Record != nil && embed.EmbedRecordWithMedia.Record.Record != nil {
303
+
recordView.Record = &bsky.EmbedRecord_View{
304
+
LexiconTypeID: "app.bsky.embed.record#view",
305
+
Record: h.hydrateEmbeddedRecord(ctx, embed.EmbedRecordWithMedia.Record.Record.Uri, viewerDID),
306
+
}
307
+
}
308
+
309
+
// Hydrate the media part (images or external)
310
+
if embed.EmbedRecordWithMedia.Media != nil {
311
+
if embed.EmbedRecordWithMedia.Media.EmbedImages != nil {
312
+
viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedRecordWithMedia.Media.EmbedImages.Images))
313
+
for i, img := range embed.EmbedRecordWithMedia.Media.EmbedImages.Images {
314
+
fullsize := ""
315
+
thumb := ""
316
+
if img.Image != nil {
317
+
cid := img.Image.Ref.String()
318
+
fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid)
319
+
thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
320
+
}
321
+
322
+
viewImages[i] = &bsky.EmbedImages_ViewImage{
323
+
Alt: img.Alt,
324
+
AspectRatio: img.AspectRatio,
325
+
Fullsize: fullsize,
326
+
Thumb: thumb,
327
+
}
328
+
}
329
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
330
+
EmbedImages_View: &bsky.EmbedImages_View{
331
+
LexiconTypeID: "app.bsky.embed.images#view",
332
+
Images: viewImages,
333
+
},
334
+
}
335
+
} else if embed.EmbedRecordWithMedia.Media.EmbedExternal != nil && embed.EmbedRecordWithMedia.Media.EmbedExternal.External != nil {
336
+
var thumbURL *string
337
+
if embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb != nil {
338
+
cid := embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb.Ref.String()
339
+
url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
340
+
thumbURL = &url
341
+
}
342
+
343
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
344
+
EmbedExternal_View: &bsky.EmbedExternal_View{
345
+
LexiconTypeID: "app.bsky.embed.external#view",
346
+
External: &bsky.EmbedExternal_ViewExternal{
347
+
Uri: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Uri,
348
+
Title: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Title,
349
+
Description: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Description,
350
+
Thumb: thumbURL,
351
+
},
352
+
},
353
+
}
354
+
} else if embed.EmbedRecordWithMedia.Media.EmbedVideo != nil && embed.EmbedRecordWithMedia.Media.EmbedVideo.Video != nil {
355
+
cid := embed.EmbedRecordWithMedia.Media.EmbedVideo.Video.Ref.String()
356
+
// URL-encode the DID (replace : with %3A)
357
+
encodedDID := ""
358
+
for _, ch := range authorDID {
359
+
if ch == ':' {
360
+
encodedDID += "%3A"
361
+
} else {
362
+
encodedDID += string(ch)
363
+
}
364
+
}
365
+
366
+
playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid)
367
+
thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid)
368
+
369
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
370
+
EmbedVideo_View: &bsky.EmbedVideo_View{
371
+
LexiconTypeID: "app.bsky.embed.video#view",
372
+
Cid: cid,
373
+
Playlist: playlist,
374
+
Thumbnail: &thumbnail,
375
+
Alt: embed.EmbedRecordWithMedia.Media.EmbedVideo.Alt,
376
+
AspectRatio: embed.EmbedRecordWithMedia.Media.EmbedVideo.AspectRatio,
377
+
},
378
+
}
379
+
}
380
+
}
381
+
382
+
result.EmbedRecordWithMedia_View = recordView
383
+
return result
384
+
}
385
+
386
+
return nil
387
+
}
388
+
389
+
// hydrateEmbeddedRecord hydrates an embedded record (for quote posts, etc.)
390
+
func (h *Hydrator) hydrateEmbeddedRecord(ctx context.Context, uri string, viewerDID string) *bsky.EmbedRecord_View_Record {
391
+
ctx, span := tracer.Start(ctx, "hydrateEmbeddedRecord")
392
+
defer span.End()
393
+
394
+
// Check if it's a post URI
395
+
if !isPostURI(uri) {
396
+
// Could be a feed generator, list, labeler, or starter pack
397
+
// For now, return not found for non-post embeds
398
+
return &bsky.EmbedRecord_View_Record{
399
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
400
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
401
+
Uri: uri,
402
+
},
403
+
}
404
+
}
405
+
406
+
// Try to hydrate the post
407
+
quotedPost, err := h.HydratePost(ctx, uri, viewerDID)
408
+
if err != nil {
409
+
// Post not found
410
+
return &bsky.EmbedRecord_View_Record{
411
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
412
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
413
+
Uri: uri,
414
+
NotFound: true,
415
+
},
416
+
}
417
+
}
418
+
419
+
// Hydrate the author
420
+
authorInfo, err := h.HydrateActor(ctx, quotedPost.Author)
421
+
if err != nil {
422
+
// Author not found, treat as not found
423
+
return &bsky.EmbedRecord_View_Record{
424
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
425
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
426
+
Uri: uri,
427
+
NotFound: true,
428
+
},
429
+
}
430
+
}
431
+
432
+
// TODO: Check if viewer has blocked or is blocked by the author
433
+
// For now, just return the record view
434
+
435
+
// Build the author profile view
436
+
authorView := &bsky.ActorDefs_ProfileViewBasic{
437
+
Did: authorInfo.DID,
438
+
Handle: authorInfo.Handle,
439
+
}
440
+
if authorInfo.Profile != nil {
441
+
if authorInfo.Profile.DisplayName != nil && *authorInfo.Profile.DisplayName != "" {
442
+
authorView.DisplayName = authorInfo.Profile.DisplayName
443
+
}
444
+
if authorInfo.Profile.Avatar != nil {
445
+
avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", authorInfo.DID, authorInfo.Profile.Avatar.Ref.String())
446
+
authorView.Avatar = &avatarURL
447
+
}
448
+
}
449
+
450
+
// Build the embedded post view
451
+
embedView := &bsky.EmbedRecord_ViewRecord{
452
+
LexiconTypeID: "app.bsky.embed.record#viewRecord",
453
+
Uri: quotedPost.URI,
454
+
Cid: quotedPost.Cid,
455
+
Author: authorView,
456
+
Value: &util.LexiconTypeDecoder{
457
+
Val: quotedPost.Post,
458
+
},
459
+
IndexedAt: quotedPost.Post.CreatedAt,
460
+
}
461
+
462
+
// Add engagement counts
463
+
if quotedPost.LikeCount > 0 {
464
+
lc := int64(quotedPost.LikeCount)
465
+
embedView.LikeCount = &lc
466
+
}
467
+
if quotedPost.RepostCount > 0 {
468
+
rc := int64(quotedPost.RepostCount)
469
+
embedView.RepostCount = &rc
470
+
}
471
+
if quotedPost.ReplyCount > 0 {
472
+
rpc := int64(quotedPost.ReplyCount)
473
+
embedView.ReplyCount = &rpc
474
+
}
475
+
476
+
// Note: We don't recursively hydrate embeds for quoted posts to avoid deep nesting
477
+
// The official app also doesn't show embeds within quoted posts
478
+
479
+
return &bsky.EmbedRecord_View_Record{
480
+
EmbedRecord_ViewRecord: embedView,
481
+
}
482
+
}
483
+
484
+
// isPostURI checks if a URI is a post URI
485
+
func isPostURI(uri string) bool {
486
+
return len(uri) > 5 && uri[:5] == "at://" && (
487
+
// Check if it contains /app.bsky.feed.post/
488
+
len(uri) > 25 && uri[len(uri)-25:len(uri)-12] == "/app.bsky.feed.post/" ||
489
+
// More flexible check
490
+
contains(uri, "/app.bsky.feed.post/"))
491
+
}
492
+
493
+
// contains checks if a string contains a substring
494
+
func contains(s, substr string) bool {
495
+
for i := 0; i <= len(s)-len(substr); i++ {
496
+
if s[i:i+len(substr)] == substr {
497
+
return true
498
+
}
499
+
}
500
+
return false
501
+
}
+39
hydration/utils.go
+39
hydration/utils.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/whyrusleeping/market/models"
9
+
)
10
+
11
+
func (h *Hydrator) NormalizeUri(ctx context.Context, uri string) (string, error) {
12
+
puri, err := syntax.ParseATURI(uri)
13
+
if err != nil {
14
+
return "", fmt.Errorf("invalid uri: %w", err)
15
+
}
16
+
17
+
var did string
18
+
if !puri.Authority().IsDID() {
19
+
resp, err := h.dir.LookupHandle(ctx, syntax.Handle(puri.Authority().String()))
20
+
if err != nil {
21
+
return "", err
22
+
}
23
+
24
+
did = resp.DID.String()
25
+
} else {
26
+
did = puri.Authority().String()
27
+
}
28
+
29
+
return fmt.Sprintf("at://%s/%s/%s", did, puri.Collection().String(), puri.RecordKey().String()), nil
30
+
}
31
+
32
+
func (h *Hydrator) UriForPost(ctx context.Context, p *models.Post) (string, error) {
33
+
did, err := h.backend.DidFromID(ctx, p.Author)
34
+
if err != nil {
35
+
return "", err
36
+
}
37
+
38
+
return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, p.Rkey), nil
39
+
}
+160
-144
main.go
+160
-144
main.go
···
1
1
package main
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
6
+
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"log/slog"
···
16
18
17
19
"github.com/bluesky-social/indigo/api/atproto"
18
20
"github.com/bluesky-social/indigo/atproto/identity"
21
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
19
22
"github.com/bluesky-social/indigo/atproto/syntax"
20
-
"github.com/bluesky-social/indigo/cmd/relay/stream"
21
-
"github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel"
23
+
"github.com/bluesky-social/indigo/repo"
22
24
"github.com/bluesky-social/indigo/util/cliutil"
23
-
"github.com/bluesky-social/indigo/xrpc"
24
-
"github.com/gorilla/websocket"
25
-
lru "github.com/hashicorp/golang-lru/v2"
25
+
xrpclib "github.com/bluesky-social/indigo/xrpc"
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/backend"
32
+
"github.com/whyrusleeping/konbini/xrpc"
33
+
"go.opentelemetry.io/otel"
34
+
"go.opentelemetry.io/otel/attribute"
35
+
"go.opentelemetry.io/otel/exporters/jaeger"
36
+
"go.opentelemetry.io/otel/sdk/resource"
37
+
tracesdk "go.opentelemetry.io/otel/sdk/trace"
38
+
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
39
+
"gorm.io/gorm"
30
40
"gorm.io/gorm/logger"
41
+
42
+
. "github.com/whyrusleeping/konbini/models"
31
43
)
32
-
33
-
var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{
34
-
Name: "handle_op_duration",
35
-
Help: "A histogram of op handling durations",
36
-
Buckets: prometheus.ExponentialBuckets(1, 2, 15),
37
-
}, []string{"op", "collection"})
38
44
39
45
var firehoseCursorGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
40
46
Name: "firehose_cursor",
···
50
56
Name: "db-url",
51
57
EnvVars: []string{"DATABASE_URL"},
52
58
},
59
+
&cli.BoolFlag{
60
+
Name: "jaeger",
61
+
},
53
62
&cli.StringFlag{
54
63
Name: "handle",
55
64
},
56
65
&cli.IntFlag{
57
66
Name: "max-db-connections",
58
67
Value: runtime.NumCPU(),
68
+
},
69
+
&cli.StringFlag{
70
+
Name: "redis-url",
71
+
},
72
+
&cli.StringFlag{
73
+
Name: "sync-config",
59
74
},
60
75
}
61
76
app.Action = func(cctx *cli.Context) error {
···
71
86
Colorful: true,
72
87
})
73
88
89
+
if cctx.Bool("jaeger") {
90
+
// Use Jaeger native exporter sending to port 14268
91
+
jaegerUrl := "http://localhost:14268/api/traces"
92
+
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(jaegerUrl)))
93
+
if err != nil {
94
+
return err
95
+
}
96
+
97
+
env := os.Getenv("ENV")
98
+
if env == "" {
99
+
env = "development"
100
+
}
101
+
102
+
tp := tracesdk.NewTracerProvider(
103
+
// Always be sure to batch in production.
104
+
tracesdk.WithBatcher(exp),
105
+
// Record information about this application in a Resource.
106
+
tracesdk.WithResource(resource.NewWithAttributes(
107
+
semconv.SchemaURL,
108
+
semconv.ServiceNameKey.String("konbini"),
109
+
attribute.String("env", env), // DataDog
110
+
attribute.String("environment", env), // Others
111
+
attribute.Int64("ID", 1),
112
+
)),
113
+
)
114
+
115
+
otel.SetTracerProvider(tp)
116
+
}
117
+
74
118
db.AutoMigrate(Repo{})
75
119
db.AutoMigrate(Post{})
76
120
db.AutoMigrate(Follow{})
···
86
130
db.AutoMigrate(Image{})
87
131
db.AutoMigrate(PostGate{})
88
132
db.AutoMigrate(StarterPack{})
89
-
db.AutoMigrate(SyncInfo{})
133
+
db.AutoMigrate(backend.SyncInfo{})
90
134
db.AutoMigrate(Notification{})
135
+
db.AutoMigrate(NotificationSeen{})
136
+
db.AutoMigrate(SequenceTracker{})
137
+
db.Exec("CREATE INDEX IF NOT EXISTS reposts_subject_idx ON reposts (subject)")
138
+
db.Exec("CREATE INDEX IF NOT EXISTS posts_reply_to_idx ON posts (reply_to)")
139
+
db.Exec("CREATE INDEX IF NOT EXISTS posts_in_thread_idx ON posts (in_thread)")
91
140
92
141
ctx := context.TODO()
93
142
94
-
rc, _ := lru.New2Q[string, *Repo](1_000_000)
95
-
pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000)
96
-
revc, _ := lru.New2Q[uint, string](1_000_000)
97
-
98
143
cfg, err := pgxpool.ParseConfig(cctx.String("db-url"))
99
144
if err != nil {
100
145
return err
···
118
163
119
164
dir := identity.DefaultDirectory()
120
165
166
+
if redisURL := cctx.String("redis-url"); redisURL != "" {
167
+
rdir, err := redisdir.NewRedisDirectory(dir, redisURL, time.Minute, time.Second*10, time.Second*10, 100_000)
168
+
if err != nil {
169
+
return err
170
+
}
171
+
dir = rdir
172
+
}
173
+
121
174
resp, err := dir.LookupHandle(ctx, syntax.Handle(handle))
122
175
if err != nil {
123
176
return err
124
177
}
125
178
mydid := resp.DID.String()
126
179
127
-
cc := &xrpc.Client{
180
+
cc := &xrpclib.Client{
128
181
Host: resp.PDSEndpoint(),
129
182
}
130
183
···
136
189
return err
137
190
}
138
191
139
-
cc.Auth = &xrpc.AuthInfo{
192
+
cc.Auth = &xrpclib.AuthInfo{
140
193
AccessJwt: nsess.AccessJwt,
141
194
Did: mydid,
142
195
Handle: nsess.Handle,
···
148
201
client: cc,
149
202
dir: dir,
150
203
151
-
missingProfiles: make(chan string, 1024),
152
-
missingPosts: make(chan string, 1024),
204
+
db: db,
153
205
}
154
206
155
-
pgb := &PostgresBackend{
156
-
relevantDids: make(map[string]bool),
157
-
s: s,
158
-
db: db,
159
-
postInfoCache: pc,
160
-
repoCache: rc,
161
-
revCache: revc,
162
-
pgx: pool,
207
+
pgb, err := backend.NewPostgresBackend(mydid, db, pool, cc, dir)
208
+
if err != nil {
209
+
return err
163
210
}
211
+
164
212
s.backend = pgb
165
213
166
-
myrepo, err := s.backend.getOrCreateRepo(ctx, mydid)
214
+
myrepo, err := s.backend.GetOrCreateRepo(ctx, mydid)
167
215
if err != nil {
168
216
return fmt.Errorf("failed to get repo record for our own did: %w", err)
169
217
}
170
218
s.myrepo = myrepo
171
219
172
-
if err := s.backend.loadRelevantDids(); err != nil {
220
+
if err := s.backend.LoadRelevantDids(); err != nil {
173
221
return fmt.Errorf("failed to load relevant dids set: %w", err)
174
222
}
175
223
224
+
// Start custom API server (for the custom frontend)
176
225
go func() {
177
226
if err := s.runApiServer(); err != nil {
178
227
fmt.Println("failed to start api server: ", err)
179
228
}
180
229
}()
181
230
231
+
// Start XRPC server (for official Bluesky app compatibility)
232
+
go func() {
233
+
xrpcServer := xrpc.NewServer(db, dir, pgb)
234
+
if err := xrpcServer.Start(":4446"); err != nil {
235
+
fmt.Println("failed to start XRPC server: ", err)
236
+
}
237
+
}()
238
+
239
+
// Start pprof server
182
240
go func() {
183
241
http.ListenAndServe(":4445", nil)
184
242
}()
185
243
186
-
go s.missingProfileFetcher()
187
-
go s.missingPostFetcher()
244
+
sc := SyncConfig{
245
+
Backends: []SyncBackend{
246
+
{
247
+
Type: "firehose",
248
+
Host: "bsky.network",
249
+
},
250
+
},
251
+
}
188
252
189
-
seqno, err := loadLastSeq("sequence.txt")
190
-
if err != nil {
191
-
fmt.Println("failed to load sequence number, starting over", err)
253
+
if scfn := cctx.String("sync-config"); scfn != "" {
254
+
{
255
+
scfi, err := os.Open(scfn)
256
+
if err != nil {
257
+
return err
258
+
}
259
+
defer scfi.Close()
260
+
261
+
var lsc SyncConfig
262
+
if err := json.NewDecoder(scfi).Decode(&lsc); err != nil {
263
+
return err
264
+
}
265
+
sc = lsc
266
+
}
192
267
}
193
268
194
-
return s.startLiveTail(ctx, seqno, 10, 20)
269
+
/*
270
+
sc.Backends[0] = SyncBackend{
271
+
Type: "jetstream",
272
+
Host: "jetstream1.us-west.bsky.network",
273
+
}
274
+
*/
275
+
276
+
return s.StartSyncEngine(ctx, &sc)
277
+
195
278
}
196
279
197
280
app.RunAndExitOnError()
198
281
}
199
282
200
283
type Server struct {
201
-
backend *PostgresBackend
284
+
backend *backend.PostgresBackend
202
285
203
286
dir identity.Directory
204
287
205
-
client *xrpc.Client
288
+
client *xrpclib.Client
206
289
mydid string
207
290
myrepo *Repo
208
291
209
292
seqLk sync.Mutex
210
293
lastSeq int64
211
294
212
-
mpLk sync.Mutex
213
-
missingProfiles chan string
214
-
missingPosts chan string
295
+
mpLk sync.Mutex
296
+
297
+
db *gorm.DB
215
298
}
216
299
217
-
func (s *Server) getXrpcClient() (*xrpc.Client, error) {
300
+
func (s *Server) getXrpcClient() (*xrpclib.Client, error) {
218
301
// TODO: handle refreshing the token periodically
219
302
return s.client, nil
220
303
}
221
304
222
-
func (s *Server) startLiveTail(ctx context.Context, curs int, parWorkers, maxQ int) error {
223
-
slog.Info("starting live tail")
224
-
225
-
// Connect to the Relay websocket
226
-
urlStr := fmt.Sprintf("wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", curs)
227
-
228
-
d := websocket.DefaultDialer
229
-
con, _, err := d.Dial(urlStr, http.Header{
230
-
"User-Agent": []string{"market/0.0.1"},
231
-
})
232
-
if err != nil {
233
-
return fmt.Errorf("failed to connect to relay: %w", err)
234
-
}
235
-
236
-
var lelk sync.Mutex
237
-
lastEvent := time.Now()
238
-
239
-
go func() {
240
-
for range time.Tick(time.Second) {
241
-
lelk.Lock()
242
-
let := lastEvent
243
-
lelk.Unlock()
244
-
245
-
if time.Since(let) > time.Second*30 {
246
-
slog.Error("firehose connection timed out")
247
-
con.Close()
248
-
return
249
-
}
250
-
251
-
}
252
-
253
-
}()
254
-
255
-
var cclk sync.Mutex
256
-
var completeCursor int64
257
-
258
-
rsc := &stream.RepoStreamCallbacks{
259
-
RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error {
260
-
ctx := context.Background()
261
-
262
-
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq))
263
-
264
-
s.seqLk.Lock()
265
-
if evt.Seq > s.lastSeq {
266
-
curs = int(evt.Seq)
267
-
s.lastSeq = evt.Seq
268
-
269
-
if evt.Seq%1000 == 0 {
270
-
if err := storeLastSeq("sequence.txt", int(evt.Seq)); err != nil {
271
-
fmt.Println("failed to store seqno: ", err)
272
-
}
273
-
}
274
-
}
275
-
s.seqLk.Unlock()
276
-
277
-
lelk.Lock()
278
-
lastEvent = time.Now()
279
-
lelk.Unlock()
280
-
281
-
if err := s.backend.HandleEvent(ctx, evt); err != nil {
282
-
return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err)
283
-
}
284
-
285
-
cclk.Lock()
286
-
if evt.Seq > completeCursor {
287
-
completeCursor = evt.Seq
288
-
firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq))
289
-
}
290
-
cclk.Unlock()
291
-
292
-
return nil
293
-
},
294
-
RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error {
295
-
return nil
296
-
},
297
-
// TODO: all the other event types
298
-
Error: func(errf *stream.ErrorFrame) error {
299
-
return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message)
300
-
},
301
-
}
302
-
303
-
sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler)
304
-
305
-
//s.eventScheduler = sched
306
-
//s.streamFinished = make(chan struct{})
307
-
308
-
return stream.HandleRepoStream(ctx, con, sched, slog.Default())
309
-
}
310
-
311
305
func (s *Server) resolveAccountIdent(ctx context.Context, acc string) (string, error) {
312
306
unesc, err := url.PathUnescape(acc)
313
307
if err != nil {
···
327
321
return resp.DID.String(), nil
328
322
}
329
323
330
-
const (
331
-
NotifKindReply = "reply"
332
-
NotifKindLike = "like"
333
-
NotifKindMention = "mention"
334
-
NotifKindRepost = "repost"
335
-
)
324
+
func (s *Server) rescanRepo(ctx context.Context, did string) error {
325
+
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
326
+
if err != nil {
327
+
return err
328
+
}
336
329
337
-
func (s *Server) AddNotification(ctx context.Context, forUser, author uint, recordUri string, kind string) error {
338
-
return s.backend.db.Create(&Notification{
339
-
For: forUser,
340
-
Author: author,
341
-
Source: recordUri,
342
-
Kind: kind,
343
-
}).Error
330
+
s.backend.AddRelevantDid(did)
331
+
332
+
c := &xrpclib.Client{
333
+
Host: resp.PDSEndpoint(),
334
+
}
335
+
336
+
repob, err := atproto.SyncGetRepo(ctx, c, did, "")
337
+
if err != nil {
338
+
return err
339
+
}
340
+
341
+
rep, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(repob))
342
+
if err != nil {
343
+
return err
344
+
}
345
+
346
+
return rep.ForEach(ctx, "", func(k string, v cid.Cid) error {
347
+
blk, err := rep.Blockstore().Get(ctx, v)
348
+
if err != nil {
349
+
slog.Error("record missing in repo", "path", k, "cid", v, "error", err)
350
+
return nil
351
+
}
352
+
353
+
d := blk.RawData()
354
+
if err := s.backend.HandleCreate(ctx, did, "", k, &d, &v); err != nil {
355
+
slog.Error("failed to index record", "path", k, "cid", v, "error", err)
356
+
}
357
+
return nil
358
+
})
359
+
344
360
}
-131
missing.go
-131
missing.go
···
1
-
package main
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
"strings"
8
-
9
-
"github.com/bluesky-social/indigo/api/atproto"
10
-
"github.com/bluesky-social/indigo/api/bsky"
11
-
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/indigo/xrpc"
13
-
"github.com/ipfs/go-cid"
14
-
"github.com/labstack/gommon/log"
15
-
)
16
-
17
-
func (s *Server) addMissingProfile(ctx context.Context, did string) {
18
-
select {
19
-
case s.missingProfiles <- did:
20
-
case <-ctx.Done():
21
-
}
22
-
}
23
-
24
-
func (s *Server) missingProfileFetcher() {
25
-
for did := range s.missingProfiles {
26
-
if err := s.fetchMissingProfile(context.TODO(), did); err != nil {
27
-
log.Warn("failed to fetch missing profile", "did", did, "error", err)
28
-
}
29
-
}
30
-
}
31
-
32
-
func (s *Server) fetchMissingProfile(ctx context.Context, did string) error {
33
-
repo, err := s.backend.getOrCreateRepo(ctx, did)
34
-
if err != nil {
35
-
return err
36
-
}
37
-
38
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
39
-
if err != nil {
40
-
return err
41
-
}
42
-
43
-
c := &xrpc.Client{
44
-
Host: resp.PDSEndpoint(),
45
-
}
46
-
47
-
rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self")
48
-
if err != nil {
49
-
return err
50
-
}
51
-
52
-
prof, ok := rec.Value.Val.(*bsky.ActorProfile)
53
-
if !ok {
54
-
return fmt.Errorf("record we got back wasnt a profile somehow")
55
-
}
56
-
57
-
buf := new(bytes.Buffer)
58
-
if err := prof.MarshalCBOR(buf); err != nil {
59
-
return err
60
-
}
61
-
62
-
cc, err := cid.Decode(*rec.Cid)
63
-
if err != nil {
64
-
return err
65
-
}
66
-
67
-
return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc)
68
-
}
69
-
70
-
func (s *Server) addMissingPost(ctx context.Context, uri string) {
71
-
select {
72
-
case s.missingPosts <- uri:
73
-
case <-ctx.Done():
74
-
}
75
-
}
76
-
77
-
func (s *Server) missingPostFetcher() {
78
-
for uri := range s.missingPosts {
79
-
if err := s.fetchMissingPost(context.TODO(), uri); err != nil {
80
-
log.Warn("failed to fetch missing post", "uri", uri, "error", err)
81
-
}
82
-
}
83
-
}
84
-
85
-
func (s *Server) fetchMissingPost(ctx context.Context, uri string) error {
86
-
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
87
-
parts := strings.Split(uri, "/")
88
-
if len(parts) < 5 || !strings.HasPrefix(parts[2], "did:") {
89
-
return fmt.Errorf("invalid AT URI: %s", uri)
90
-
}
91
-
92
-
did := parts[2]
93
-
collection := parts[3]
94
-
rkey := parts[4]
95
-
96
-
repo, err := s.backend.getOrCreateRepo(ctx, did)
97
-
if err != nil {
98
-
return err
99
-
}
100
-
101
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
102
-
if err != nil {
103
-
return err
104
-
}
105
-
106
-
c := &xrpc.Client{
107
-
Host: resp.PDSEndpoint(),
108
-
}
109
-
110
-
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
111
-
if err != nil {
112
-
return err
113
-
}
114
-
115
-
post, ok := rec.Value.Val.(*bsky.FeedPost)
116
-
if !ok {
117
-
return fmt.Errorf("record we got back wasn't a post somehow")
118
-
}
119
-
120
-
buf := new(bytes.Buffer)
121
-
if err := post.MarshalCBOR(buf); err != nil {
122
-
return err
123
-
}
124
-
125
-
cc, err := cid.Decode(*rec.Cid)
126
-
if err != nil {
127
-
return err
128
-
}
129
-
130
-
return s.backend.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc)
131
-
}
+54
models/models.go
+54
models/models.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/whyrusleeping/market/models"
7
+
"gorm.io/gorm"
8
+
)
9
+
10
+
type Repo = models.Repo
11
+
type Post = models.Post
12
+
type Follow = models.Follow
13
+
type Block = models.Block
14
+
type Repost = models.Repost
15
+
type List = models.List
16
+
type ListItem = models.ListItem
17
+
type ListBlock = models.ListBlock
18
+
type Profile = models.Profile
19
+
type ThreadGate = models.ThreadGate
20
+
type FeedGenerator = models.FeedGenerator
21
+
type Image = models.Image
22
+
type PostGate = models.PostGate
23
+
type StarterPack = models.StarterPack
24
+
25
+
type Like struct {
26
+
ID uint `gorm:"primarykey"`
27
+
Created time.Time
28
+
Indexed time.Time
29
+
Author uint `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
30
+
Rkey string `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
31
+
Subject uint
32
+
Cid string
33
+
}
34
+
35
+
type Notification struct {
36
+
gorm.Model
37
+
For uint
38
+
39
+
Author uint
40
+
Source string
41
+
SourceCid string
42
+
Kind string
43
+
}
44
+
45
+
type SequenceTracker struct {
46
+
ID uint `gorm:"primarykey"`
47
+
Key string `gorm:"uniqueIndex"`
48
+
IntVal int64
49
+
}
50
+
51
+
type NotificationSeen struct {
52
+
Repo uint `gorm:"uniqueindex"`
53
+
SeenAt time.Time
54
+
}
-42
models.go
-42
models.go
···
1
-
package main
2
-
3
-
import (
4
-
"time"
5
-
6
-
"github.com/whyrusleeping/market/models"
7
-
"gorm.io/gorm"
8
-
)
9
-
10
-
type Repo = models.Repo
11
-
type Post = models.Post
12
-
type Follow = models.Follow
13
-
type Block = models.Block
14
-
type Repost = models.Repost
15
-
type List = models.List
16
-
type ListItem = models.ListItem
17
-
type ListBlock = models.ListBlock
18
-
type Profile = models.Profile
19
-
type ThreadGate = models.ThreadGate
20
-
type FeedGenerator = models.FeedGenerator
21
-
type Image = models.Image
22
-
type PostGate = models.PostGate
23
-
type StarterPack = models.StarterPack
24
-
25
-
type Like struct {
26
-
ID uint `gorm:"primarykey"`
27
-
Created time.Time
28
-
Indexed time.Time
29
-
Author uint `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
30
-
Rkey string `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
31
-
Subject uint
32
-
Cid string
33
-
}
34
-
35
-
type Notification struct {
36
-
gorm.Model
37
-
For uint
38
-
39
-
Author uint
40
-
Source string
41
-
Kind string
42
-
}
-402
pgbackend.go
-402
pgbackend.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"errors"
6
-
"fmt"
7
-
"strings"
8
-
"time"
9
-
10
-
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/api/bsky"
12
-
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"github.com/bluesky-social/indigo/util"
14
-
"github.com/jackc/pgx/v5"
15
-
"github.com/jackc/pgx/v5/pgconn"
16
-
"github.com/whyrusleeping/market/models"
17
-
"gorm.io/gorm"
18
-
"gorm.io/gorm/clause"
19
-
"gorm.io/gorm/logger"
20
-
)
21
-
22
-
func (b *PostgresBackend) getOrCreateRepo(ctx context.Context, did string) (*Repo, error) {
23
-
r, ok := b.repoCache.Get(did)
24
-
if !ok {
25
-
b.reposLk.Lock()
26
-
27
-
r, ok = b.repoCache.Get(did)
28
-
if !ok {
29
-
r = &Repo{}
30
-
r.Did = did
31
-
b.repoCache.Add(did, r)
32
-
}
33
-
34
-
b.reposLk.Unlock()
35
-
}
36
-
37
-
r.Lk.Lock()
38
-
defer r.Lk.Unlock()
39
-
if r.Setup {
40
-
return r, nil
41
-
}
42
-
43
-
row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did)
44
-
45
-
err := row.Scan(&r.ID, &r.CreatedAt, &r.Did)
46
-
if err == nil {
47
-
// found it!
48
-
r.Setup = true
49
-
return r, nil
50
-
}
51
-
52
-
if err != pgx.ErrNoRows {
53
-
return nil, err
54
-
}
55
-
56
-
r.Did = did
57
-
if err := b.db.Create(r).Error; err != nil {
58
-
return nil, err
59
-
}
60
-
61
-
r.Setup = true
62
-
63
-
return r, nil
64
-
}
65
-
66
-
func (b *PostgresBackend) getOrCreateList(ctx context.Context, uri string) (*List, error) {
67
-
puri, err := util.ParseAtUri(uri)
68
-
if err != nil {
69
-
return nil, err
70
-
}
71
-
72
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
73
-
if err != nil {
74
-
return nil, err
75
-
}
76
-
77
-
// TODO: needs upsert treatment when we actually find the list
78
-
var list List
79
-
if err := b.db.FirstOrCreate(&list, map[string]any{
80
-
"author": r.ID,
81
-
"rkey": puri.Rkey,
82
-
}).Error; err != nil {
83
-
return nil, err
84
-
}
85
-
return &list, nil
86
-
}
87
-
88
-
type cachedPostInfo struct {
89
-
ID uint
90
-
Author uint
91
-
}
92
-
93
-
func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) {
94
-
// getPostByUri implicitly fills the cache
95
-
p, err := b.postInfoForUri(ctx, uri)
96
-
if err != nil {
97
-
return 0, err
98
-
}
99
-
100
-
return p.ID, nil
101
-
}
102
-
103
-
func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) {
104
-
v, ok := b.postInfoCache.Get(uri)
105
-
if ok {
106
-
return v, nil
107
-
}
108
-
109
-
// getPostByUri implicitly fills the cache
110
-
p, err := b.getOrCreatePostBare(ctx, uri)
111
-
if err != nil {
112
-
return cachedPostInfo{}, err
113
-
}
114
-
115
-
return cachedPostInfo{ID: p.ID, Author: p.Author}, nil
116
-
}
117
-
118
-
func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) {
119
-
var p Post
120
-
q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2"
121
-
if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil {
122
-
if errors.Is(err, pgx.ErrNoRows) {
123
-
return nil, nil
124
-
}
125
-
return nil, err
126
-
}
127
-
128
-
return &p, nil
129
-
}
130
-
131
-
func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) {
132
-
puri, err := util.ParseAtUri(uri)
133
-
if err != nil {
134
-
return nil, err
135
-
}
136
-
137
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
138
-
if err != nil {
139
-
return nil, err
140
-
}
141
-
142
-
post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
143
-
if err != nil {
144
-
return nil, err
145
-
}
146
-
147
-
if post == nil {
148
-
post = &Post{
149
-
Rkey: puri.Rkey,
150
-
Author: r.ID,
151
-
NotFound: true,
152
-
}
153
-
154
-
err := b.pgx.QueryRow(ctx, "INSERT INTO posts (rkey, author, not_found) VALUES ($1, $2, $3) RETURNING id", puri.Rkey, r.ID, true).Scan(&post.ID)
155
-
if err != nil {
156
-
pgErr, ok := err.(*pgconn.PgError)
157
-
if !ok || pgErr.Code != "23505" {
158
-
return nil, err
159
-
}
160
-
161
-
out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
162
-
if err != nil {
163
-
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
164
-
}
165
-
if out == nil {
166
-
return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey)
167
-
}
168
-
169
-
post = out
170
-
}
171
-
172
-
}
173
-
174
-
b.postInfoCache.Add(uri, cachedPostInfo{
175
-
ID: post.ID,
176
-
Author: post.Author,
177
-
})
178
-
179
-
return post, nil
180
-
}
181
-
182
-
func (b *PostgresBackend) getPostByUri(ctx context.Context, uri string, fields string) (*Post, error) {
183
-
puri, err := util.ParseAtUri(uri)
184
-
if err != nil {
185
-
return nil, err
186
-
}
187
-
188
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
189
-
if err != nil {
190
-
return nil, err
191
-
}
192
-
193
-
q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?"
194
-
195
-
var post Post
196
-
if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil {
197
-
return nil, err
198
-
}
199
-
200
-
if post.ID == 0 {
201
-
post.Rkey = puri.Rkey
202
-
post.Author = r.ID
203
-
post.NotFound = true
204
-
205
-
if err := b.db.Session(&gorm.Session{
206
-
Logger: logger.Default.LogMode(logger.Silent),
207
-
}).Create(&post).Error; err != nil {
208
-
if !errors.Is(err, gorm.ErrDuplicatedKey) {
209
-
return nil, err
210
-
}
211
-
if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil {
212
-
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
213
-
}
214
-
}
215
-
216
-
}
217
-
218
-
b.postInfoCache.Add(uri, cachedPostInfo{
219
-
ID: post.ID,
220
-
Author: post.Author,
221
-
})
222
-
223
-
return &post, nil
224
-
}
225
-
226
-
func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) {
227
-
lrev, ok := b.revCache.Get(rr.ID)
228
-
if ok {
229
-
return lrev, nil
230
-
}
231
-
232
-
var rev string
233
-
if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil {
234
-
if errors.Is(err, pgx.ErrNoRows) {
235
-
return "", nil
236
-
}
237
-
return "", err
238
-
}
239
-
240
-
if rev != "" {
241
-
b.revCache.Add(rr.ID, rev)
242
-
}
243
-
return rev, nil
244
-
}
245
-
246
-
func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error {
247
-
r, err := b.getOrCreateRepo(ctx, user)
248
-
if err != nil {
249
-
return err
250
-
}
251
-
252
-
var si SyncInfo
253
-
if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil {
254
-
return err
255
-
}
256
-
257
-
// not found
258
-
if si.Repo == 0 {
259
-
if err := b.db.Create(&SyncInfo{
260
-
Repo: r.ID,
261
-
}).Error; err != nil {
262
-
return err
263
-
}
264
-
}
265
-
266
-
if si.FollowsSynced {
267
-
return nil
268
-
}
269
-
270
-
var follows []Follow
271
-
var cursor string
272
-
for {
273
-
resp, err := atproto.RepoListRecords(ctx, b.s.client, "app.bsky.graph.follow", cursor, 100, b.s.mydid, false)
274
-
if err != nil {
275
-
return err
276
-
}
277
-
278
-
for _, rec := range resp.Records {
279
-
if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok {
280
-
fr, err := b.getOrCreateRepo(ctx, fol.Subject)
281
-
if err != nil {
282
-
return err
283
-
}
284
-
285
-
puri, err := syntax.ParseATURI(rec.Uri)
286
-
if err != nil {
287
-
return err
288
-
}
289
-
290
-
follows = append(follows, Follow{
291
-
Created: time.Now(),
292
-
Indexed: time.Now(),
293
-
Rkey: puri.RecordKey().String(),
294
-
Author: r.ID,
295
-
Subject: fr.ID,
296
-
})
297
-
}
298
-
}
299
-
300
-
if resp.Cursor == nil || len(resp.Records) == 0 {
301
-
break
302
-
}
303
-
cursor = *resp.Cursor
304
-
}
305
-
306
-
if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil {
307
-
return err
308
-
}
309
-
310
-
if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil {
311
-
return err
312
-
}
313
-
314
-
fmt.Println("Got follows: ", len(follows))
315
-
316
-
return nil
317
-
}
318
-
319
-
func (b *PostgresBackend) loadRelevantDids() error {
320
-
ctx := context.TODO()
321
-
322
-
if err := b.ensureFollowsScraped(ctx, b.s.mydid); err != nil {
323
-
return fmt.Errorf("failed to scrape follows: %w", err)
324
-
}
325
-
326
-
r, err := b.getOrCreateRepo(ctx, b.s.mydid)
327
-
if err != nil {
328
-
return err
329
-
}
330
-
331
-
var dids []string
332
-
if err := b.db.Raw("select did from follows left join repos on follows.subject = repos.id where follows.author = ?", r.ID).Scan(&dids).Error; err != nil {
333
-
return err
334
-
}
335
-
336
-
b.relevantDids[b.s.mydid] = true
337
-
for _, d := range dids {
338
-
fmt.Println("adding did: ", d)
339
-
b.relevantDids[d] = true
340
-
}
341
-
342
-
return nil
343
-
}
344
-
345
-
type SyncInfo struct {
346
-
Repo uint `gorm:"index"`
347
-
FollowsSynced bool
348
-
Rev string
349
-
}
350
-
351
-
func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) {
352
-
var id uint
353
-
var notfound bool
354
-
if err := b.pgx.QueryRow(ctx, "SELECT id, not_found FROM posts WHERE author = $1 AND rkey = $2", repo.ID, rkey).Scan(&id, ¬found); err != nil {
355
-
if errors.Is(err, pgx.ErrNoRows) {
356
-
return false, nil
357
-
}
358
-
return false, err
359
-
}
360
-
361
-
if id != 0 && !notfound {
362
-
return true, nil
363
-
}
364
-
365
-
return false, nil
366
-
}
367
-
368
-
func (b *PostgresBackend) didIsRelevant(did string) bool {
369
-
b.rdLk.Lock()
370
-
defer b.rdLk.Unlock()
371
-
return b.relevantDids[did]
372
-
}
373
-
374
-
func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool {
375
-
for _, id := range idents {
376
-
if strings.HasPrefix(id, "did:") {
377
-
if b.didIsRelevant(id) {
378
-
return true
379
-
}
380
-
} else if strings.HasPrefix(id, "at://") {
381
-
puri, err := syntax.ParseATURI(id)
382
-
if err != nil {
383
-
continue
384
-
}
385
-
386
-
if b.didIsRelevant(puri.Authority().String()) {
387
-
return true
388
-
}
389
-
}
390
-
}
391
-
392
-
return false
393
-
}
394
-
395
-
func (b *PostgresBackend) getRepoByID(ctx context.Context, id uint) (*models.Repo, error) {
396
-
var r models.Repo
397
-
if err := b.db.Find(&r, "id = ?", id).Error; err != nil {
398
-
return nil, err
399
-
}
400
-
401
-
return &r, nil
402
-
}
+19
-18
seqno.go
+19
-18
seqno.go
···
1
1
package main
2
2
3
3
import (
4
-
"fmt"
5
-
"io/ioutil"
6
-
"strconv"
7
-
"strings"
4
+
"gorm.io/gorm"
5
+
"gorm.io/gorm/clause"
6
+
7
+
. "github.com/whyrusleeping/konbini/models"
8
8
)
9
9
10
-
func storeLastSeq(filename string, seq int) error {
11
-
data := fmt.Sprint(seq)
12
-
return ioutil.WriteFile(filename, []byte(data), 0644)
10
+
func storeLastSeq(db *gorm.DB, key string, seq int64) error {
11
+
return db.Clauses(clause.OnConflict{
12
+
Columns: []clause.Column{{Name: "key"}},
13
+
DoUpdates: clause.AssignmentColumns([]string{"int_val"}),
14
+
}).Create(&SequenceTracker{
15
+
Key: key,
16
+
IntVal: seq,
17
+
}).Error
13
18
}
14
19
15
-
func loadLastSeq(filename string) (int, error) {
16
-
data, err := ioutil.ReadFile(filename)
17
-
if err != nil {
18
-
return 0, err
19
-
}
20
-
21
-
seqStr := strings.TrimSpace(string(data))
22
-
seq, err := strconv.Atoi(seqStr)
23
-
if err != nil {
20
+
func loadLastSeq(db *gorm.DB, key string) (int64, error) {
21
+
var info SequenceTracker
22
+
if err := db.Where("key = ?", key).First(&info).Error; err != nil {
23
+
if err == gorm.ErrRecordNotFound {
24
+
return 0, nil
25
+
}
24
26
return 0, err
25
27
}
26
-
27
-
return seq, nil
28
+
return info.IntVal, nil
28
29
}
+8
sync-config-jetstream.json
+8
sync-config-jetstream.json
+281
sync.go
+281
sync.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"sync"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/cmd/relay/stream"
13
+
"github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel"
14
+
jsclient "github.com/bluesky-social/jetstream/pkg/client"
15
+
jsparallel "github.com/bluesky-social/jetstream/pkg/client/schedulers/parallel"
16
+
"github.com/bluesky-social/jetstream/pkg/models"
17
+
"github.com/gorilla/websocket"
18
+
)
19
+
20
+
type SyncConfig struct {
21
+
Backends []SyncBackend `json:"backends"`
22
+
}
23
+
24
+
type SyncBackend struct {
25
+
Type string `json:"type"`
26
+
Host string `json:"host"`
27
+
MaxWorkers int `json:"max_workers,omitempty"`
28
+
}
29
+
30
+
func (s *Server) StartSyncEngine(ctx context.Context, sc *SyncConfig) error {
31
+
for _, be := range sc.Backends {
32
+
switch be.Type {
33
+
case "firehose":
34
+
go s.runSyncFirehose(ctx, be)
35
+
case "jetstream":
36
+
go s.runSyncJetstream(ctx, be)
37
+
default:
38
+
return fmt.Errorf("unrecognized sync backend type: %q", be.Type)
39
+
}
40
+
}
41
+
42
+
<-ctx.Done()
43
+
return fmt.Errorf("exiting sync routine")
44
+
}
45
+
46
+
const failureTimeInterval = time.Second * 5
47
+
48
+
func (s *Server) runSyncFirehose(ctx context.Context, be SyncBackend) {
49
+
var failures int
50
+
for {
51
+
seqno, err := loadLastSeq(s.db, be.Host)
52
+
if err != nil {
53
+
fmt.Println("failed to load sequence number, starting over", err)
54
+
}
55
+
56
+
maxWorkers := 10
57
+
if be.MaxWorkers != 0 {
58
+
maxWorkers = be.MaxWorkers
59
+
}
60
+
61
+
start := time.Now()
62
+
if err := s.startLiveTail(ctx, be.Host, int(seqno), maxWorkers, 20); err != nil {
63
+
slog.Error("firehose connection lost", "host", be.Host, "error", err)
64
+
}
65
+
66
+
elapsed := time.Since(start)
67
+
68
+
if elapsed > failureTimeInterval {
69
+
failures = 0
70
+
continue
71
+
}
72
+
failures++
73
+
74
+
delay := delayForFailureCount(failures)
75
+
slog.Warn("retrying connection after delay", "host", be.Host, "delay", delay)
76
+
}
77
+
}
78
+
79
+
func (s *Server) runSyncJetstream(ctx context.Context, be SyncBackend) {
80
+
var failures int
81
+
for {
82
+
// Load last cursor (stored as sequence number in same table)
83
+
cursor, err := loadLastSeq(s.db, be.Host)
84
+
if err != nil {
85
+
slog.Warn("failed to load jetstream cursor, starting from live", "error", err)
86
+
cursor = 0
87
+
}
88
+
89
+
maxWorkers := 10
90
+
if be.MaxWorkers != 0 {
91
+
maxWorkers = be.MaxWorkers
92
+
}
93
+
94
+
start := time.Now()
95
+
if err := s.startJetstreamTail(ctx, be.Host, cursor, maxWorkers); err != nil {
96
+
slog.Error("jetstream connection lost", "host", be.Host, "error", err)
97
+
}
98
+
99
+
elapsed := time.Since(start)
100
+
101
+
if elapsed > failureTimeInterval {
102
+
failures = 0
103
+
continue
104
+
}
105
+
failures++
106
+
107
+
delay := delayForFailureCount(failures)
108
+
slog.Warn("retrying jetstream connection after delay", "host", be.Host, "delay", delay)
109
+
time.Sleep(delay)
110
+
}
111
+
}
112
+
113
+
func delayForFailureCount(n int) time.Duration {
114
+
if n < 5 {
115
+
return (time.Second * 5) + (time.Second * 2 * time.Duration(n))
116
+
}
117
+
118
+
return time.Second * 30
119
+
}
120
+
121
+
func (s *Server) startLiveTail(ctx context.Context, host string, curs int, parWorkers, maxQ int) error {
122
+
ctx, cancel := context.WithCancel(ctx)
123
+
defer cancel()
124
+
125
+
slog.Info("starting live tail")
126
+
127
+
// Connect to the Relay websocket
128
+
urlStr := fmt.Sprintf("wss://%s/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", host, curs)
129
+
130
+
d := websocket.DefaultDialer
131
+
con, _, err := d.Dial(urlStr, http.Header{
132
+
"User-Agent": []string{"konbini/0.0.1"},
133
+
})
134
+
if err != nil {
135
+
return fmt.Errorf("failed to connect to relay: %w", err)
136
+
}
137
+
138
+
var lelk sync.Mutex
139
+
lastEvent := time.Now()
140
+
141
+
go func() {
142
+
tick := time.NewTicker(time.Second)
143
+
defer tick.Stop()
144
+
for {
145
+
select {
146
+
case <-tick.C:
147
+
lelk.Lock()
148
+
let := lastEvent
149
+
lelk.Unlock()
150
+
151
+
if time.Since(let) > time.Second*30 {
152
+
slog.Error("firehose connection timed out")
153
+
con.Close()
154
+
return
155
+
}
156
+
case <-ctx.Done():
157
+
return
158
+
}
159
+
}
160
+
}()
161
+
162
+
var cclk sync.Mutex
163
+
var completeCursor int64
164
+
165
+
rsc := &stream.RepoStreamCallbacks{
166
+
RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error {
167
+
ctx := context.Background()
168
+
169
+
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq))
170
+
171
+
s.seqLk.Lock()
172
+
if evt.Seq > s.lastSeq {
173
+
curs = int(evt.Seq)
174
+
s.lastSeq = evt.Seq
175
+
176
+
if evt.Seq%1000 == 0 {
177
+
if err := storeLastSeq(s.db, host, evt.Seq); err != nil {
178
+
fmt.Println("failed to store seqno: ", err)
179
+
}
180
+
}
181
+
}
182
+
s.seqLk.Unlock()
183
+
184
+
lelk.Lock()
185
+
lastEvent = time.Now()
186
+
lelk.Unlock()
187
+
188
+
if err := s.backend.HandleEvent(ctx, evt); err != nil {
189
+
return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err)
190
+
}
191
+
192
+
cclk.Lock()
193
+
if evt.Seq > completeCursor {
194
+
completeCursor = evt.Seq
195
+
firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq))
196
+
}
197
+
cclk.Unlock()
198
+
199
+
return nil
200
+
},
201
+
RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error {
202
+
return nil
203
+
},
204
+
// TODO: all the other event types
205
+
Error: func(errf *stream.ErrorFrame) error {
206
+
return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message)
207
+
},
208
+
}
209
+
210
+
sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler)
211
+
212
+
return stream.HandleRepoStream(ctx, con, sched, slog.Default())
213
+
}
214
+
215
+
func (s *Server) startJetstreamTail(ctx context.Context, host string, cursor int64, parWorkers int) error {
216
+
ctx, cancel := context.WithCancel(ctx)
217
+
defer cancel()
218
+
219
+
slog.Info("starting jetstream tail", "host", host, "cursor", cursor)
220
+
221
+
// Create a scheduler for parallel processing
222
+
lastStored := int64(0)
223
+
sched := jsparallel.NewScheduler(
224
+
parWorkers,
225
+
host,
226
+
slog.Default(),
227
+
func(ctx context.Context, event *models.Event) error {
228
+
// Update cursor tracking
229
+
s.seqLk.Lock()
230
+
if event.TimeUS > s.lastSeq {
231
+
s.lastSeq = event.TimeUS
232
+
if event.TimeUS-lastStored > 1_000_000 {
233
+
// Store checkpoint periodically
234
+
if err := storeLastSeq(s.db, host, event.TimeUS); err != nil {
235
+
slog.Error("failed to store jetstream cursor", "error", err)
236
+
}
237
+
lastStored = event.TimeUS
238
+
}
239
+
}
240
+
s.seqLk.Unlock()
241
+
242
+
// Update metrics
243
+
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(event.TimeUS))
244
+
245
+
// Convert Jetstream event to ATProto event format
246
+
if event.Commit != nil {
247
+
248
+
if err := s.backend.HandleEventJetstream(ctx, event); err != nil {
249
+
return fmt.Errorf("handle event (%s,%d): %w", event.Did, event.TimeUS, err)
250
+
}
251
+
252
+
firehoseCursorGauge.WithLabelValues("complete").Set(float64(event.TimeUS))
253
+
}
254
+
255
+
return nil
256
+
},
257
+
)
258
+
259
+
// Configure Jetstream client
260
+
config := jsclient.DefaultClientConfig()
261
+
config.WebsocketURL = fmt.Sprintf("wss://%s/subscribe", host)
262
+
263
+
// Prepare cursor pointer
264
+
var cursorPtr *int64
265
+
if cursor > 0 {
266
+
cursorPtr = &cursor
267
+
}
268
+
269
+
// Create and connect client
270
+
client, err := jsclient.NewClient(
271
+
config,
272
+
slog.Default(),
273
+
sched,
274
+
)
275
+
if err != nil {
276
+
return fmt.Errorf("create jetstream client: %w", err)
277
+
}
278
+
279
+
// Start reading from Jetstream
280
+
return client.ConnectAndRead(ctx, cursorPtr)
281
+
}
+102
views/actor.go
+102
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.ActorInfoDetailed) *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
+
view.FollowersCount = &actor.FollowerCount
89
+
view.FollowsCount = &actor.FollowCount
90
+
view.PostsCount = &actor.PostCount
91
+
92
+
// Add viewer state if available
93
+
if actor.ViewerState != nil {
94
+
view.Viewer = actor.ViewerState
95
+
}
96
+
97
+
return view
98
+
}
99
+
100
+
func formatBlobRef(did string, blob *util.LexBlob) string {
101
+
return fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", did, blob.Ref.String())
102
+
}
+117
views/feed.go
+117
views/feed.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
+
// PostView builds a post view (app.bsky.feed.defs#postView)
12
+
func PostView(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_PostView {
13
+
view := &bsky.FeedDefs_PostView{
14
+
LexiconTypeID: "app.bsky.feed.defs#postView",
15
+
Uri: post.URI,
16
+
Cid: post.Cid,
17
+
Author: ProfileViewBasic(author),
18
+
Record: &util.LexiconTypeDecoder{
19
+
Val: post.Post,
20
+
},
21
+
IndexedAt: post.Post.CreatedAt, // Using createdAt as indexedAt for now
22
+
}
23
+
24
+
// Add engagement counts
25
+
if post.LikeCount > 0 {
26
+
lc := int64(post.LikeCount)
27
+
view.LikeCount = &lc
28
+
}
29
+
if post.RepostCount > 0 {
30
+
rc := int64(post.RepostCount)
31
+
view.RepostCount = &rc
32
+
}
33
+
if post.ReplyCount > 0 {
34
+
rpc := int64(post.ReplyCount)
35
+
view.ReplyCount = &rpc
36
+
}
37
+
38
+
// Add viewer state
39
+
if post.ViewerLike != "" {
40
+
view.Viewer = &bsky.FeedDefs_ViewerState{
41
+
Like: &post.ViewerLike,
42
+
}
43
+
}
44
+
45
+
// Add embed if it was hydrated
46
+
if post.EmbedInfo != nil {
47
+
view.Embed = post.EmbedInfo
48
+
}
49
+
50
+
return view
51
+
}
52
+
53
+
// FeedViewPost builds a feed view post (app.bsky.feed.defs#feedViewPost)
54
+
func FeedViewPost(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_FeedViewPost {
55
+
return &bsky.FeedDefs_FeedViewPost{
56
+
Post: PostView(post, author),
57
+
}
58
+
}
59
+
60
+
// ThreadViewPost builds a thread view post (app.bsky.feed.defs#threadViewPost)
61
+
func ThreadViewPost(post *hydration.PostInfo, author *hydration.ActorInfo, parent, replies any) *bsky.FeedDefs_ThreadViewPost {
62
+
view := &bsky.FeedDefs_ThreadViewPost{
63
+
LexiconTypeID: "app.bsky.feed.defs#threadViewPost",
64
+
Post: PostView(post, author),
65
+
}
66
+
67
+
// TODO: Type parent and replies properly as union types
68
+
// For now leaving them as interface{} to be handled by handlers
69
+
70
+
return view
71
+
}
72
+
73
+
// GeneratorView builds a feed generator view (app.bsky.feed.defs#generatorView)
74
+
func GeneratorView(uri, cid string, record *bsky.FeedGenerator, creator *hydration.ActorInfo, likeCount int64, viewerLike string, indexedAt string) *bsky.FeedDefs_GeneratorView {
75
+
view := &bsky.FeedDefs_GeneratorView{
76
+
LexiconTypeID: "app.bsky.feed.defs#generatorView",
77
+
Uri: uri,
78
+
Cid: cid,
79
+
Did: record.Did,
80
+
Creator: ProfileView(creator),
81
+
DisplayName: record.DisplayName,
82
+
Description: record.Description,
83
+
IndexedAt: indexedAt,
84
+
}
85
+
86
+
// Add optional fields
87
+
if record.Avatar != nil {
88
+
avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar/plain/%s/%s@jpeg", creator.DID, record.Avatar.Ref.String())
89
+
view.Avatar = &avatarURL
90
+
}
91
+
92
+
if record.DescriptionFacets != nil && len(record.DescriptionFacets) > 0 {
93
+
view.DescriptionFacets = record.DescriptionFacets
94
+
}
95
+
96
+
if record.AcceptsInteractions != nil {
97
+
view.AcceptsInteractions = record.AcceptsInteractions
98
+
}
99
+
100
+
if record.ContentMode != nil {
101
+
view.ContentMode = record.ContentMode
102
+
}
103
+
104
+
// Add like count if present
105
+
if likeCount > 0 {
106
+
view.LikeCount = &likeCount
107
+
}
108
+
109
+
// Add viewer state if viewer has liked
110
+
if viewerLike != "" {
111
+
view.Viewer = &bsky.FeedDefs_GeneratorViewerState{
112
+
Like: &viewerLike,
113
+
}
114
+
}
115
+
116
+
return view
117
+
}
+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
+
*/
+47
xrpc/actor/getProfile.go
+47
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
+
viewer, _ := c.Get("viewer").(string)
24
+
25
+
// Resolve actor to DID
26
+
did, 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
+
// Hydrate actor info
35
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
36
+
if err != nil {
37
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
38
+
"error": "ActorNotFound",
39
+
"message": "failed to load actor",
40
+
})
41
+
}
42
+
43
+
// Build response
44
+
profile := views.ProfileViewDetailed(actorInfo)
45
+
46
+
return c.JSON(http.StatusOK, profile)
47
+
}
+55
xrpc/actor/getProfiles.go
+55
xrpc/actor/getProfiles.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
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetProfiles implements app.bsky.actor.getProfiles
14
+
func HandleGetProfiles(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
// Parse actors parameter (can be multiple)
16
+
actors := c.QueryParams()["actors"]
17
+
if len(actors) == 0 {
18
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
19
+
"error": "InvalidRequest",
20
+
"message": "actors parameter is required",
21
+
})
22
+
}
23
+
24
+
// Limit to reasonable batch size
25
+
if len(actors) > 25 {
26
+
actors = actors[:25]
27
+
}
28
+
29
+
ctx := c.Request().Context()
30
+
viewer, _ := c.Get("viewer").(string)
31
+
32
+
// Resolve all actors to DIDs and hydrate profiles
33
+
profiles := make([]*bsky.ActorDefs_ProfileViewDetailed, 0, len(actors))
34
+
for _, actor := range actors {
35
+
// Resolve actor to DID
36
+
did, err := hydrator.ResolveDID(ctx, actor)
37
+
if err != nil {
38
+
// Skip actors that can't be resolved
39
+
continue
40
+
}
41
+
42
+
// Hydrate actor info
43
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
44
+
if err != nil {
45
+
// Skip actors that can't be hydrated
46
+
continue
47
+
}
48
+
49
+
profiles = append(profiles, views.ProfileViewDetailed(actorInfo))
50
+
}
51
+
52
+
return c.JSON(http.StatusOK, map[string]interface{}{
53
+
"profiles": profiles,
54
+
})
55
+
}
+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
+
}
+207
xrpc/feed/getAuthorFeed.go
+207
xrpc/feed/getAuthorFeed.go
···
1
+
package feed
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"net/http"
7
+
"strconv"
8
+
"strings"
9
+
"sync"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"github.com/labstack/echo/v4"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
"github.com/whyrusleeping/konbini/views"
17
+
"gorm.io/gorm"
18
+
)
19
+
20
+
type postRow struct {
21
+
URI string
22
+
AuthorID uint
23
+
}
24
+
25
+
// HandleGetAuthorFeed implements app.bsky.feed.getAuthorFeed
26
+
func HandleGetAuthorFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
27
+
actorParam := c.QueryParam("actor")
28
+
if actorParam == "" {
29
+
return c.JSON(http.StatusBadRequest, map[string]any{
30
+
"error": "InvalidRequest",
31
+
"message": "actor parameter is required",
32
+
})
33
+
}
34
+
35
+
// Parse limit
36
+
limit := 50
37
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
38
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
39
+
limit = l
40
+
}
41
+
}
42
+
43
+
// Parse cursor (timestamp)
44
+
cursor := time.Now()
45
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
46
+
if t, err := time.Parse(time.RFC3339, cursorParam); err == nil {
47
+
cursor = t
48
+
}
49
+
}
50
+
51
+
// Parse filter (posts_with_replies, posts_no_replies, posts_with_media, etc.)
52
+
filter := c.QueryParam("filter")
53
+
if filter == "" {
54
+
filter = "posts_with_replies" // default
55
+
}
56
+
57
+
ctx := c.Request().Context()
58
+
viewer := getUserDID(c)
59
+
60
+
// Resolve actor to DID
61
+
did, err := hydrator.ResolveDID(ctx, actorParam)
62
+
if err != nil {
63
+
return c.JSON(http.StatusBadRequest, map[string]any{
64
+
"error": "ActorNotFound",
65
+
"message": "actor not found",
66
+
})
67
+
}
68
+
69
+
// Build query based on filter
70
+
var query string
71
+
switch filter {
72
+
case "posts_no_replies", "posts_and_author_threads":
73
+
query = `
74
+
SELECT
75
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
76
+
p.author as author_id
77
+
FROM posts p
78
+
JOIN repos r ON r.id = p.author
79
+
WHERE p.author = (SELECT id FROM repos WHERE did = ?)
80
+
AND p.reply_to = 0
81
+
AND p.created < ?
82
+
AND p.not_found = false
83
+
ORDER BY p.created DESC
84
+
LIMIT ?
85
+
`
86
+
default: // posts_with_replies
87
+
query = `
88
+
SELECT
89
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
90
+
p.author as author_id
91
+
FROM posts p
92
+
JOIN repos r ON r.id = p.author
93
+
WHERE p.author = (SELECT id FROM repos WHERE did = ?)
94
+
AND p.created < ?
95
+
AND p.not_found = false
96
+
ORDER BY p.created DESC
97
+
LIMIT ?
98
+
`
99
+
}
100
+
101
+
var rows []postRow
102
+
if err := db.Raw(query, did, cursor, limit).Scan(&rows).Error; err != nil {
103
+
return c.JSON(http.StatusInternalServerError, map[string]any{
104
+
"error": "InternalError",
105
+
"message": "failed to query author feed",
106
+
})
107
+
}
108
+
109
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
110
+
111
+
// Generate next cursor
112
+
var nextCursor string
113
+
if len(rows) > 0 {
114
+
lastURI := rows[len(rows)-1].URI
115
+
postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer)
116
+
if err == nil && postInfo.Post != nil {
117
+
t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt)
118
+
if err == nil {
119
+
nextCursor = t.Format(time.RFC3339)
120
+
}
121
+
}
122
+
}
123
+
124
+
return c.JSON(http.StatusOK, map[string]any{
125
+
"feed": feed,
126
+
"cursor": nextCursor,
127
+
})
128
+
}
129
+
130
+
func hydratePostRows(ctx context.Context, hydrator *hydration.Hydrator, viewer string, rows []postRow) []*bsky.FeedDefs_FeedViewPost {
131
+
ctx, span := tracer.Start(ctx, "hydratePostRows")
132
+
defer span.End()
133
+
134
+
// Hydrate posts
135
+
var wg sync.WaitGroup
136
+
137
+
var outLk sync.Mutex
138
+
feed := make([]*bsky.FeedDefs_FeedViewPost, len(rows))
139
+
for i, row := range rows {
140
+
wg.Add(1)
141
+
go func(i int, row postRow) {
142
+
defer wg.Done()
143
+
144
+
puri, err := syntax.ParseATURI(row.URI)
145
+
if err != nil {
146
+
slog.Error("row had invalid uri", "uri", row.URI, "error", err)
147
+
return
148
+
}
149
+
150
+
var subwg sync.WaitGroup
151
+
152
+
var postInfo *hydration.PostInfo
153
+
subwg.Go(func() {
154
+
pi, err := hydrator.HydratePost(ctx, row.URI, viewer)
155
+
if err != nil {
156
+
if strings.Contains(err.Error(), "post not found") {
157
+
hydrator.AddMissingRecord(row.URI, true)
158
+
pi, err = hydrator.HydratePost(ctx, row.URI, viewer)
159
+
if err != nil {
160
+
slog.Error("failed to hydrate post after fetch missing", "uri", row.URI, "error", err)
161
+
return
162
+
}
163
+
} else {
164
+
slog.Warn("failed to hydrate post", "uri", row.URI, "error", err)
165
+
return
166
+
}
167
+
}
168
+
postInfo = pi
169
+
})
170
+
171
+
var authorInfo *hydration.ActorInfo
172
+
subwg.Go(func() {
173
+
ai, err := hydrator.HydrateActor(ctx, puri.Authority().String())
174
+
if err != nil {
175
+
hydrator.AddMissingRecord(postInfo.Author, false)
176
+
slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err)
177
+
return
178
+
}
179
+
authorInfo = ai
180
+
})
181
+
182
+
subwg.Wait()
183
+
184
+
if postInfo == nil || authorInfo == nil {
185
+
return
186
+
}
187
+
188
+
feedItem := views.FeedViewPost(postInfo, authorInfo)
189
+
outLk.Lock()
190
+
feed[i] = feedItem
191
+
outLk.Unlock()
192
+
}(i, row)
193
+
}
194
+
wg.Wait()
195
+
196
+
x := 0
197
+
for i := 0; i < len(feed); i++ {
198
+
if feed[i] != nil {
199
+
feed[x] = feed[i]
200
+
x++
201
+
continue
202
+
}
203
+
}
204
+
feed = feed[:x]
205
+
206
+
return feed
207
+
}
+200
xrpc/feed/getFeed.go
+200
xrpc/feed/getFeed.go
···
1
+
package feed
2
+
3
+
import (
4
+
"bytes"
5
+
"log/slog"
6
+
"net/http"
7
+
"strconv"
8
+
"strings"
9
+
"sync"
10
+
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/atproto/identity"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"github.com/bluesky-social/indigo/xrpc"
15
+
"github.com/labstack/echo/v4"
16
+
"github.com/whyrusleeping/konbini/hydration"
17
+
"github.com/whyrusleeping/konbini/views"
18
+
"github.com/whyrusleeping/market/models"
19
+
"gorm.io/gorm"
20
+
)
21
+
22
+
// HandleGetFeed implements app.bsky.feed.getFeed
23
+
// Gets posts from a custom feed generator
24
+
func HandleGetFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator, dir identity.Directory) error {
25
+
// Parse parameters
26
+
feedURI := c.QueryParam("feed")
27
+
if feedURI == "" {
28
+
return c.JSON(http.StatusBadRequest, map[string]any{
29
+
"error": "InvalidRequest",
30
+
"message": "feed parameter is required",
31
+
})
32
+
}
33
+
34
+
// Parse limit
35
+
limit := int64(50)
36
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
37
+
if l, err := strconv.ParseInt(limitParam, 10, 64); err == nil && l > 0 && l <= 100 {
38
+
limit = l
39
+
}
40
+
}
41
+
42
+
// Parse cursor
43
+
cursor := c.QueryParam("cursor")
44
+
45
+
ctx := c.Request().Context()
46
+
viewer := getUserDID(c)
47
+
48
+
// Extract feed generator DID and rkey from URI
49
+
// URI format: at://did:plc:xxx/app.bsky.feed.generator/rkey
50
+
did := extractDIDFromURI(feedURI)
51
+
rkey := extractRkeyFromURI(feedURI)
52
+
53
+
if did == "" || rkey == "" {
54
+
return c.JSON(http.StatusBadRequest, map[string]any{
55
+
"error": "InvalidRequest",
56
+
"message": "invalid feed URI format",
57
+
})
58
+
}
59
+
60
+
// Check if feed generator exists in database
61
+
var feedGen models.FeedGenerator
62
+
if err := db.Raw(`
63
+
SELECT * FROM feed_generators fg WHERE fg.author = (select id from repos where did = ?) AND fg.rkey = ?
64
+
`, did, rkey).Scan(&feedGen).Error; err != nil {
65
+
return err
66
+
}
67
+
68
+
if feedGen.ID == 0 {
69
+
hydrator.AddMissingRecord(feedURI, true)
70
+
return c.JSON(http.StatusNotFound, map[string]any{
71
+
"error": "NotFound",
72
+
"message": "feed generator not found",
73
+
})
74
+
}
75
+
76
+
// Decode the feed generator record to get the service DID
77
+
var feedGenRecord bsky.FeedGenerator
78
+
if err := feedGenRecord.UnmarshalCBOR(bytes.NewReader(feedGen.Raw)); err != nil {
79
+
slog.Error("failed to decode feed generator record", "error", err)
80
+
return c.JSON(http.StatusInternalServerError, map[string]any{
81
+
"error": "InternalError",
82
+
"message": "failed to decode feed generator record",
83
+
})
84
+
}
85
+
86
+
// Parse the service DID
87
+
serviceDID, err := syntax.ParseDID(feedGenRecord.Did)
88
+
if err != nil {
89
+
slog.Error("invalid service DID in feed generator", "error", err, "did", feedGenRecord.Did)
90
+
return c.JSON(http.StatusInternalServerError, map[string]any{
91
+
"error": "InternalError",
92
+
"message": "invalid service DID",
93
+
})
94
+
}
95
+
96
+
// Resolve the service DID to get its endpoint
97
+
serviceIdent, err := dir.LookupDID(ctx, serviceDID)
98
+
if err != nil {
99
+
slog.Error("failed to resolve service DID", "error", err, "did", serviceDID)
100
+
return c.JSON(http.StatusInternalServerError, map[string]any{
101
+
"error": "InternalError",
102
+
"message": "failed to resolve service endpoint",
103
+
})
104
+
}
105
+
106
+
serviceEndpoint := serviceIdent.GetServiceEndpoint("bsky_fg")
107
+
if serviceEndpoint == "" {
108
+
slog.Error("service has no bsky_fg endpoint", "did", serviceDID)
109
+
return c.JSON(http.StatusInternalServerError, map[string]any{
110
+
"error": "InternalError",
111
+
"message": "service has no endpoint",
112
+
})
113
+
}
114
+
115
+
// Create XRPC client for the feed generator service
116
+
// Pass through headers from the original request so feed generators can
117
+
// customize feeds based on the viewer
118
+
headers := make(map[string]string)
119
+
120
+
// Set User-Agent to identify konbini
121
+
headers["User-Agent"] = "konbini/0.0.1"
122
+
123
+
// Pass through Authorization header if present (for authenticated feed requests)
124
+
if authHeader := c.Request().Header.Get("Authorization"); authHeader != "" {
125
+
headers["Authorization"] = authHeader
126
+
}
127
+
128
+
// Pass through Accept-Language header if present
129
+
if langHeader := c.Request().Header.Get("Accept-Language"); langHeader != "" {
130
+
headers["Accept-Language"] = langHeader
131
+
}
132
+
133
+
// Pass through X-Bsky-Topics header if present
134
+
if topicsHeader := c.Request().Header.Get("X-Bsky-Topics"); topicsHeader != "" {
135
+
headers["X-Bsky-Topics"] = topicsHeader
136
+
}
137
+
138
+
client := &xrpc.Client{
139
+
Host: serviceEndpoint,
140
+
Headers: headers,
141
+
}
142
+
143
+
// Call getFeedSkeleton on the service
144
+
skeleton, err := bsky.FeedGetFeedSkeleton(ctx, client, cursor, feedURI, limit)
145
+
if err != nil {
146
+
slog.Error("failed to call getFeedSkeleton", "error", err, "service", serviceEndpoint)
147
+
// Return empty feed on error rather than failing completely
148
+
return c.JSON(http.StatusOK, &bsky.FeedGetFeed_Output{
149
+
Feed: make([]*bsky.FeedDefs_FeedViewPost, 0),
150
+
})
151
+
}
152
+
153
+
// Hydrate the posts from the skeleton
154
+
posts := make([]*bsky.FeedDefs_FeedViewPost, len(skeleton.Feed))
155
+
var wg sync.WaitGroup
156
+
for i := range skeleton.Feed {
157
+
wg.Add(1)
158
+
go func(ix int) {
159
+
defer wg.Done()
160
+
skeletonPost := skeleton.Feed[ix]
161
+
postURI, err := syntax.ParseATURI(skeletonPost.Post)
162
+
if err != nil {
163
+
slog.Warn("invalid post URI in skeleton", "uri", skeletonPost.Post, "error", err)
164
+
return
165
+
}
166
+
167
+
postInfo, err := hydrator.HydratePost(ctx, postURI.String(), viewer)
168
+
if err != nil {
169
+
if strings.Contains(err.Error(), "post not found") {
170
+
hydrator.AddMissingRecord(postURI.String(), true)
171
+
postInfo, err = hydrator.HydratePost(ctx, postURI.String(), viewer)
172
+
if err != nil {
173
+
slog.Error("failed to hydrate post after fetch missing", "uri", postURI, "error", err)
174
+
return
175
+
}
176
+
} else {
177
+
slog.Warn("failed to hydrate post", "uri", postURI, "error", err)
178
+
return
179
+
}
180
+
}
181
+
182
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
183
+
if err != nil {
184
+
hydrator.AddMissingRecord(postInfo.Author, false)
185
+
slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err)
186
+
return
187
+
}
188
+
189
+
posts[ix] = views.FeedViewPost(postInfo, authorInfo)
190
+
}(i)
191
+
}
192
+
wg.Wait()
193
+
194
+
output := &bsky.FeedGetFeed_Output{
195
+
Feed: posts,
196
+
Cursor: skeleton.Cursor,
197
+
}
198
+
199
+
return c.JSON(http.StatusOK, output)
200
+
}
+161
xrpc/feed/getFeedGenerator.go
+161
xrpc/feed/getFeedGenerator.go
···
1
+
package feed
2
+
3
+
import (
4
+
"bytes"
5
+
"log/slog"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/bsky"
10
+
"github.com/bluesky-social/indigo/atproto/identity"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
cid "github.com/ipfs/go-cid"
13
+
"github.com/labstack/echo/v4"
14
+
mh "github.com/multiformats/go-multihash"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
"github.com/whyrusleeping/konbini/views"
17
+
"gorm.io/gorm"
18
+
)
19
+
20
+
// HandleGetFeedGenerator implements app.bsky.feed.getFeedGenerator
21
+
func HandleGetFeedGenerator(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator, dir identity.Directory) error {
22
+
ctx := c.Request().Context()
23
+
24
+
// Parse parameters
25
+
feedURI := c.QueryParam("feed")
26
+
if feedURI == "" {
27
+
return c.JSON(http.StatusBadRequest, map[string]any{
28
+
"error": "InvalidRequest",
29
+
"message": "feed parameter is required",
30
+
})
31
+
}
32
+
33
+
nu, err := hydrator.NormalizeUri(ctx, feedURI)
34
+
if err != nil {
35
+
return err
36
+
}
37
+
feedURI = nu
38
+
39
+
viewer := getUserDID(c)
40
+
_ = viewer
41
+
42
+
// Extract feed generator DID and rkey from URI
43
+
did := extractDIDFromURI(feedURI)
44
+
rkey := extractRkeyFromURI(feedURI)
45
+
46
+
if did == "" || rkey == "" {
47
+
return c.JSON(http.StatusBadRequest, map[string]any{
48
+
"error": "InvalidRequest",
49
+
"message": "invalid feed URI format",
50
+
})
51
+
}
52
+
53
+
// Query feed generator from database
54
+
type feedGenRow struct {
55
+
ID uint
56
+
Did string
57
+
Raw []byte
58
+
AuthorDid string
59
+
Indexed time.Time
60
+
}
61
+
var feedGen feedGenRow
62
+
err = db.Raw(`
63
+
SELECT fg.id, fg.did, fg.raw, r.did as author_did, indexed
64
+
FROM feed_generators fg
65
+
JOIN repos r ON r.id = fg.author
66
+
WHERE r.did = ? AND fg.rkey = ?
67
+
`, did, rkey).Scan(&feedGen).Error
68
+
69
+
if err != nil || feedGen.ID == 0 {
70
+
// Track this missing feed generator for fetching
71
+
hydrator.AddMissingRecord(feedURI, true)
72
+
73
+
return c.JSON(http.StatusNotFound, map[string]any{
74
+
"error": "NotFound",
75
+
"message": "feed generator not found",
76
+
})
77
+
}
78
+
79
+
// Decode the feed generator record
80
+
var feedGenRecord bsky.FeedGenerator
81
+
if err := feedGenRecord.UnmarshalCBOR(bytes.NewReader(feedGen.Raw)); err != nil {
82
+
slog.Error("failed to decode feed generator record", "error", err)
83
+
return c.JSON(http.StatusInternalServerError, map[string]any{
84
+
"error": "InternalError",
85
+
"message": "failed to decode feed generator record",
86
+
})
87
+
}
88
+
89
+
// Compute CID from raw bytes
90
+
hash, err := mh.Sum(feedGen.Raw, mh.SHA2_256, -1)
91
+
if err != nil {
92
+
slog.Error("failed to hash record", "error", err)
93
+
return c.JSON(http.StatusInternalServerError, map[string]any{
94
+
"error": "InternalError",
95
+
"message": "failed to compute CID",
96
+
})
97
+
}
98
+
recordCid := cid.NewCidV1(cid.DagCBOR, hash).String()
99
+
100
+
// Hydrate the creator
101
+
creatorInfo, err := hydrator.HydrateActor(ctx, feedGen.AuthorDid)
102
+
if err != nil {
103
+
slog.Error("failed to hydrate creator", "error", err, "did", feedGen.AuthorDid)
104
+
return c.JSON(http.StatusInternalServerError, map[string]any{
105
+
"error": "InternalError",
106
+
"message": "failed to hydrate creator",
107
+
})
108
+
}
109
+
110
+
// Count likes for this feed generator
111
+
var likeCount int64
112
+
113
+
// Check if viewer has liked this feed generator
114
+
viewerLike := ""
115
+
116
+
// Validate the service DID (check if it's resolvable)
117
+
serviceDID, err := syntax.ParseDID(feedGenRecord.Did)
118
+
if err != nil {
119
+
slog.Error("invalid service DID in feed generator", "error", err, "did", feedGenRecord.Did)
120
+
return c.JSON(http.StatusInternalServerError, map[string]any{
121
+
"error": "InternalError",
122
+
"message": "invalid service DID",
123
+
})
124
+
}
125
+
126
+
// Try to resolve the service DID to check if it's online/valid
127
+
isOnline := true
128
+
isValid := true
129
+
serviceIdent, err := dir.LookupDID(ctx, serviceDID)
130
+
if err != nil {
131
+
slog.Warn("failed to resolve service DID", "error", err, "did", serviceDID)
132
+
isOnline = false
133
+
isValid = false
134
+
} else {
135
+
// Check if service has an endpoint
136
+
serviceEndpoint := serviceIdent.PDSEndpoint()
137
+
if serviceEndpoint == "" {
138
+
slog.Warn("service has no PDS endpoint", "did", serviceDID)
139
+
isValid = false
140
+
}
141
+
}
142
+
143
+
// Build the generator view
144
+
generatorView := views.GeneratorView(
145
+
feedURI,
146
+
recordCid,
147
+
&feedGenRecord,
148
+
creatorInfo,
149
+
likeCount,
150
+
viewerLike,
151
+
feedGen.Indexed.Format(time.RFC3339),
152
+
)
153
+
154
+
output := &bsky.FeedGetFeedGenerator_Output{
155
+
View: generatorView,
156
+
IsOnline: isOnline,
157
+
IsValid: isValid,
158
+
}
159
+
160
+
return c.JSON(http.StatusOK, output)
161
+
}
+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]any{
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]any{
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: []any{},
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]any{
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]any{
111
+
"thread": thread,
112
+
})
113
+
}
114
+
115
+
type threadPostNode struct {
116
+
id uint
117
+
uri string
118
+
replyTo uint
119
+
inThread uint
120
+
replies []any
121
+
}
122
+
123
+
func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent any) any {
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]any{
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]any{
138
+
"$type": "app.bsky.feed.defs#notFoundPost",
139
+
"uri": node.uri,
140
+
}
141
+
}
142
+
143
+
// Build replies
144
+
var replies []any
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 any
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
+
}
+114
xrpc/feed/getTimeline.go
+114
xrpc/feed/getTimeline.go
···
1
+
package feed
2
+
3
+
import (
4
+
"context"
5
+
"net/http"
6
+
"strconv"
7
+
"time"
8
+
9
+
"github.com/labstack/echo/v4"
10
+
"github.com/whyrusleeping/konbini/hydration"
11
+
"go.opentelemetry.io/otel"
12
+
"gorm.io/gorm"
13
+
)
14
+
15
+
var tracer = otel.Tracer("xrpc/feed")
16
+
17
+
// HandleGetTimeline implements app.bsky.feed.getTimeline
18
+
func HandleGetTimeline(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
19
+
ctx := c.Request().Context()
20
+
ctx, span := tracer.Start(ctx, "getTimeline")
21
+
defer span.End()
22
+
23
+
viewer := getUserDID(c)
24
+
if viewer == "" {
25
+
return c.JSON(http.StatusUnauthorized, map[string]any{
26
+
"error": "AuthenticationRequired",
27
+
"message": "authentication required",
28
+
})
29
+
}
30
+
31
+
// Parse limit
32
+
limit := 50
33
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
34
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
35
+
limit = l
36
+
}
37
+
}
38
+
39
+
// Parse cursor (timestamp)
40
+
cursor := time.Now()
41
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
42
+
if t, err := time.Parse(time.RFC3339, cursorParam); err == nil {
43
+
cursor = t
44
+
}
45
+
}
46
+
47
+
// Get viewer's repo ID
48
+
var viewerRepoID uint
49
+
if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil {
50
+
return c.JSON(http.StatusInternalServerError, map[string]any{
51
+
"error": "InternalError",
52
+
"message": "failed to load viewer",
53
+
})
54
+
}
55
+
56
+
// Query posts from followed users
57
+
58
+
rows, err := getTimelinePosts(ctx, db, viewerRepoID, cursor, limit)
59
+
if err != nil {
60
+
return c.JSON(http.StatusInternalServerError, map[string]any{
61
+
"error": "InternalError",
62
+
"message": "failed to query timeline",
63
+
})
64
+
}
65
+
66
+
// Hydrate posts
67
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
68
+
69
+
// Generate next cursor
70
+
var nextCursor string
71
+
if len(rows) > 0 {
72
+
// Get the created time of the last post
73
+
var lastCreated time.Time
74
+
lastURI := rows[len(rows)-1].URI
75
+
postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer)
76
+
if err == nil && postInfo.Post != nil {
77
+
t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt)
78
+
if err == nil {
79
+
lastCreated = t
80
+
nextCursor = lastCreated.Format(time.RFC3339)
81
+
}
82
+
}
83
+
}
84
+
85
+
return c.JSON(http.StatusOK, map[string]any{
86
+
"feed": feed,
87
+
"cursor": nextCursor,
88
+
})
89
+
}
90
+
91
+
func getTimelinePosts(ctx context.Context, db *gorm.DB, uid uint, cursor time.Time, limit int) ([]postRow, error) {
92
+
ctx, span := tracer.Start(ctx, "getTimelineQuery")
93
+
defer span.End()
94
+
95
+
var rows []postRow
96
+
err := db.Raw(`
97
+
SELECT
98
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
99
+
p.author as author_id
100
+
FROM posts p
101
+
JOIN repos r ON r.id = p.author
102
+
WHERE p.reply_to = 0
103
+
AND p.author IN (SELECT subject FROM follows WHERE author = ?)
104
+
AND p.created < ?
105
+
AND p.not_found = false
106
+
ORDER BY p.created DESC
107
+
LIMIT ?
108
+
`, uid, cursor, limit).Scan(&rows).Error
109
+
110
+
if err != nil {
111
+
return nil, err
112
+
}
113
+
return rows, nil
114
+
}
+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
+
}
+392
xrpc/notification/listNotifications.go
+392
xrpc/notification/listNotifications.go
···
1
+
package notification
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"net/http"
7
+
"strconv"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/lex/util"
13
+
lexutil "github.com/bluesky-social/indigo/lex/util"
14
+
"github.com/labstack/echo/v4"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
models "github.com/whyrusleeping/konbini/models"
17
+
"github.com/whyrusleeping/konbini/views"
18
+
"gorm.io/gorm"
19
+
"gorm.io/gorm/clause"
20
+
)
21
+
22
+
// HandleListNotifications implements app.bsky.notification.listNotifications
23
+
func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
24
+
viewer := getUserDID(c)
25
+
if viewer == "" {
26
+
return c.JSON(http.StatusUnauthorized, map[string]any{
27
+
"error": "AuthenticationRequired",
28
+
"message": "authentication required",
29
+
})
30
+
}
31
+
32
+
// Parse limit
33
+
limit := 50
34
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
35
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
36
+
limit = l
37
+
}
38
+
}
39
+
40
+
// Parse cursor (notification ID)
41
+
var cursor uint
42
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
43
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
44
+
cursor = uint(c)
45
+
}
46
+
}
47
+
48
+
ctx := c.Request().Context()
49
+
50
+
// Query notifications for viewer with CIDs from source records
51
+
type notifRow struct {
52
+
ID uint
53
+
Kind string
54
+
AuthorDid string
55
+
Source string
56
+
SourceCid string
57
+
CreatedAt string
58
+
}
59
+
var rows []notifRow
60
+
61
+
// This query tries to fetch the CID from the source record
62
+
// depending on the notification kind (like, repost, reply, etc.)
63
+
query := `
64
+
SELECT
65
+
n.id,
66
+
n.kind,
67
+
r.did as author_did,
68
+
n.source,
69
+
n.source_cid,
70
+
n.created_at
71
+
FROM notifications n
72
+
JOIN repos r ON r.id = n.author
73
+
LEFT JOIN repos r2 ON r2.id = n.author
74
+
WHERE n.for = (SELECT id FROM repos WHERE did = ?)
75
+
`
76
+
if cursor > 0 {
77
+
query += ` AND n.id < ?`
78
+
}
79
+
query += ` ORDER BY n.created_at DESC LIMIT ?`
80
+
81
+
var queryArgs []any
82
+
queryArgs = append(queryArgs, viewer)
83
+
if cursor > 0 {
84
+
queryArgs = append(queryArgs, cursor)
85
+
}
86
+
queryArgs = append(queryArgs, limit)
87
+
88
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
89
+
return c.JSON(http.StatusInternalServerError, map[string]any{
90
+
"error": "InternalError",
91
+
"message": "failed to query notifications",
92
+
})
93
+
}
94
+
95
+
// Hydrate notifications
96
+
notifications := make([]*bsky.NotificationListNotifications_Notification, 0)
97
+
for _, row := range rows {
98
+
authorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
99
+
if err != nil {
100
+
continue
101
+
}
102
+
103
+
// Skip notifications without CIDs as they're invalid
104
+
if row.SourceCid == "" {
105
+
continue
106
+
}
107
+
108
+
// Fetch and decode the raw record
109
+
recordDecoder, err := fetchNotificationRecord(db, row.Source, row.Kind)
110
+
if err != nil {
111
+
continue
112
+
}
113
+
114
+
notif := &bsky.NotificationListNotifications_Notification{
115
+
Uri: row.Source,
116
+
Cid: row.SourceCid,
117
+
Author: views.ProfileView(authorInfo),
118
+
Reason: mapNotifKind(row.Kind),
119
+
Record: recordDecoder,
120
+
IsRead: false,
121
+
IndexedAt: row.CreatedAt,
122
+
}
123
+
124
+
notifications = append(notifications, notif)
125
+
}
126
+
127
+
// Generate next cursor
128
+
var cursorPtr *string
129
+
if len(rows) > 0 {
130
+
cursor := strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
131
+
cursorPtr = &cursor
132
+
}
133
+
134
+
var lastSeen time.Time
135
+
if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = (select id from repos where did = ?)", viewer).Scan(&lastSeen).Error; err != nil {
136
+
return err
137
+
}
138
+
139
+
var lastSeenStr *string
140
+
if !lastSeen.IsZero() {
141
+
s := lastSeen.Format(time.RFC3339)
142
+
lastSeenStr = &s
143
+
}
144
+
145
+
output := &bsky.NotificationListNotifications_Output{
146
+
Notifications: notifications,
147
+
Cursor: cursorPtr,
148
+
SeenAt: lastSeenStr,
149
+
}
150
+
151
+
return c.JSON(http.StatusOK, output)
152
+
}
153
+
154
+
// HandleGetUnreadCount implements app.bsky.notification.getUnreadCount
155
+
func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
156
+
viewer := getUserDID(c)
157
+
if viewer == "" {
158
+
return c.JSON(http.StatusUnauthorized, map[string]any{
159
+
"error": "AuthenticationRequired",
160
+
"message": "authentication required",
161
+
})
162
+
}
163
+
164
+
var repo models.Repo
165
+
if err := db.Find(&repo, "did = ?", viewer).Error; err != nil {
166
+
return err
167
+
}
168
+
169
+
var lastSeen time.Time
170
+
if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = ?", repo.ID).Scan(&lastSeen).Error; err != nil {
171
+
return err
172
+
}
173
+
174
+
var count int
175
+
query := `SELECT count(*) FROM notifications WHERE created_at > ? AND for = ?`
176
+
if err := db.Raw(query, lastSeen, repo.ID).Scan(&count).Error; err != nil {
177
+
return c.JSON(http.StatusInternalServerError, map[string]any{
178
+
"error": "InternalError",
179
+
"message": "failed to count unread notifications",
180
+
})
181
+
}
182
+
183
+
return c.JSON(http.StatusOK, map[string]any{
184
+
"count": count,
185
+
})
186
+
}
187
+
188
+
// HandleUpdateSeen implements app.bsky.notification.updateSeen
189
+
func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
190
+
viewer := getUserDID(c)
191
+
if viewer == "" {
192
+
return c.JSON(http.StatusUnauthorized, map[string]any{
193
+
"error": "AuthenticationRequired",
194
+
"message": "authentication required",
195
+
})
196
+
}
197
+
198
+
var body bsky.NotificationUpdateSeen_Input
199
+
if err := c.Bind(&body); err != nil {
200
+
return c.JSON(http.StatusBadRequest, map[string]any{
201
+
"error": "InvalidRequest",
202
+
"message": "invalid request body",
203
+
})
204
+
}
205
+
206
+
// Parse the seenAt timestamp
207
+
seenAt, err := time.Parse(time.RFC3339, body.SeenAt)
208
+
if err != nil {
209
+
return c.JSON(http.StatusBadRequest, map[string]any{
210
+
"error": "InvalidRequest",
211
+
"message": "invalid seenAt timestamp",
212
+
})
213
+
}
214
+
215
+
// Get the viewer's repo ID
216
+
var repoID uint
217
+
if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&repoID).Error; err != nil {
218
+
return c.JSON(http.StatusInternalServerError, map[string]any{
219
+
"error": "InternalError",
220
+
"message": "failed to find viewer repo",
221
+
})
222
+
}
223
+
224
+
if repoID == 0 {
225
+
return c.JSON(http.StatusInternalServerError, map[string]any{
226
+
"error": "InternalError",
227
+
"message": "viewer repo not found",
228
+
})
229
+
}
230
+
231
+
// Upsert the NotificationSeen record
232
+
notifSeen := models.NotificationSeen{
233
+
Repo: repoID,
234
+
SeenAt: seenAt,
235
+
}
236
+
237
+
err = db.Clauses(clause.OnConflict{
238
+
Columns: []clause.Column{{Name: "repo"}},
239
+
DoUpdates: clause.AssignmentColumns([]string{"seen_at"}),
240
+
}).Create(¬ifSeen).Error
241
+
242
+
if err != nil {
243
+
return c.JSON(http.StatusInternalServerError, map[string]any{
244
+
"error": "InternalError",
245
+
"message": "failed to update seen timestamp",
246
+
})
247
+
}
248
+
249
+
return c.JSON(http.StatusOK, map[string]any{})
250
+
}
251
+
252
+
func getUserDID(c echo.Context) string {
253
+
did := c.Get("viewer")
254
+
if did == nil {
255
+
return ""
256
+
}
257
+
if s, ok := did.(string); ok {
258
+
return s
259
+
}
260
+
return ""
261
+
}
262
+
263
+
func mapNotifKind(kind string) string {
264
+
switch kind {
265
+
case "reply":
266
+
return "reply"
267
+
case "like":
268
+
return "like"
269
+
case "repost":
270
+
return "repost"
271
+
case "mention":
272
+
return "mention"
273
+
case "follow":
274
+
return "follow"
275
+
default:
276
+
return kind
277
+
}
278
+
}
279
+
280
+
// fetchNotificationRecord fetches and decodes the raw record for a notification
281
+
func fetchNotificationRecord(db *gorm.DB, sourceURI string, kind string) (*util.LexiconTypeDecoder, error) {
282
+
// Parse the source URI to extract DID and rkey
283
+
// URI format: at://did:plc:xxx/collection/rkey
284
+
did := extractDIDFromURI(sourceURI)
285
+
rkey := extractRkeyFromURI(sourceURI)
286
+
287
+
if did == "" || rkey == "" {
288
+
return nil, fmt.Errorf("invalid source URI")
289
+
}
290
+
291
+
var raw []byte
292
+
var err error
293
+
294
+
// Fetch raw data based on notification kind
295
+
switch kind {
296
+
case "reply", "mention", "quote":
297
+
// These reference posts
298
+
err = db.Raw(`
299
+
SELECT p.raw
300
+
FROM posts p
301
+
JOIN repos r ON r.id = p.author
302
+
WHERE r.did = ? AND p.rkey = ?
303
+
`, did, rkey).Scan(&raw).Error
304
+
305
+
case "like":
306
+
// we don't store the raw like objects, so we just reconstruct it here...
307
+
// These reference like records
308
+
var like models.Like
309
+
err = db.Raw(`
310
+
SELECT *
311
+
FROM likes l
312
+
JOIN repos r ON r.id = l.author
313
+
WHERE r.did = ? AND l.rkey = ?
314
+
`, did, rkey).Scan(&like).Error
315
+
316
+
lk := bsky.FeedLike{
317
+
CreatedAt: like.Created.Format(time.RFC3339),
318
+
Subject: &atproto.RepoStrongRef{
319
+
Cid: "",
320
+
Uri: "",
321
+
},
322
+
}
323
+
buf := new(bytes.Buffer)
324
+
if err := lk.MarshalCBOR(buf); err != nil {
325
+
return nil, fmt.Errorf("failed to marshal reconstructed like: %w", err)
326
+
}
327
+
raw = buf.Bytes()
328
+
329
+
case "repost":
330
+
// These reference repost records
331
+
err = db.Raw(`
332
+
SELECT r.raw
333
+
FROM reposts r
334
+
JOIN repos repo ON repo.id = r.author
335
+
WHERE repo.did = ? AND r.rkey = ?
336
+
`, did, rkey).Scan(&raw).Error
337
+
338
+
case "follow":
339
+
// These reference follow records
340
+
err = db.Raw(`
341
+
SELECT f.raw
342
+
FROM follows f
343
+
JOIN repos r ON r.id = f.author
344
+
WHERE r.did = ? AND f.rkey = ?
345
+
`, did, rkey).Scan(&raw).Error
346
+
347
+
default:
348
+
return nil, fmt.Errorf("unknown notification kind: %s", kind)
349
+
}
350
+
351
+
if err != nil || len(raw) == 0 {
352
+
return nil, fmt.Errorf("failed to fetch record: %w", err)
353
+
}
354
+
355
+
// Decode the CBOR data
356
+
decoded, err := lexutil.CborDecodeValue(raw)
357
+
if err != nil {
358
+
return nil, fmt.Errorf("failed to decode CBOR: %w", err)
359
+
}
360
+
361
+
return &util.LexiconTypeDecoder{
362
+
Val: decoded,
363
+
}, nil
364
+
}
365
+
366
+
func extractDIDFromURI(uri string) string {
367
+
// URI format: at://did:plc:xxx/collection/rkey
368
+
if len(uri) < 5 || uri[:5] != "at://" {
369
+
return ""
370
+
}
371
+
parts := []rune(uri[5:])
372
+
for i, r := range parts {
373
+
if r == '/' {
374
+
return string(parts[:i])
375
+
}
376
+
}
377
+
return string(parts)
378
+
}
379
+
380
+
func extractRkeyFromURI(uri string) string {
381
+
// URI format: at://did:plc:xxx/collection/rkey
382
+
if len(uri) < 5 || uri[:5] != "at://" {
383
+
return ""
384
+
}
385
+
// Find last slash
386
+
for i := len(uri) - 1; i >= 5; i-- {
387
+
if uri[i] == '/' {
388
+
return uri[i+1:]
389
+
}
390
+
}
391
+
return ""
392
+
}
+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
+
}
+223
xrpc/server.go
+223
xrpc/server.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/identity"
9
+
"github.com/labstack/echo/v4"
10
+
"github.com/labstack/echo/v4/middleware"
11
+
"github.com/whyrusleeping/konbini/backend"
12
+
"github.com/whyrusleeping/konbini/hydration"
13
+
"github.com/whyrusleeping/konbini/models"
14
+
"github.com/whyrusleeping/konbini/xrpc/actor"
15
+
"github.com/whyrusleeping/konbini/xrpc/feed"
16
+
"github.com/whyrusleeping/konbini/xrpc/graph"
17
+
"github.com/whyrusleeping/konbini/xrpc/labeler"
18
+
"github.com/whyrusleeping/konbini/xrpc/notification"
19
+
"github.com/whyrusleeping/konbini/xrpc/repo"
20
+
"github.com/whyrusleeping/konbini/xrpc/unspecced"
21
+
"gorm.io/gorm"
22
+
)
23
+
24
+
// Server represents the XRPC API server
25
+
type Server struct {
26
+
e *echo.Echo
27
+
db *gorm.DB
28
+
dir identity.Directory
29
+
backend Backend
30
+
hydrator *hydration.Hydrator
31
+
}
32
+
33
+
// Backend interface for data access
34
+
type Backend interface {
35
+
// Add methods as needed for data access
36
+
37
+
TrackMissingRecord(identifier string, wait bool)
38
+
GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error)
39
+
}
40
+
41
+
// NewServer creates a new XRPC server
42
+
func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server {
43
+
e := echo.New()
44
+
e.HidePort = true
45
+
e.HideBanner = true
46
+
47
+
// CORS middleware
48
+
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
49
+
AllowOrigins: []string{"*"},
50
+
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
51
+
AllowHeaders: []string{"*"},
52
+
}))
53
+
54
+
// Logging middleware
55
+
e.Use(middleware.Logger())
56
+
e.Use(middleware.Recover())
57
+
58
+
s := &Server{
59
+
e: e,
60
+
db: db,
61
+
dir: dir,
62
+
backend: backend,
63
+
hydrator: hydration.NewHydrator(db, dir, backend),
64
+
}
65
+
66
+
// Register XRPC endpoints
67
+
s.registerEndpoints()
68
+
69
+
return s
70
+
}
71
+
72
+
// Start starts the XRPC server
73
+
func (s *Server) Start(addr string) error {
74
+
slog.Info("starting XRPC server", "addr", addr)
75
+
return s.e.Start(addr)
76
+
}
77
+
78
+
// registerEndpoints registers all XRPC endpoints
79
+
func (s *Server) registerEndpoints() {
80
+
// XRPC endpoints follow the pattern: /xrpc/<namespace>.<method>
81
+
82
+
s.e.GET("/.well-known/did.json", func(c echo.Context) error {
83
+
return c.File("did.json")
84
+
})
85
+
86
+
xrpcGroup := s.e.Group("/xrpc")
87
+
88
+
// com.atproto.identity.*
89
+
xrpcGroup.GET("/com.atproto.identity.resolveHandle", s.handleResolveHandle)
90
+
91
+
// com.atproto.repo.*
92
+
xrpcGroup.GET("/com.atproto.repo.getRecord", func(c echo.Context) error {
93
+
return repo.HandleGetRecord(c, s.db, s.hydrator)
94
+
})
95
+
96
+
// app.bsky.actor.*
97
+
xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error {
98
+
return actor.HandleGetProfile(c, s.hydrator)
99
+
}, s.optionalAuth)
100
+
xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error {
101
+
return actor.HandleGetProfiles(c, s.db, s.hydrator)
102
+
}, s.optionalAuth)
103
+
xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error {
104
+
return actor.HandleGetPreferences(c, s.db, s.hydrator)
105
+
}, s.requireAuth)
106
+
xrpcGroup.POST("/app.bsky.actor.putPreferences", func(c echo.Context) error {
107
+
return actor.HandlePutPreferences(c, s.db, s.hydrator)
108
+
}, s.requireAuth)
109
+
xrpcGroup.GET("/app.bsky.actor.searchActors", s.handleSearchActors)
110
+
xrpcGroup.GET("/app.bsky.actor.searchActorsTypeahead", s.handleSearchActorsTypeahead)
111
+
112
+
// app.bsky.feed.*
113
+
xrpcGroup.GET("/app.bsky.feed.getTimeline", func(c echo.Context) error {
114
+
return feed.HandleGetTimeline(c, s.db, s.hydrator)
115
+
}, s.requireAuth)
116
+
xrpcGroup.GET("/app.bsky.feed.getAuthorFeed", func(c echo.Context) error {
117
+
return feed.HandleGetAuthorFeed(c, s.db, s.hydrator)
118
+
})
119
+
xrpcGroup.GET("/app.bsky.feed.getPostThread", func(c echo.Context) error {
120
+
return feed.HandleGetPostThread(c, s.db, s.hydrator)
121
+
})
122
+
xrpcGroup.GET("/app.bsky.feed.getPosts", func(c echo.Context) error {
123
+
return feed.HandleGetPosts(c, s.hydrator)
124
+
})
125
+
xrpcGroup.GET("/app.bsky.feed.getLikes", func(c echo.Context) error {
126
+
return feed.HandleGetLikes(c, s.db, s.hydrator)
127
+
})
128
+
xrpcGroup.GET("/app.bsky.feed.getRepostedBy", func(c echo.Context) error {
129
+
return feed.HandleGetRepostedBy(c, s.db, s.hydrator)
130
+
})
131
+
xrpcGroup.GET("/app.bsky.feed.getActorLikes", func(c echo.Context) error {
132
+
return feed.HandleGetActorLikes(c, s.db, s.hydrator)
133
+
}, s.requireAuth)
134
+
xrpcGroup.GET("/app.bsky.feed.getFeed", func(c echo.Context) error {
135
+
return feed.HandleGetFeed(c, s.db, s.hydrator, s.dir)
136
+
}, s.optionalAuth)
137
+
xrpcGroup.GET("/app.bsky.feed.getFeedGenerator", func(c echo.Context) error {
138
+
return feed.HandleGetFeedGenerator(c, s.db, s.hydrator, s.dir)
139
+
})
140
+
141
+
// app.bsky.graph.*
142
+
xrpcGroup.GET("/app.bsky.graph.getFollows", func(c echo.Context) error {
143
+
return graph.HandleGetFollows(c, s.db, s.hydrator)
144
+
})
145
+
xrpcGroup.GET("/app.bsky.graph.getFollowers", func(c echo.Context) error {
146
+
return graph.HandleGetFollowers(c, s.db, s.hydrator)
147
+
})
148
+
xrpcGroup.GET("/app.bsky.graph.getBlocks", func(c echo.Context) error {
149
+
return graph.HandleGetBlocks(c, s.db, s.hydrator)
150
+
}, s.requireAuth)
151
+
xrpcGroup.GET("/app.bsky.graph.getMutes", func(c echo.Context) error {
152
+
return graph.HandleGetMutes(c, s.db, s.hydrator)
153
+
}, s.requireAuth)
154
+
xrpcGroup.GET("/app.bsky.graph.getRelationships", func(c echo.Context) error {
155
+
return graph.HandleGetRelationships(c, s.db, s.hydrator)
156
+
})
157
+
xrpcGroup.GET("/app.bsky.graph.getLists", s.handleGetLists)
158
+
xrpcGroup.GET("/app.bsky.graph.getList", s.handleGetList)
159
+
160
+
// app.bsky.notification.*
161
+
xrpcGroup.GET("/app.bsky.notification.listNotifications", func(c echo.Context) error {
162
+
return notification.HandleListNotifications(c, s.db, s.hydrator)
163
+
}, s.requireAuth)
164
+
xrpcGroup.GET("/app.bsky.notification.getUnreadCount", func(c echo.Context) error {
165
+
return notification.HandleGetUnreadCount(c, s.db, s.hydrator)
166
+
}, s.requireAuth)
167
+
xrpcGroup.POST("/app.bsky.notification.updateSeen", func(c echo.Context) error {
168
+
return notification.HandleUpdateSeen(c, s.db, s.hydrator)
169
+
}, s.requireAuth)
170
+
171
+
// app.bsky.labeler.*
172
+
xrpcGroup.GET("/app.bsky.labeler.getServices", func(c echo.Context) error {
173
+
return labeler.HandleGetServices(c)
174
+
})
175
+
176
+
// app.bsky.unspecced.*
177
+
xrpcGroup.GET("/app.bsky.unspecced.getConfig", func(c echo.Context) error {
178
+
return unspecced.HandleGetConfig(c)
179
+
})
180
+
xrpcGroup.GET("/app.bsky.unspecced.getTrendingTopics", func(c echo.Context) error {
181
+
return unspecced.HandleGetTrendingTopics(c)
182
+
})
183
+
xrpcGroup.GET("/app.bsky.unspecced.getPostThreadV2", func(c echo.Context) error {
184
+
return unspecced.HandleGetPostThreadV2(c, s.db, s.hydrator)
185
+
})
186
+
}
187
+
188
+
// XRPCError creates a properly formatted XRPC error response
189
+
func XRPCError(c echo.Context, statusCode int, errType, message string) error {
190
+
return c.JSON(statusCode, map[string]interface{}{
191
+
"error": errType,
192
+
"message": message,
193
+
})
194
+
}
195
+
196
+
// getUserDID extracts the viewer DID from the request context
197
+
// Returns empty string if not authenticated
198
+
func getUserDID(c echo.Context) string {
199
+
did := c.Get("viewer")
200
+
if did == nil {
201
+
return ""
202
+
}
203
+
if s, ok := did.(string); ok {
204
+
return s
205
+
}
206
+
return ""
207
+
}
208
+
209
+
func (s *Server) handleSearchActors(c echo.Context) error {
210
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
211
+
}
212
+
213
+
func (s *Server) handleSearchActorsTypeahead(c echo.Context) error {
214
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
215
+
}
216
+
217
+
func (s *Server) handleGetLists(c echo.Context) error {
218
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
219
+
}
220
+
221
+
func (s *Server) handleGetList(c echo.Context) error {
222
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
223
+
}
+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
+
}
+362
xrpc/unspecced/getPostThreadV2.go
+362
xrpc/unspecced/getPostThreadV2.go
···
1
+
package unspecced
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"net/http"
9
+
"strconv"
10
+
"sync"
11
+
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/labstack/echo/v4"
14
+
"github.com/whyrusleeping/konbini/hydration"
15
+
"github.com/whyrusleeping/konbini/views"
16
+
"github.com/whyrusleeping/market/models"
17
+
"go.opentelemetry.io/otel"
18
+
"gorm.io/gorm"
19
+
)
20
+
21
+
var tracer = otel.Tracer("xrpc/unspecced")
22
+
23
+
// HandleGetPostThreadV2 implements app.bsky.unspecced.getPostThreadV2
24
+
func HandleGetPostThreadV2(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
25
+
ctx, span := tracer.Start(c.Request().Context(), "getPostThreadV2")
26
+
defer span.End()
27
+
ctx = context.WithValue(ctx, "auto-fetch", true)
28
+
29
+
// Parse parameters
30
+
anchorRaw := c.QueryParam("anchor")
31
+
if anchorRaw == "" {
32
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
33
+
"error": "InvalidRequest",
34
+
"message": "anchor parameter is required",
35
+
})
36
+
}
37
+
38
+
anchorUri, err := hydrator.NormalizeUri(ctx, anchorRaw)
39
+
if err != nil {
40
+
return err
41
+
}
42
+
43
+
// Parse optional parameters with defaults
44
+
above := c.QueryParam("above") != "false" // default true
45
+
46
+
below := int64(6) // default
47
+
if belowParam := c.QueryParam("below"); belowParam != "" {
48
+
if b, err := strconv.ParseInt(belowParam, 10, 64); err == nil && b >= 0 && b <= 20 {
49
+
below = b
50
+
}
51
+
}
52
+
53
+
branchingFactor := int64(10) // default
54
+
if bfParam := c.QueryParam("branchingFactor"); bfParam != "" {
55
+
if bf, err := strconv.ParseInt(bfParam, 10, 64); err == nil && bf > 0 {
56
+
branchingFactor = bf
57
+
}
58
+
}
59
+
60
+
_ = c.QueryParam("prioritizeFollowedUsers") == "true" // TODO: implement prioritization
61
+
62
+
sort := c.QueryParam("sort")
63
+
if sort == "" {
64
+
sort = "newest"
65
+
}
66
+
67
+
viewer := getUserDID(c)
68
+
69
+
// Hydrate the anchor post
70
+
anchorPostInfo, err := hydrator.HydratePost(ctx, anchorUri, viewer)
71
+
if err != nil {
72
+
slog.Error("failed to hydrate post", "error", err, "anchor", anchorUri)
73
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
74
+
"error": "NotFound",
75
+
"message": "anchor post not found",
76
+
})
77
+
}
78
+
79
+
threadID := anchorPostInfo.InThread
80
+
if threadID == 0 {
81
+
threadID = anchorPostInfo.ID
82
+
}
83
+
84
+
var threadPosts []*models.Post
85
+
if err := db.Raw("SELECT * FROM posts WHERE in_thread = ? OR id = ?", threadID, anchorPostInfo.ID).Scan(&threadPosts).Error; err != nil {
86
+
return err
87
+
}
88
+
89
+
fmt.Println("GOT THREAD POSTS: ", len(threadPosts))
90
+
91
+
treeNodes, err := buildThreadTree(ctx, hydrator, db, threadPosts)
92
+
if err != nil {
93
+
return fmt.Errorf("failed to construct tree: %w", err)
94
+
}
95
+
96
+
anchor := treeNodes[anchorPostInfo.ID]
97
+
98
+
// Build flat thread items list
99
+
var threadItems []*bsky.UnspeccedGetPostThreadV2_ThreadItem
100
+
hasOtherReplies := false
101
+
102
+
// Add parents if requested
103
+
if above {
104
+
parent := anchor.parent
105
+
depth := int64(-1)
106
+
for parent != nil {
107
+
if parent.missing {
108
+
fmt.Println("Parent missing: ", depth)
109
+
item := &bsky.UnspeccedGetPostThreadV2_ThreadItem{
110
+
Depth: depth,
111
+
Uri: parent.uri,
112
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
113
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
114
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
115
+
},
116
+
},
117
+
}
118
+
119
+
threadItems = append(threadItems, item)
120
+
break
121
+
}
122
+
123
+
item := buildThreadItem(ctx, hydrator, parent, depth, viewer)
124
+
if item != nil {
125
+
threadItems = append(threadItems, item)
126
+
}
127
+
128
+
parent = parent.parent
129
+
depth--
130
+
}
131
+
}
132
+
133
+
// Add anchor post (depth 0)
134
+
anchorItem := buildThreadItem(ctx, hydrator, anchor, 0, viewer)
135
+
if anchorItem != nil {
136
+
threadItems = append(threadItems, anchorItem)
137
+
}
138
+
139
+
// Add replies below anchor
140
+
if below > 0 {
141
+
replies, err := collectReplies(ctx, hydrator, anchor, 0, below, branchingFactor, sort, viewer)
142
+
if err != nil {
143
+
return err
144
+
}
145
+
threadItems = append(threadItems, replies...)
146
+
//hasOtherReplies = hasMore
147
+
}
148
+
149
+
return c.JSON(http.StatusOK, &bsky.UnspeccedGetPostThreadV2_Output{
150
+
Thread: threadItems,
151
+
HasOtherReplies: hasOtherReplies,
152
+
})
153
+
}
154
+
155
+
func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, curnode *threadTree, depth int64, below int64, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, error) {
156
+
if below == 0 {
157
+
return nil, nil
158
+
}
159
+
160
+
type parThreadResults struct {
161
+
node *bsky.UnspeccedGetPostThreadV2_ThreadItem
162
+
children []*bsky.UnspeccedGetPostThreadV2_ThreadItem
163
+
}
164
+
165
+
results := make([]parThreadResults, len(curnode.children))
166
+
167
+
var wg sync.WaitGroup
168
+
for i := range curnode.children {
169
+
ix := i
170
+
wg.Go(func() {
171
+
child := curnode.children[ix]
172
+
173
+
results[ix].node = buildThreadItem(ctx, hydrator, child, depth+1, viewer)
174
+
if child.missing {
175
+
return
176
+
}
177
+
178
+
sub, err := collectReplies(ctx, hydrator, child, depth+1, below-1, branchingFactor, sort, viewer)
179
+
if err != nil {
180
+
slog.Error("failed to collect replies", "node", child.uri, "error", err)
181
+
return
182
+
}
183
+
184
+
results[ix].children = sub
185
+
})
186
+
}
187
+
188
+
wg.Wait()
189
+
190
+
var out []*bsky.UnspeccedGetPostThreadV2_ThreadItem
191
+
for _, res := range results {
192
+
out = append(out, res.node)
193
+
out = append(out, res.children...)
194
+
}
195
+
196
+
return out, nil
197
+
}
198
+
199
+
func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadTree, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem {
200
+
if node.missing {
201
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
202
+
Depth: depth,
203
+
Uri: node.uri,
204
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
205
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
206
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
207
+
},
208
+
},
209
+
}
210
+
}
211
+
212
+
// Hydrate the post
213
+
postInfo, err := hydrator.HydratePostDB(ctx, node.uri, node.val, viewer)
214
+
if err != nil {
215
+
slog.Error("failed to hydrate post in thread item", "uri", node.uri, "error", err)
216
+
// Return not found item
217
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
218
+
Depth: depth,
219
+
Uri: node.uri,
220
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
221
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
222
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
223
+
},
224
+
},
225
+
}
226
+
}
227
+
228
+
// Hydrate author
229
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
230
+
if err != nil {
231
+
slog.Error("failed to hydrate actor in thread item", "author", postInfo.Author, "error", err)
232
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
233
+
Depth: depth,
234
+
Uri: node.uri,
235
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
236
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
237
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
238
+
},
239
+
},
240
+
}
241
+
}
242
+
243
+
// Build post view
244
+
postView := views.PostView(postInfo, authorInfo)
245
+
246
+
// Calculate moreReplies count
247
+
moreReplies := int64(0)
248
+
if len(node.children) > 0 {
249
+
// This is a simplified calculation - actual count would need more complex logic
250
+
moreReplies = int64(len(node.children))
251
+
}
252
+
253
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
254
+
Depth: depth,
255
+
Uri: node.uri,
256
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
257
+
UnspeccedDefs_ThreadItemPost: &bsky.UnspeccedDefs_ThreadItemPost{
258
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemPost",
259
+
Post: postView,
260
+
HiddenByThreadgate: false,
261
+
MoreParents: false,
262
+
MoreReplies: moreReplies,
263
+
MutedByViewer: false,
264
+
OpThread: false, // TODO: Calculate this properly
265
+
},
266
+
},
267
+
}
268
+
}
269
+
270
+
func getUserDID(c echo.Context) string {
271
+
did := c.Get("viewer")
272
+
if did == nil {
273
+
return ""
274
+
}
275
+
if s, ok := did.(string); ok {
276
+
return s
277
+
}
278
+
return ""
279
+
}
280
+
281
+
func extractDIDFromURI(uri string) string {
282
+
// URI format: at://did:plc:xxx/collection/rkey
283
+
if len(uri) < 5 || uri[:5] != "at://" {
284
+
return ""
285
+
}
286
+
parts := []rune(uri[5:])
287
+
for i, r := range parts {
288
+
if r == '/' {
289
+
return string(parts[:i])
290
+
}
291
+
}
292
+
return string(parts)
293
+
}
294
+
295
+
type threadTree struct {
296
+
parent *threadTree
297
+
children []*threadTree
298
+
299
+
val *models.Post
300
+
301
+
missing bool
302
+
303
+
uri string
304
+
cid string
305
+
}
306
+
307
+
func buildThreadTree(ctx context.Context, hydrator *hydration.Hydrator, db *gorm.DB, posts []*models.Post) (map[uint]*threadTree, error) {
308
+
nodes := make(map[uint]*threadTree)
309
+
for _, p := range posts {
310
+
puri, err := hydrator.UriForPost(ctx, p)
311
+
if err != nil {
312
+
return nil, err
313
+
}
314
+
315
+
t := &threadTree{
316
+
val: p,
317
+
uri: puri,
318
+
}
319
+
320
+
nodes[p.ID] = t
321
+
}
322
+
323
+
missing := make(map[uint]*threadTree)
324
+
for _, node := range nodes {
325
+
if node.val.ReplyTo == 0 {
326
+
continue
327
+
}
328
+
329
+
pnode, ok := nodes[node.val.ReplyTo]
330
+
if !ok {
331
+
pnode = &threadTree{
332
+
missing: true,
333
+
}
334
+
missing[node.val.ReplyTo] = pnode
335
+
336
+
var bspost bsky.FeedPost
337
+
if err := bspost.UnmarshalCBOR(bytes.NewReader(node.val.Raw)); err != nil {
338
+
return nil, err
339
+
}
340
+
341
+
if bspost.Reply == nil || bspost.Reply.Parent == nil {
342
+
return nil, fmt.Errorf("node with parent had no parent in object")
343
+
}
344
+
345
+
pnode.uri = bspost.Reply.Parent.Uri
346
+
pnode.cid = bspost.Reply.Parent.Cid
347
+
348
+
/* Maybe we could force hydrate these?
349
+
hydrator.AddMissingRecord(puri, true)
350
+
*/
351
+
}
352
+
353
+
pnode.children = append(pnode.children, node)
354
+
node.parent = pnode
355
+
}
356
+
357
+
for k, v := range missing {
358
+
nodes[k] = v
359
+
}
360
+
361
+
return nodes, nil
362
+
}
+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
+
}