+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
README.md
+135
README.md
···
19
19
### Prerequisites
20
20
21
21
- Docker and Docker Compose installed
22
+
- Creating an app password (via: https://bsky.app/settings/app-passwords)
22
23
23
24
### Setup
24
25
···
108
109
109
110
The frontend will be available at http://localhost:3000 and will connect to the API at http://localhost:4444.
110
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
+
111
242
## License
112
243
113
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, cc, 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, cc, 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, cc, 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, cc, 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
+18
-9
go.mod
+18
-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
7
-
github.com/golang-jwt/jwt/v5 v5.2.2
6
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
7
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1
8
8
github.com/gorilla/websocket v1.5.1
9
9
github.com/hashicorp/golang-lru/v2 v2.0.7
10
10
github.com/ipfs/go-cid v0.4.1
11
11
github.com/jackc/pgx/v5 v5.6.0
12
12
github.com/labstack/echo/v4 v4.11.3
13
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
14
16
github.com/prometheus/client_golang v1.19.1
15
17
github.com/urfave/cli/v2 v2.27.7
18
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
16
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
17
23
gorm.io/gorm v1.31.0
18
24
)
19
25
···
25
31
github.com/cespare/xxhash/v2 v2.3.0 // indirect
26
32
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
27
33
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
34
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
28
35
github.com/felixge/httpsnoop v1.0.4 // indirect
29
36
github.com/go-logr/logr v1.4.2 // indirect
30
37
github.com/go-logr/stdr v1.2.2 // indirect
38
+
github.com/go-redis/cache/v9 v9.0.0 // indirect
31
39
github.com/goccy/go-json v0.10.5 // indirect
32
40
github.com/gogo/protobuf v1.3.2 // indirect
33
41
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
···
53
61
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
54
62
github.com/ipfs/go-peertaskqueue v0.8.1 // indirect
55
63
github.com/ipfs/go-verifcid v0.0.3 // indirect
56
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 // indirect
64
+
github.com/ipld/go-car v0.6.2 // indirect
57
65
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
58
66
github.com/ipld/go-ipld-prime v0.21.0 // indirect
59
67
github.com/jackc/pgpassfile v1.0.0 // indirect
···
62
70
github.com/jbenet/goprocess v0.1.4 // indirect
63
71
github.com/jinzhu/inflection v1.0.0 // indirect
64
72
github.com/jinzhu/now v1.1.5 // indirect
73
+
github.com/klauspost/compress v1.17.9 // indirect
65
74
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
66
75
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
67
76
github.com/lestrrat-go/httpcc v1.0.1 // indirect
68
77
github.com/lestrrat-go/httprc v1.0.4 // indirect
69
78
github.com/lestrrat-go/iter v1.0.2 // indirect
70
-
github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect
71
79
github.com/lestrrat-go/option v1.0.1 // indirect
72
80
github.com/libp2p/go-libp2p v0.25.1 // indirect
73
81
github.com/mattn/go-colorable v0.1.13 // indirect
···
79
87
github.com/multiformats/go-base36 v0.2.0 // indirect
80
88
github.com/multiformats/go-multiaddr v0.8.0 // indirect
81
89
github.com/multiformats/go-multibase v0.2.0 // indirect
82
-
github.com/multiformats/go-multihash v0.2.3 // indirect
83
90
github.com/multiformats/go-varint v0.0.7 // indirect
84
91
github.com/opentracing/opentracing-go v1.2.0 // indirect
85
92
github.com/orandin/slog-gorm v1.3.2 // indirect
86
93
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
87
94
github.com/prometheus/client_model v0.6.1 // indirect
88
-
github.com/prometheus/common v0.48.0 // indirect
89
-
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
90
98
github.com/russross/blackfriday/v2 v2.1.0 // indirect
91
99
github.com/segmentio/asm v1.2.0 // indirect
92
100
github.com/spaolacci/murmur3 v1.1.0 // indirect
93
101
github.com/valyala/bytebufferpool v1.0.0 // indirect
94
102
github.com/valyala/fasttemplate v1.2.2 // indirect
95
-
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
96
106
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
97
107
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
98
108
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
99
109
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
100
110
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
101
111
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
102
-
go.opentelemetry.io/otel v1.34.0 // indirect
103
112
go.opentelemetry.io/otel/metric v1.34.0 // indirect
104
113
go.opentelemetry.io/otel/trace v1.34.0 // indirect
105
114
go.uber.org/atomic v1.11.0 // indirect
+155
-10
go.sum
+155
-10
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=
47
-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
48
-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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=
49
84
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
50
85
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
51
86
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
52
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=
53
89
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
54
90
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
55
91
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
···
69
105
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
70
106
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
71
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=
72
109
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
73
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=
74
112
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
75
113
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
76
114
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
···
122
160
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
123
161
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
124
162
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
125
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
126
-
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=
127
165
github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4=
128
166
github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo=
129
167
github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc=
···
151
189
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
152
190
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
153
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=
154
195
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
155
196
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
156
197
github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8=
157
198
github.com/koron/go-ssdp v0.0.3/go.mod h1:b2MxI6yh02pKrsyNoQUsk4+YNikaGhe4894J+Q5lDvA=
158
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=
159
201
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
160
202
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
161
203
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
···
231
273
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
232
274
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
233
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=
234
302
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
235
303
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
236
304
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
···
246
314
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
247
315
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
248
316
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
249
-
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
250
-
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
251
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
252
-
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=
253
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=
254
326
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
255
327
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
256
328
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
···
267
339
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
268
340
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
269
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=
270
343
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
271
344
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
272
345
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
273
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=
274
348
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
275
349
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
276
350
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
277
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=
278
353
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
279
354
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
280
355
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
···
285
360
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
286
361
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
287
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=
288
370
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
289
371
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
290
372
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
···
302
384
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
303
385
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
304
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=
305
388
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
306
389
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
307
390
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
···
313
396
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
314
397
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
315
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=
316
401
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
317
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=
318
405
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
319
406
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
320
407
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
338
425
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
339
426
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
340
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=
341
429
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
342
430
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
343
431
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
···
348
436
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
349
437
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
350
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=
351
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=
352
443
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
353
444
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
354
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=
355
447
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
356
448
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
357
449
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
358
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=
359
452
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
360
453
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
361
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=
362
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=
363
464
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
364
465
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
365
466
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
366
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=
367
469
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
368
470
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
369
471
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
372
474
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
373
475
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
374
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=
375
478
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
376
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=
377
485
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
378
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=
379
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=
380
490
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
381
491
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
382
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=
383
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=
384
498
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
385
499
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
386
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=
387
505
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
388
506
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
389
507
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
392
510
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
393
511
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
394
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=
395
517
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
396
518
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
397
519
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
398
520
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
399
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=
400
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=
401
527
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
402
528
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
403
529
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
···
413
539
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
414
540
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
415
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=
416
543
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
417
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=
418
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=
419
549
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
420
550
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
421
551
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
···
425
555
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
426
556
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
427
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=
428
567
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
429
568
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
430
569
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
431
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=
432
572
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
433
573
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
434
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=
435
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=
436
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=
437
582
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
438
583
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
439
584
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+61
-41
handlers.go
+61
-41
handlers.go
···
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 {
···
24
27
e.Use(middleware.CORS())
25
28
e.GET("/debug", s.handleGetDebugInfo)
26
29
e.GET("/reldids", s.handleGetRelevantDids)
30
+
e.GET("/rescan/:did", s.handleRescanDid)
27
31
28
32
views := e.Group("/api")
29
33
views.GET("/me", s.handleGetMe)
···
53
57
54
58
func (s *Server) handleGetRelevantDids(e echo.Context) error {
55
59
return e.JSON(200, map[string]any{
56
-
"dids": s.backend.relevantDids,
60
+
"dids": s.backend.GetRelevantDids(),
57
61
})
58
62
}
59
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
+
60
80
func (s *Server) handleGetMe(e echo.Context) error {
61
81
ctx := e.Request().Context()
62
82
···
86
106
87
107
postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
88
108
89
-
p, err := s.backend.getPostByUri(ctx, postUri, "*")
109
+
p, err := s.backend.GetPostByUri(ctx, postUri, "*")
90
110
if err != nil {
91
111
return err
92
112
}
···
115
135
return err
116
136
}
117
137
118
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
138
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
119
139
if err != nil {
120
140
return err
121
141
}
122
142
123
143
var profile models.Profile
124
-
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 {
125
145
return err
126
146
}
127
147
128
148
if profile.Raw == nil || len(profile.Raw) == 0 {
129
-
s.addMissingProfile(ctx, accdid)
149
+
s.backend.TrackMissingRecord(accdid, false)
130
150
return e.JSON(404, map[string]any{
131
151
"error": "missing profile info for user",
132
152
})
···
150
170
return err
151
171
}
152
172
153
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
173
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
154
174
if err != nil {
155
175
return err
156
176
}
···
169
189
}
170
190
171
191
var dbposts []models.Post
172
-
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 {
173
193
return err
174
194
}
175
195
···
239
259
func (s *Server) handleGetFollowingFeed(e echo.Context) error {
240
260
ctx := e.Request().Context()
241
261
242
-
myr, err := s.backend.getOrCreateRepo(ctx, s.mydid)
262
+
myr, err := s.backend.GetOrCreateRepo(ctx, s.mydid)
243
263
if err != nil {
244
264
return err
245
265
}
···
257
277
tcursor = t
258
278
}
259
279
var dbposts []models.Post
260
-
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 {
261
281
return err
262
282
}
263
283
···
277
297
278
298
func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) {
279
299
var profile models.Profile
280
-
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 {
281
301
return nil, err
282
302
}
283
303
···
287
307
}
288
308
289
309
if profile.Raw == nil || len(profile.Raw) == 0 {
290
-
s.addMissingProfile(ctx, r.Did)
310
+
s.backend.TrackMissingRecord(r.Did, false)
291
311
return &authorInfo{
292
312
Handle: resp.Handle.String(),
293
313
Did: r.Did,
···
314
334
315
335
go func() {
316
336
defer wg.Done()
317
-
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 {
318
338
slog.Error("failed to get likes count", "post", pid, "error", err)
319
339
}
320
340
}()
321
341
322
342
go func() {
323
343
defer wg.Done()
324
-
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 {
325
345
slog.Error("failed to get reposts count", "post", pid, "error", err)
326
346
}
327
347
}()
328
348
329
349
go func() {
330
350
defer wg.Done()
331
-
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 {
332
352
slog.Error("failed to get replies count", "post", pid, "error", err)
333
353
}
334
354
}()
···
347
367
go func(ix int) {
348
368
defer wg.Done()
349
369
p := dbposts[ix]
350
-
r, err := s.backend.getRepoByID(ctx, p.Author)
370
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
351
371
if err != nil {
352
372
fmt.Println("failed to get repo: ", err)
353
373
posts[ix] = postResponse{
···
359
379
360
380
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey)
361
381
if len(p.Raw) == 0 || p.NotFound {
362
-
s.addMissingPost(ctx, uri)
382
+
s.backend.TrackMissingRecord(uri, false)
363
383
posts[ix] = postResponse{
364
384
Uri: uri,
365
385
Missing: true,
···
415
435
416
436
func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike {
417
437
var like Like
418
-
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 {
419
439
slog.Error("failed to lookup like", "error", err)
420
440
return nil
421
441
}
···
492
512
quotedURI := embedRecord.Record.Uri
493
513
quotedCid := embedRecord.Record.Cid
494
514
495
-
quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*")
515
+
quotedPost, err := s.backend.GetPostByUri(ctx, quotedURI, "*")
496
516
if err != nil {
497
517
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
498
-
s.addMissingPost(ctx, quotedURI)
518
+
s.backend.TrackMissingRecord(quotedURI, false)
499
519
return s.buildQuoteFallback(quotedURI, quotedCid)
500
520
}
501
521
502
522
if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound {
503
-
s.addMissingPost(ctx, quotedURI)
523
+
s.backend.TrackMissingRecord(quotedURI, false)
504
524
return s.buildQuoteFallback(quotedURI, quotedCid)
505
525
}
506
526
···
510
530
return s.buildQuoteFallback(quotedURI, quotedCid)
511
531
}
512
532
513
-
quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author)
533
+
quotedRepo, err := s.backend.GetRepoByID(ctx, quotedPost.Author)
514
534
if err != nil {
515
535
slog.Warn("failed to get quoted post author", "error", err)
516
536
return s.buildQuoteFallback(quotedURI, quotedCid)
···
557
577
558
578
// Get the requested post to find the thread root
559
579
var requestedPost models.Post
560
-
if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
580
+
if err := s.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
561
581
return err
562
582
}
563
583
···
576
596
// Get all posts in this thread
577
597
var dbposts []models.Post
578
598
query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC"
579
-
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 {
580
600
return err
581
601
}
582
602
583
603
// Build response for each post
584
604
posts := []postResponse{}
585
605
for _, p := range dbposts {
586
-
r, err := s.backend.getRepoByID(ctx, p.Author)
606
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
587
607
if err != nil {
588
608
return err
589
609
}
···
657
677
658
678
// Get all likes for this post
659
679
var likes []models.Like
660
-
if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil {
680
+
if err := s.db.Find(&likes, "subject = ?", postID).Error; err != nil {
661
681
return err
662
682
}
663
683
664
684
users := []engagementUser{}
665
685
for _, like := range likes {
666
-
r, err := s.backend.getRepoByID(ctx, like.Author)
686
+
r, err := s.backend.GetRepoByID(ctx, like.Author)
667
687
if err != nil {
668
688
slog.Error("failed to get repo for like author", "error", err)
669
689
continue
···
678
698
679
699
// Get profile if available
680
700
var profile models.Profile
681
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
701
+
s.db.Find(&profile, "repo = ?", r.ID)
682
702
683
703
var prof *bsky.ActorProfile
684
704
if len(profile.Raw) > 0 {
···
687
707
prof = &p
688
708
}
689
709
} else {
690
-
s.addMissingProfile(ctx, r.Did)
710
+
s.backend.TrackMissingRecord(r.Did, false)
691
711
}
692
712
693
713
users = append(users, engagementUser{
···
717
737
718
738
// Get all reposts for this post
719
739
var reposts []models.Repost
720
-
if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
740
+
if err := s.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
721
741
return err
722
742
}
723
743
724
744
users := []engagementUser{}
725
745
for _, repost := range reposts {
726
-
r, err := s.backend.getRepoByID(ctx, repost.Author)
746
+
r, err := s.backend.GetRepoByID(ctx, repost.Author)
727
747
if err != nil {
728
748
slog.Error("failed to get repo for repost author", "error", err)
729
749
continue
···
738
758
739
759
// Get profile if available
740
760
var profile models.Profile
741
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
761
+
s.db.Find(&profile, "repo = ?", r.ID)
742
762
743
763
var prof *bsky.ActorProfile
744
764
if len(profile.Raw) > 0 {
···
747
767
prof = &p
748
768
}
749
769
} else {
750
-
s.addMissingProfile(ctx, r.Did)
770
+
s.backend.TrackMissingRecord(r.Did, false)
751
771
}
752
772
753
773
users = append(users, engagementUser{
···
777
797
778
798
// Get all replies to this post
779
799
var replies []models.Post
780
-
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 {
781
801
return err
782
802
}
783
803
···
791
811
}
792
812
seen[reply.Author] = true
793
813
794
-
r, err := s.backend.getRepoByID(ctx, reply.Author)
814
+
r, err := s.backend.GetRepoByID(ctx, reply.Author)
795
815
if err != nil {
796
816
slog.Error("failed to get repo for reply author", "error", err)
797
817
continue
···
806
826
807
827
// Get profile if available
808
828
var profile models.Profile
809
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
829
+
s.db.Find(&profile, "repo = ?", r.ID)
810
830
811
831
var prof *bsky.ActorProfile
812
832
if len(profile.Raw) > 0 {
···
815
835
prof = &p
816
836
}
817
837
} else {
818
-
s.addMissingProfile(ctx, r.Did)
838
+
s.backend.TrackMissingRecord(r.Did, false)
819
839
}
820
840
821
841
users = append(users, engagementUser{
···
913
933
query := `SELECT * FROM notifications WHERE "for" = ?`
914
934
if cursorID > 0 {
915
935
query += ` AND id < ?`
916
-
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 {
917
937
return err
918
938
}
919
939
} else {
920
-
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 {
921
941
return err
922
942
}
923
943
}
···
926
946
results := []notificationResponse{}
927
947
for _, notif := range notifications {
928
948
// Get author info
929
-
author, err := s.backend.getRepoByID(ctx, notif.Author)
949
+
author, err := s.backend.GetRepoByID(ctx, notif.Author)
930
950
if err != nil {
931
951
slog.Error("failed to get repo for notification author", "error", err)
932
952
continue
···
947
967
}
948
968
949
969
// Try to get source post preview for reply/mention notifications
950
-
if notif.Kind == NotifKindReply || notif.Kind == NotifKindMention {
970
+
if notif.Kind == backend.NotifKindReply || notif.Kind == backend.NotifKindMention {
951
971
// Parse URI to get post
952
-
p, err := s.backend.getPostByUri(ctx, notif.Source, "*")
972
+
p, err := s.backend.GetPostByUri(ctx, notif.Source, "*")
953
973
if err == nil && p.Raw != nil && len(p.Raw) > 0 {
954
974
var fp bsky.FeedPost
955
975
if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err == nil {
+113
-11
hydration/actor.go
+113
-11
hydration/actor.go
···
10
10
11
11
"github.com/bluesky-social/indigo/api/bsky"
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/whyrusleeping/market/models"
13
14
)
14
15
15
16
// ActorInfo contains hydrated actor information
···
21
22
22
23
// HydrateActor hydrates full actor information
23
24
func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) {
25
+
ctx, span := tracer.Start(ctx, "hydrateActor")
26
+
defer span.End()
27
+
24
28
// Look up handle
25
29
resp, err := h.dir.LookupDID(ctx, syntax.DID(did))
26
30
if err != nil {
···
60
64
FollowCount int64
61
65
FollowerCount int64
62
66
PostCount int64
67
+
ViewerState *bsky.ActorDefs_ViewerState
63
68
}
64
69
65
-
func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string) (*ActorInfoDetailed, error) {
70
+
func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) {
66
71
act, err := h.HydrateActor(ctx, did)
67
72
if err != nil {
68
73
return nil, err
···
73
78
}
74
79
75
80
var wg sync.WaitGroup
76
-
wg.Add(3)
77
-
go func() {
78
-
defer wg.Done()
81
+
wg.Go(func() {
79
82
c, err := h.getFollowCountForUser(ctx, did)
80
83
if err != nil {
81
84
slog.Error("failed to get follow count", "did", did, "error", err)
82
85
}
83
86
actd.FollowCount = c
84
-
}()
85
-
go func() {
86
-
defer wg.Done()
87
+
})
88
+
wg.Go(func() {
87
89
c, err := h.getFollowerCountForUser(ctx, did)
88
90
if err != nil {
89
91
slog.Error("failed to get follower count", "did", did, "error", err)
90
92
}
91
93
actd.FollowerCount = c
92
-
}()
93
-
go func() {
94
-
defer wg.Done()
94
+
})
95
+
wg.Go(func() {
95
96
c, err := h.getPostCountForUser(ctx, did)
96
97
if err != nil {
97
98
slog.Error("failed to get post count", "did", did, "error", err)
98
99
}
99
100
actd.PostCount = c
100
-
}()
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
+
101
113
wg.Wait()
102
114
103
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
104
206
}
105
207
106
208
func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) {
+15
-13
hydration/hydrator.go
+15
-13
hydration/hydrator.go
···
2
2
3
3
import (
4
4
"github.com/bluesky-social/indigo/atproto/identity"
5
+
"github.com/whyrusleeping/konbini/backend"
5
6
"gorm.io/gorm"
6
7
)
7
8
8
9
// Hydrator handles data hydration from the database
9
10
type Hydrator struct {
10
-
db *gorm.DB
11
-
dir identity.Directory
12
-
13
-
missingActorCallback func(string)
14
-
missingPostCallback func(string)
11
+
db *gorm.DB
12
+
dir identity.Directory
13
+
backend *backend.PostgresBackend
15
14
}
16
15
17
16
// NewHydrator creates a new Hydrator
18
-
func NewHydrator(db *gorm.DB, dir identity.Directory) *Hydrator {
17
+
func NewHydrator(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Hydrator {
19
18
return &Hydrator{
20
-
db: db,
21
-
dir: dir,
19
+
db: db,
20
+
dir: dir,
21
+
backend: backend,
22
22
}
23
23
}
24
24
25
-
func (h *Hydrator) SetMissingActorCallback(fn func(string)) {
26
-
h.missingActorCallback = fn
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
+
}
27
30
}
28
31
32
+
// addMissingActor is a convenience method for adding missing actors
29
33
func (h *Hydrator) addMissingActor(did string) {
30
-
if h.missingActorCallback != nil {
31
-
h.missingActorCallback(did)
32
-
}
34
+
h.AddMissingRecord(did, false)
33
35
}
34
36
35
37
// HydrateCtx contains context for hydration operations
+388
-39
hydration/post.go
+388
-39
hydration/post.go
···
5
5
"context"
6
6
"fmt"
7
7
"log/slog"
8
+
"sync"
8
9
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"
10
14
)
11
15
16
+
var tracer = otel.Tracer("hydrator")
17
+
12
18
// PostInfo contains hydrated post information
13
19
type PostInfo struct {
20
+
ID uint
14
21
URI string
15
22
Cid string
16
23
Post *bsky.FeedPost
···
22
29
RepostCount int
23
30
ReplyCount int
24
31
ViewerLike string // URI of viewer's like, if any
32
+
33
+
EmbedInfo *bsky.FeedDefs_PostView_Embed
25
34
}
26
35
27
36
const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu"
28
37
29
38
// HydratePost hydrates a single post by URI
30
39
func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) {
31
-
// Query post from database
32
-
var dbPost struct {
33
-
ID uint
34
-
Cid string
35
-
Raw []byte
36
-
NotFound bool
37
-
ReplyTo uint
38
-
ReplyToUsr uint
39
-
InThread uint
40
-
AuthorID uint
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
41
46
}
42
47
43
-
err := h.db.Raw(`
44
-
SELECT p.id, p.cid, p.raw, p.not_found, p.reply_to, p.reply_to_usr, p.in_thread, p.author as author_id
45
-
FROM posts p
46
-
WHERE p.id = (
47
-
SELECT id FROM posts
48
-
WHERE author = (SELECT id FROM repos WHERE did = ?)
49
-
AND rkey = ?
50
-
)
51
-
`, extractDIDFromURI(uri), extractRkeyFromURI(uri)).Scan(&dbPost).Error
48
+
return h.HydratePostDB(ctx, uri, p, viewerDID)
49
+
}
52
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)
53
56
if err != nil {
54
-
return nil, fmt.Errorf("failed to query post: %w", err)
57
+
return nil, err
55
58
}
56
59
57
60
if dbPost.NotFound || len(dbPost.Raw) == 0 {
58
-
return nil, fmt.Errorf("post not found")
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
+
}
59
72
}
60
73
61
74
// Unmarshal post record
···
64
77
return nil, fmt.Errorf("failed to unmarshal post: %w", err)
65
78
}
66
79
67
-
// Get author DID
68
-
var authorDID string
69
-
h.db.Raw("SELECT did FROM repos WHERE id = ?", dbPost.AuthorID).Scan(&authorDID)
80
+
var wg sync.WaitGroup
81
+
82
+
authorDID := r.Did
70
83
71
84
// Get engagement counts
72
85
var likes, reposts, replies int
73
-
h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes)
74
-
h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts)
75
-
h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies)
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()
76
124
77
125
info := &PostInfo{
126
+
ID: dbPost.ID,
78
127
URI: uri,
79
128
Cid: dbPost.Cid,
80
129
Post: &feedPost,
···
85
134
LikeCount: likes,
86
135
RepostCount: reposts,
87
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)
88
142
}
89
143
90
144
if info.Cid == "" {
···
92
146
info.Cid = fakeCid
93
147
}
94
148
95
-
// Check if viewer liked this post
96
-
if viewerDID != "" {
97
-
var likeRkey string
98
-
h.db.Raw(`
99
-
SELECT l.rkey FROM likes l
100
-
WHERE l.subject = ?
101
-
AND l.author = (SELECT id FROM repos WHERE did = ?)
102
-
`, dbPost.ID, viewerDID).Scan(&likeRkey)
103
-
if likeRkey != "" {
104
-
info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey)
105
-
}
106
-
}
149
+
// Hydrate embed
107
150
108
151
return info, nil
109
152
}
···
150
193
}
151
194
return ""
152
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
+
}
+136
-146
main.go
+136
-146
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
25
xrpclib "github.com/bluesky-social/indigo/xrpc"
24
-
"github.com/gorilla/websocket"
25
-
lru "github.com/hashicorp/golang-lru/v2"
26
26
"github.com/ipfs/go-cid"
27
27
"github.com/jackc/pgx/v5/pgxpool"
28
28
"github.com/prometheus/client_golang/prometheus"
29
29
"github.com/prometheus/client_golang/prometheus/promauto"
30
30
"github.com/urfave/cli/v2"
31
+
"github.com/whyrusleeping/konbini/backend"
31
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"
32
40
"gorm.io/gorm/logger"
33
-
)
34
41
35
-
var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{
36
-
Name: "handle_op_duration",
37
-
Help: "A histogram of op handling durations",
38
-
Buckets: prometheus.ExponentialBuckets(1, 2, 15),
39
-
}, []string{"op", "collection"})
42
+
. "github.com/whyrusleeping/konbini/models"
43
+
)
40
44
41
45
var firehoseCursorGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
42
46
Name: "firehose_cursor",
···
51
55
&cli.StringFlag{
52
56
Name: "db-url",
53
57
EnvVars: []string{"DATABASE_URL"},
58
+
},
59
+
&cli.BoolFlag{
60
+
Name: "jaeger",
54
61
},
55
62
&cli.StringFlag{
56
63
Name: "handle",
···
59
66
Name: "max-db-connections",
60
67
Value: runtime.NumCPU(),
61
68
},
69
+
&cli.StringFlag{
70
+
Name: "redis-url",
71
+
},
72
+
&cli.StringFlag{
73
+
Name: "sync-config",
74
+
},
62
75
}
63
76
app.Action = func(cctx *cli.Context) error {
64
77
db, err := cliutil.SetupDatabase(cctx.String("db-url"), cctx.Int("max-db-connections"))
···
73
86
Colorful: true,
74
87
})
75
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
+
76
118
db.AutoMigrate(Repo{})
77
119
db.AutoMigrate(Post{})
78
120
db.AutoMigrate(Follow{})
···
88
130
db.AutoMigrate(Image{})
89
131
db.AutoMigrate(PostGate{})
90
132
db.AutoMigrate(StarterPack{})
91
-
db.AutoMigrate(SyncInfo{})
133
+
db.AutoMigrate(backend.SyncInfo{})
92
134
db.AutoMigrate(Notification{})
135
+
db.AutoMigrate(NotificationSeen{})
93
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)")
94
140
95
141
ctx := context.TODO()
96
142
97
-
rc, _ := lru.New2Q[string, *Repo](1_000_000)
98
-
pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000)
99
-
revc, _ := lru.New2Q[uint, string](1_000_000)
100
-
101
143
cfg, err := pgxpool.ParseConfig(cctx.String("db-url"))
102
144
if err != nil {
103
145
return err
···
120
162
password := os.Getenv("BSKY_PASSWORD")
121
163
122
164
dir := identity.DefaultDirectory()
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
+
}
123
173
124
174
resp, err := dir.LookupHandle(ctx, syntax.Handle(handle))
125
175
if err != nil {
···
151
201
client: cc,
152
202
dir: dir,
153
203
154
-
missingProfiles: make(chan string, 1024),
155
-
missingPosts: make(chan string, 1024),
204
+
db: db,
156
205
}
157
-
fmt.Println("MY DID: ", s.mydid)
158
206
159
-
pgb := &PostgresBackend{
160
-
relevantDids: make(map[string]bool),
161
-
s: s,
162
-
db: db,
163
-
postInfoCache: pc,
164
-
repoCache: rc,
165
-
revCache: revc,
166
-
pgx: pool,
207
+
pgb, err := backend.NewPostgresBackend(mydid, db, pool, cc, dir)
208
+
if err != nil {
209
+
return err
167
210
}
211
+
168
212
s.backend = pgb
169
213
170
-
myrepo, err := s.backend.getOrCreateRepo(ctx, mydid)
214
+
myrepo, err := s.backend.GetOrCreateRepo(ctx, mydid)
171
215
if err != nil {
172
216
return fmt.Errorf("failed to get repo record for our own did: %w", err)
173
217
}
174
218
s.myrepo = myrepo
175
219
176
-
if err := s.backend.loadRelevantDids(); err != nil {
220
+
if err := s.backend.LoadRelevantDids(); err != nil {
177
221
return fmt.Errorf("failed to load relevant dids set: %w", err)
178
222
}
179
223
···
197
241
http.ListenAndServe(":4445", nil)
198
242
}()
199
243
200
-
go s.missingProfileFetcher()
201
-
go s.missingPostFetcher()
244
+
sc := SyncConfig{
245
+
Backends: []SyncBackend{
246
+
{
247
+
Type: "firehose",
248
+
Host: "bsky.network",
249
+
},
250
+
},
251
+
}
202
252
203
-
seqno, err := loadLastSeq(db, "firehose_seq")
204
-
if err != nil {
205
-
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
+
}
206
267
}
207
268
208
-
return s.startLiveTail(ctx, int(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
+
209
278
}
210
279
211
280
app.RunAndExitOnError()
212
281
}
213
282
214
283
type Server struct {
215
-
backend *PostgresBackend
284
+
backend *backend.PostgresBackend
216
285
217
286
dir identity.Directory
218
287
···
223
292
seqLk sync.Mutex
224
293
lastSeq int64
225
294
226
-
mpLk sync.Mutex
227
-
missingProfiles chan string
228
-
missingPosts chan string
295
+
mpLk sync.Mutex
296
+
297
+
db *gorm.DB
229
298
}
230
299
231
300
func (s *Server) getXrpcClient() (*xrpclib.Client, error) {
···
233
302
return s.client, nil
234
303
}
235
304
236
-
func (s *Server) startLiveTail(ctx context.Context, curs int, parWorkers, maxQ int) error {
237
-
slog.Info("starting live tail")
238
-
239
-
// Connect to the Relay websocket
240
-
urlStr := fmt.Sprintf("wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", curs)
241
-
242
-
d := websocket.DefaultDialer
243
-
con, _, err := d.Dial(urlStr, http.Header{
244
-
"User-Agent": []string{"market/0.0.1"},
245
-
})
246
-
if err != nil {
247
-
return fmt.Errorf("failed to connect to relay: %w", err)
248
-
}
249
-
250
-
var lelk sync.Mutex
251
-
lastEvent := time.Now()
252
-
253
-
go func() {
254
-
for range time.Tick(time.Second) {
255
-
lelk.Lock()
256
-
let := lastEvent
257
-
lelk.Unlock()
258
-
259
-
if time.Since(let) > time.Second*30 {
260
-
slog.Error("firehose connection timed out")
261
-
con.Close()
262
-
return
263
-
}
264
-
265
-
}
266
-
267
-
}()
268
-
269
-
var cclk sync.Mutex
270
-
var completeCursor int64
271
-
272
-
rsc := &stream.RepoStreamCallbacks{
273
-
RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error {
274
-
ctx := context.Background()
275
-
276
-
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq))
277
-
278
-
s.seqLk.Lock()
279
-
if evt.Seq > s.lastSeq {
280
-
curs = int(evt.Seq)
281
-
s.lastSeq = evt.Seq
282
-
283
-
if evt.Seq%1000 == 0 {
284
-
if err := storeLastSeq(s.backend.db, "firehose_seq", evt.Seq); err != nil {
285
-
fmt.Println("failed to store seqno: ", err)
286
-
}
287
-
}
288
-
}
289
-
s.seqLk.Unlock()
290
-
291
-
lelk.Lock()
292
-
lastEvent = time.Now()
293
-
lelk.Unlock()
294
-
295
-
if err := s.backend.HandleEvent(ctx, evt); err != nil {
296
-
return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err)
297
-
}
298
-
299
-
cclk.Lock()
300
-
if evt.Seq > completeCursor {
301
-
completeCursor = evt.Seq
302
-
firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq))
303
-
}
304
-
cclk.Unlock()
305
-
306
-
return nil
307
-
},
308
-
RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error {
309
-
return nil
310
-
},
311
-
// TODO: all the other event types
312
-
Error: func(errf *stream.ErrorFrame) error {
313
-
return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message)
314
-
},
315
-
}
316
-
317
-
sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler)
318
-
319
-
//s.eventScheduler = sched
320
-
//s.streamFinished = make(chan struct{})
321
-
322
-
return stream.HandleRepoStream(ctx, con, sched, slog.Default())
323
-
}
324
-
325
305
func (s *Server) resolveAccountIdent(ctx context.Context, acc string) (string, error) {
326
306
unesc, err := url.PathUnescape(acc)
327
307
if err != nil {
···
341
321
return resp.DID.String(), nil
342
322
}
343
323
344
-
const (
345
-
NotifKindReply = "reply"
346
-
NotifKindLike = "like"
347
-
NotifKindMention = "mention"
348
-
NotifKindRepost = "repost"
349
-
)
350
-
351
-
func (s *Server) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error {
352
-
return s.backend.db.Create(&Notification{
353
-
For: forUser,
354
-
Author: author,
355
-
Source: recordUri,
356
-
SourceCid: recordCid.String(),
357
-
Kind: kind,
358
-
}).Error
359
-
}
360
-
361
324
func (s *Server) rescanRepo(ctx context.Context, did string) error {
362
325
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
363
326
if err != nil {
364
327
return err
365
328
}
366
329
367
-
_ = resp
368
-
return nil
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
+
})
369
359
370
360
}
-133
missing.go
-133
missing.go
···
1
-
package main
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
"log/slog"
8
-
"strings"
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
-
xrpclib "github.com/bluesky-social/indigo/xrpc"
14
-
"github.com/ipfs/go-cid"
15
-
"github.com/labstack/gommon/log"
16
-
)
17
-
18
-
func (s *Server) addMissingProfile(ctx context.Context, did string) {
19
-
select {
20
-
case s.missingProfiles <- did:
21
-
case <-ctx.Done():
22
-
}
23
-
}
24
-
25
-
func (s *Server) missingProfileFetcher() {
26
-
for did := range s.missingProfiles {
27
-
if err := s.fetchMissingProfile(context.TODO(), did); err != nil {
28
-
log.Warn("failed to fetch missing profile", "did", did, "error", err)
29
-
}
30
-
}
31
-
}
32
-
33
-
func (s *Server) fetchMissingProfile(ctx context.Context, did string) error {
34
-
repo, err := s.backend.getOrCreateRepo(ctx, did)
35
-
if err != nil {
36
-
return err
37
-
}
38
-
39
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
40
-
if err != nil {
41
-
return err
42
-
}
43
-
44
-
c := &xrpclib.Client{
45
-
Host: resp.PDSEndpoint(),
46
-
}
47
-
48
-
rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self")
49
-
if err != nil {
50
-
return err
51
-
}
52
-
53
-
prof, ok := rec.Value.Val.(*bsky.ActorProfile)
54
-
if !ok {
55
-
return fmt.Errorf("record we got back wasnt a profile somehow")
56
-
}
57
-
58
-
buf := new(bytes.Buffer)
59
-
if err := prof.MarshalCBOR(buf); err != nil {
60
-
return err
61
-
}
62
-
63
-
cc, err := cid.Decode(*rec.Cid)
64
-
if err != nil {
65
-
return err
66
-
}
67
-
68
-
return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc)
69
-
}
70
-
71
-
func (s *Server) addMissingPost(ctx context.Context, uri string) {
72
-
slog.Info("adding missing post to fetch queue", "uri", uri)
73
-
select {
74
-
case s.missingPosts <- uri:
75
-
case <-ctx.Done():
76
-
}
77
-
}
78
-
79
-
func (s *Server) missingPostFetcher() {
80
-
for uri := range s.missingPosts {
81
-
if err := s.fetchMissingPost(context.TODO(), uri); err != nil {
82
-
log.Warn("failed to fetch missing post", "uri", uri, "error", err)
83
-
}
84
-
}
85
-
}
86
-
87
-
func (s *Server) fetchMissingPost(ctx context.Context, uri string) error {
88
-
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
89
-
parts := strings.Split(uri, "/")
90
-
if len(parts) < 5 || !strings.HasPrefix(parts[2], "did:") {
91
-
return fmt.Errorf("invalid AT URI: %s", uri)
92
-
}
93
-
94
-
did := parts[2]
95
-
collection := parts[3]
96
-
rkey := parts[4]
97
-
98
-
repo, err := s.backend.getOrCreateRepo(ctx, did)
99
-
if err != nil {
100
-
return err
101
-
}
102
-
103
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
104
-
if err != nil {
105
-
return err
106
-
}
107
-
108
-
c := &xrpclib.Client{
109
-
Host: resp.PDSEndpoint(),
110
-
}
111
-
112
-
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
113
-
if err != nil {
114
-
return err
115
-
}
116
-
117
-
post, ok := rec.Value.Val.(*bsky.FeedPost)
118
-
if !ok {
119
-
return fmt.Errorf("record we got back wasn't a post somehow")
120
-
}
121
-
122
-
buf := new(bytes.Buffer)
123
-
if err := post.MarshalCBOR(buf); err != nil {
124
-
return err
125
-
}
126
-
127
-
cc, err := cid.Decode(*rec.Cid)
128
-
if err != nil {
129
-
return err
130
-
}
131
-
132
-
return s.backend.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc)
133
-
}
+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
+
}
-49
models.go
-49
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
-
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
-
}
-406
pgbackend.go
-406
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
-
}
403
-
404
-
func (b *PostgresBackend) TrackMissingActor(did string) {
405
-
b.s.addMissingProfile(context.TODO(), did)
406
-
}
+2
seqno.go
+2
seqno.go
+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
+
}
+5
views/actor.go
+5
views/actor.go
+53
-5
views/feed.go
+53
-5
views/feed.go
···
1
1
package views
2
2
3
3
import (
4
+
"fmt"
5
+
4
6
"github.com/bluesky-social/indigo/api/bsky"
5
7
"github.com/bluesky-social/indigo/lex/util"
6
8
"github.com/whyrusleeping/konbini/hydration"
···
40
42
}
41
43
}
42
44
43
-
// TODO: Add embed handling - need to convert embed types to proper views
44
-
// if post.Post.Embed != nil {
45
-
// view.Embed = formatEmbed(post.Post.Embed)
46
-
// }
45
+
// Add embed if it was hydrated
46
+
if post.EmbedInfo != nil {
47
+
view.Embed = post.EmbedInfo
48
+
}
47
49
48
50
return view
49
51
}
···
56
58
}
57
59
58
60
// ThreadViewPost builds a thread view post (app.bsky.feed.defs#threadViewPost)
59
-
func ThreadViewPost(post *hydration.PostInfo, author *hydration.ActorInfo, parent, replies interface{}) *bsky.FeedDefs_ThreadViewPost {
61
+
func ThreadViewPost(post *hydration.PostInfo, author *hydration.ActorInfo, parent, replies any) *bsky.FeedDefs_ThreadViewPost {
60
62
view := &bsky.FeedDefs_ThreadViewPost{
61
63
LexiconTypeID: "app.bsky.feed.defs#threadViewPost",
62
64
Post: PostView(post, author),
···
67
69
68
70
return view
69
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
+
}
+3
-1
xrpc/actor/getProfile.go
+3
-1
xrpc/actor/getProfile.go
···
20
20
21
21
ctx := c.Request().Context()
22
22
23
+
viewer, _ := c.Get("viewer").(string)
24
+
23
25
// Resolve actor to DID
24
26
did, err := hydrator.ResolveDID(ctx, actorParam)
25
27
if err != nil {
···
30
32
}
31
33
32
34
// Hydrate actor info
33
-
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did)
35
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
34
36
if err != nil {
35
37
return c.JSON(http.StatusNotFound, map[string]interface{}{
36
38
"error": "ActorNotFound",
+4
-16
xrpc/actor/getProfiles.go
+4
-16
xrpc/actor/getProfiles.go
···
3
3
import (
4
4
"net/http"
5
5
6
+
"github.com/bluesky-social/indigo/api/bsky"
6
7
"github.com/labstack/echo/v4"
7
8
"github.com/whyrusleeping/konbini/hydration"
8
9
"github.com/whyrusleeping/konbini/views"
···
26
27
}
27
28
28
29
ctx := c.Request().Context()
30
+
viewer, _ := c.Get("viewer").(string)
29
31
30
32
// Resolve all actors to DIDs and hydrate profiles
31
-
profiles := make([]interface{}, 0)
33
+
profiles := make([]*bsky.ActorDefs_ProfileViewDetailed, 0, len(actors))
32
34
for _, actor := range actors {
33
35
// Resolve actor to DID
34
36
did, err := hydrator.ResolveDID(ctx, actor)
···
38
40
}
39
41
40
42
// Hydrate actor info
41
-
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did)
43
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
42
44
if err != nil {
43
45
// Skip actors that can't be hydrated
44
46
continue
45
47
}
46
-
47
-
// Get counts for the profile
48
-
type counts struct {
49
-
Followers int
50
-
Follows int
51
-
Posts int
52
-
}
53
-
var c counts
54
-
db.Raw(`
55
-
SELECT
56
-
(SELECT COUNT(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)) as followers,
57
-
(SELECT COUNT(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)) as follows,
58
-
(SELECT COUNT(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)) as posts
59
-
`, did, did, did).Scan(&c)
60
48
61
49
profiles = append(profiles, views.ProfileViewDetailed(actorInfo))
62
50
}
+95
-25
xrpc/feed/getAuthorFeed.go
+95
-25
xrpc/feed/getAuthorFeed.go
···
1
1
package feed
2
2
3
3
import (
4
+
"context"
5
+
"log/slog"
4
6
"net/http"
5
7
"strconv"
8
+
"strings"
9
+
"sync"
6
10
"time"
7
11
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
14
"github.com/labstack/echo/v4"
9
15
"github.com/whyrusleeping/konbini/hydration"
10
16
"github.com/whyrusleeping/konbini/views"
11
17
"gorm.io/gorm"
12
18
)
13
19
20
+
type postRow struct {
21
+
URI string
22
+
AuthorID uint
23
+
}
24
+
14
25
// HandleGetAuthorFeed implements app.bsky.feed.getAuthorFeed
15
26
func HandleGetAuthorFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
27
actorParam := c.QueryParam("actor")
17
28
if actorParam == "" {
18
-
return c.JSON(http.StatusBadRequest, map[string]interface{}{
29
+
return c.JSON(http.StatusBadRequest, map[string]any{
19
30
"error": "InvalidRequest",
20
31
"message": "actor parameter is required",
21
32
})
···
49
60
// Resolve actor to DID
50
61
did, err := hydrator.ResolveDID(ctx, actorParam)
51
62
if err != nil {
52
-
return c.JSON(http.StatusBadRequest, map[string]interface{}{
63
+
return c.JSON(http.StatusBadRequest, map[string]any{
53
64
"error": "ActorNotFound",
54
65
"message": "actor not found",
55
66
})
···
87
98
`
88
99
}
89
100
90
-
type postRow struct {
91
-
URI string
92
-
AuthorID uint
93
-
}
94
101
var rows []postRow
95
102
if err := db.Raw(query, did, cursor, limit).Scan(&rows).Error; err != nil {
96
-
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
103
+
return c.JSON(http.StatusInternalServerError, map[string]any{
97
104
"error": "InternalError",
98
105
"message": "failed to query author feed",
99
106
})
100
107
}
101
108
102
-
// Hydrate posts
103
-
feed := make([]interface{}, 0)
104
-
for _, row := range rows {
105
-
postInfo, err := hydrator.HydratePost(ctx, row.URI, viewer)
106
-
if err != nil {
107
-
continue
108
-
}
109
-
110
-
// Hydrate author
111
-
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
112
-
if err != nil {
113
-
continue
114
-
}
115
-
116
-
feedItem := views.FeedViewPost(postInfo, authorInfo)
117
-
feed = append(feed, feedItem)
118
-
}
109
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
119
110
120
111
// Generate next cursor
121
112
var nextCursor string
···
130
121
}
131
122
}
132
123
133
-
return c.JSON(http.StatusOK, map[string]interface{}{
124
+
return c.JSON(http.StatusOK, map[string]any{
134
125
"feed": feed,
135
126
"cursor": nextCursor,
136
127
})
137
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
+
}
+11
-11
xrpc/feed/getPostThread.go
+11
-11
xrpc/feed/getPostThread.go
···
15
15
func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
16
uriParam := c.QueryParam("uri")
17
17
if uriParam == "" {
18
-
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
return c.JSON(http.StatusBadRequest, map[string]any{
19
19
"error": "InvalidRequest",
20
20
"message": "uri parameter is required",
21
21
})
···
27
27
// Hydrate the requested post
28
28
postInfo, err := hydrator.HydratePost(ctx, uriParam, viewer)
29
29
if err != nil {
30
-
return c.JSON(http.StatusNotFound, map[string]interface{}{
30
+
return c.JSON(http.StatusNotFound, map[string]any{
31
31
"error": "NotFound",
32
32
"message": "post not found",
33
33
})
···
74
74
uri: uri,
75
75
replyTo: tp.ReplyTo,
76
76
inThread: tp.InThread,
77
-
replies: []interface{}{},
77
+
replies: []any{},
78
78
}
79
79
}
80
80
···
98
98
}
99
99
100
100
if rootNode == nil {
101
-
return c.JSON(http.StatusNotFound, map[string]interface{}{
101
+
return c.JSON(http.StatusNotFound, map[string]any{
102
102
"error": "NotFound",
103
103
"message": "thread root not found",
104
104
})
···
107
107
// Build the response by traversing the tree
108
108
thread := buildThreadView(ctx, db, rootNode, postsByID, hydrator, viewer, nil)
109
109
110
-
return c.JSON(http.StatusOK, map[string]interface{}{
110
+
return c.JSON(http.StatusOK, map[string]any{
111
111
"thread": thread,
112
112
})
113
113
}
···
117
117
uri string
118
118
replyTo uint
119
119
inThread uint
120
-
replies []interface{}
120
+
replies []any
121
121
}
122
122
123
-
func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent interface{}) interface{} {
123
+
func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent any) any {
124
124
// Hydrate this post
125
125
postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer)
126
126
if err != nil {
127
127
// Return a notFound post
128
-
return map[string]interface{}{
128
+
return map[string]any{
129
129
"$type": "app.bsky.feed.defs#notFoundPost",
130
130
"uri": node.uri,
131
131
}
···
134
134
// Hydrate author
135
135
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
136
136
if err != nil {
137
-
return map[string]interface{}{
137
+
return map[string]any{
138
138
"$type": "app.bsky.feed.defs#notFoundPost",
139
139
"uri": node.uri,
140
140
}
141
141
}
142
142
143
143
// Build replies
144
-
var replies []interface{}
144
+
var replies []any
145
145
for _, replyNode := range node.replies {
146
146
if rn, ok := replyNode.(*threadPostNode); ok {
147
147
replyView := buildThreadView(ctx, db, rn, allNodes, hydrator, viewer, nil)
···
150
150
}
151
151
152
152
// Build the thread view post
153
-
var repliesForView interface{}
153
+
var repliesForView any
154
154
if len(replies) > 0 {
155
155
repliesForView = replies
156
156
}
+39
-43
xrpc/feed/getTimeline.go
+39
-43
xrpc/feed/getTimeline.go
···
1
1
package feed
2
2
3
3
import (
4
-
"log/slog"
4
+
"context"
5
5
"net/http"
6
6
"strconv"
7
7
"time"
8
8
9
9
"github.com/labstack/echo/v4"
10
10
"github.com/whyrusleeping/konbini/hydration"
11
-
"github.com/whyrusleeping/konbini/views"
11
+
"go.opentelemetry.io/otel"
12
12
"gorm.io/gorm"
13
13
)
14
+
15
+
var tracer = otel.Tracer("xrpc/feed")
14
16
15
17
// HandleGetTimeline implements app.bsky.feed.getTimeline
16
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
+
17
23
viewer := getUserDID(c)
18
24
if viewer == "" {
19
-
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
25
+
return c.JSON(http.StatusUnauthorized, map[string]any{
20
26
"error": "AuthenticationRequired",
21
27
"message": "authentication required",
22
28
})
···
38
44
}
39
45
}
40
46
41
-
ctx := c.Request().Context()
42
-
43
47
// Get viewer's repo ID
44
48
var viewerRepoID uint
45
49
if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil {
46
-
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
50
+
return c.JSON(http.StatusInternalServerError, map[string]any{
47
51
"error": "InternalError",
48
52
"message": "failed to load viewer",
49
53
})
50
54
}
51
55
52
56
// Query posts from followed users
53
-
type postRow struct {
54
-
URI string
55
-
AuthorID uint
56
-
}
57
-
var rows []postRow
58
-
err := db.Raw(`
59
-
SELECT
60
-
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
61
-
p.author as author_id
62
-
FROM posts p
63
-
JOIN repos r ON r.id = p.author
64
-
WHERE p.reply_to = 0
65
-
AND p.author IN (SELECT subject FROM follows WHERE author = ?)
66
-
AND p.created < ?
67
-
AND p.not_found = false
68
-
ORDER BY p.created DESC
69
-
LIMIT ?
70
-
`, viewerRepoID, cursor, limit).Scan(&rows).Error
71
57
58
+
rows, err := getTimelinePosts(ctx, db, viewerRepoID, cursor, limit)
72
59
if err != nil {
73
-
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
60
+
return c.JSON(http.StatusInternalServerError, map[string]any{
74
61
"error": "InternalError",
75
62
"message": "failed to query timeline",
76
63
})
77
64
}
78
65
79
66
// Hydrate posts
80
-
feed := make([]interface{}, 0)
81
-
for _, row := range rows {
82
-
postInfo, err := hydrator.HydratePost(ctx, row.URI, viewer)
83
-
if err != nil {
84
-
continue
85
-
}
86
-
87
-
// Hydrate author
88
-
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
89
-
if err != nil {
90
-
slog.Error("failed to hydrate actor", "author", postInfo.Author, "error", err)
91
-
continue
92
-
}
93
-
94
-
feedItem := views.FeedViewPost(postInfo, authorInfo)
95
-
feed = append(feed, feedItem)
96
-
}
67
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
97
68
98
69
// Generate next cursor
99
70
var nextCursor string
···
111
82
}
112
83
}
113
84
114
-
return c.JSON(http.StatusOK, map[string]interface{}{
85
+
return c.JSON(http.StatusOK, map[string]any{
115
86
"feed": feed,
116
87
"cursor": nextCursor,
117
88
})
118
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
+
}
+241
-30
xrpc/notification/listNotifications.go
+241
-30
xrpc/notification/listNotifications.go
···
1
1
package notification
2
2
3
3
import (
4
+
"bytes"
5
+
"fmt"
4
6
"net/http"
5
7
"strconv"
8
+
"time"
6
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"
7
14
"github.com/labstack/echo/v4"
8
15
"github.com/whyrusleeping/konbini/hydration"
16
+
models "github.com/whyrusleeping/konbini/models"
9
17
"github.com/whyrusleeping/konbini/views"
10
18
"gorm.io/gorm"
19
+
"gorm.io/gorm/clause"
11
20
)
12
21
13
22
// HandleListNotifications implements app.bsky.notification.listNotifications
14
23
func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
24
viewer := getUserDID(c)
16
25
if viewer == "" {
17
-
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
26
+
return c.JSON(http.StatusUnauthorized, map[string]any{
18
27
"error": "AuthenticationRequired",
19
28
"message": "authentication required",
20
29
})
···
69
78
}
70
79
query += ` ORDER BY n.created_at DESC LIMIT ?`
71
80
72
-
var queryArgs []interface{}
81
+
var queryArgs []any
73
82
queryArgs = append(queryArgs, viewer)
74
83
if cursor > 0 {
75
84
queryArgs = append(queryArgs, cursor)
···
77
86
queryArgs = append(queryArgs, limit)
78
87
79
88
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
80
-
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
89
+
return c.JSON(http.StatusInternalServerError, map[string]any{
81
90
"error": "InternalError",
82
91
"message": "failed to query notifications",
83
92
})
84
93
}
85
94
86
95
// Hydrate notifications
87
-
notifications := make([]interface{}, 0)
96
+
notifications := make([]*bsky.NotificationListNotifications_Notification, 0)
88
97
for _, row := range rows {
89
98
authorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
90
99
if err != nil {
91
100
continue
92
101
}
93
102
94
-
notif := map[string]interface{}{
95
-
"uri": row.Source,
96
-
"author": views.ProfileView(authorInfo),
97
-
"reason": mapNotifKind(row.Kind),
98
-
"record": nil, // Could hydrate the source record here
99
-
"isRead": false,
100
-
"indexedAt": row.CreatedAt,
101
-
"labels": []interface{}{},
103
+
// Skip notifications without CIDs as they're invalid
104
+
if row.SourceCid == "" {
105
+
continue
102
106
}
103
107
104
-
// Only include CID if we have one (required field)
105
-
if row.SourceCid != "" {
106
-
notif["cid"] = row.SourceCid
107
-
} else {
108
-
// Skip notifications without CIDs as they're invalid
108
+
// Fetch and decode the raw record
109
+
recordDecoder, err := fetchNotificationRecord(db, row.Source, row.Kind)
110
+
if err != nil {
109
111
continue
110
112
}
111
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
+
112
124
notifications = append(notifications, notif)
113
125
}
114
126
115
127
// Generate next cursor
116
-
var nextCursor string
128
+
var cursorPtr *string
117
129
if len(rows) > 0 {
118
-
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
130
+
cursor := strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
131
+
cursorPtr = &cursor
119
132
}
120
133
121
-
return c.JSON(http.StatusOK, map[string]interface{}{
122
-
"notifications": notifications,
123
-
"cursor": nextCursor,
124
-
})
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)
125
152
}
126
153
127
154
// HandleGetUnreadCount implements app.bsky.notification.getUnreadCount
128
155
func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
129
156
viewer := getUserDID(c)
130
157
if viewer == "" {
131
-
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
158
+
return c.JSON(http.StatusUnauthorized, map[string]any{
132
159
"error": "AuthenticationRequired",
133
160
"message": "authentication required",
134
161
})
135
162
}
136
163
137
-
// For now, return 0 - we'd need to track read state in the database
138
-
return c.JSON(http.StatusOK, map[string]interface{}{
139
-
"count": 0,
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,
140
185
})
141
186
}
142
187
···
144
189
func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
145
190
viewer := getUserDID(c)
146
191
if viewer == "" {
147
-
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
192
+
return c.JSON(http.StatusUnauthorized, map[string]any{
148
193
"error": "AuthenticationRequired",
149
194
"message": "authentication required",
150
195
})
151
196
}
152
197
153
-
// For now, just return success - we'd need to track seen timestamps in the database
154
-
return c.JSON(http.StatusOK, map[string]interface{}{})
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{})
155
250
}
156
251
157
252
func getUserDID(c echo.Context) string {
···
175
270
return "repost"
176
271
case "mention":
177
272
return "mention"
273
+
case "follow":
274
+
return "follow"
178
275
default:
179
276
return kind
180
277
}
181
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
+
}
+23
-7
xrpc/server.go
+23
-7
xrpc/server.go
···
1
1
package xrpc
2
2
3
3
import (
4
+
"context"
4
5
"log/slog"
5
6
"net/http"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/identity"
8
9
"github.com/labstack/echo/v4"
9
10
"github.com/labstack/echo/v4/middleware"
11
+
"github.com/whyrusleeping/konbini/backend"
10
12
"github.com/whyrusleeping/konbini/hydration"
13
+
"github.com/whyrusleeping/konbini/models"
11
14
"github.com/whyrusleeping/konbini/xrpc/actor"
12
15
"github.com/whyrusleeping/konbini/xrpc/feed"
13
16
"github.com/whyrusleeping/konbini/xrpc/graph"
···
31
34
type Backend interface {
32
35
// Add methods as needed for data access
33
36
34
-
TrackMissingActor(did string)
37
+
TrackMissingRecord(identifier string, wait bool)
38
+
GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error)
35
39
}
36
40
37
41
// NewServer creates a new XRPC server
38
-
func NewServer(db *gorm.DB, dir identity.Directory, backend Backend) *Server {
42
+
func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server {
39
43
e := echo.New()
40
44
e.HidePort = true
41
45
e.HideBanner = true
···
56
60
db: db,
57
61
dir: dir,
58
62
backend: backend,
59
-
hydrator: hydration.NewHydrator(db, dir),
63
+
hydrator: hydration.NewHydrator(db, dir, backend),
60
64
}
61
-
62
-
s.hydrator.SetMissingActorCallback(backend.TrackMissingActor)
63
65
64
66
// Register XRPC endpoints
65
67
s.registerEndpoints()
···
76
78
// registerEndpoints registers all XRPC endpoints
77
79
func (s *Server) registerEndpoints() {
78
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
+
79
86
xrpcGroup := s.e.Group("/xrpc")
80
87
81
88
// com.atproto.identity.*
···
89
96
// app.bsky.actor.*
90
97
xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error {
91
98
return actor.HandleGetProfile(c, s.hydrator)
92
-
})
99
+
}, s.optionalAuth)
93
100
xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error {
94
101
return actor.HandleGetProfiles(c, s.db, s.hydrator)
95
-
})
102
+
}, s.optionalAuth)
96
103
xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error {
97
104
return actor.HandleGetPreferences(c, s.db, s.hydrator)
98
105
}, s.requireAuth)
···
124
131
xrpcGroup.GET("/app.bsky.feed.getActorLikes", func(c echo.Context) error {
125
132
return feed.HandleGetActorLikes(c, s.db, s.hydrator)
126
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
+
})
127
140
128
141
// app.bsky.graph.*
129
142
xrpcGroup.GET("/app.bsky.graph.getFollows", func(c echo.Context) error {
···
166
179
})
167
180
xrpcGroup.GET("/app.bsky.unspecced.getTrendingTopics", func(c echo.Context) error {
168
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)
169
185
})
170
186
}
171
187
+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
+
}