+2
-2
Dockerfile
+2
-2
Dockerfile
···
1
1
# Build stage
2
-
FROM golang:1.21-alpine AS builder
2
+
FROM golang:1.25-alpine AS builder
3
3
4
4
WORKDIR /app
5
5
···
11
11
RUN go mod download
12
12
13
13
# Copy source code
14
-
COPY *.go ./
14
+
COPY . .
15
15
16
16
# Build the application
17
17
RUN CGO_ENABLED=0 GOOS=linux go build -o konbini .
+19
LICENSE-MIT
+19
LICENSE-MIT
···
1
+
MIT License
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a copy
4
+
of this software and associated documentation files (the "Software"), to deal
5
+
in the Software without restriction, including without limitation the rights
6
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+
copies of the Software, and to permit persons to whom the Software is
8
+
furnished to do so, subject to the following conditions:
9
+
10
+
The above copyright notice and this permission notice shall be included in all
11
+
copies or substantial portions of the Software.
12
+
13
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+
SOFTWARE.
+135
-1
README.md
+135
-1
README.md
···
19
19
### Prerequisites
20
20
21
21
- Docker and Docker Compose installed
22
-
- Your Bluesky DID (find it at https://bsky.app/settings/account)
22
+
- Creating an app password (via: https://bsky.app/settings/app-passwords)
23
23
24
24
### Setup
25
25
···
109
109
110
110
The frontend will be available at http://localhost:3000 and will connect to the API at http://localhost:4444.
111
111
112
+
## Running the Bluesky App against Konbini
113
+
114
+
Konbini implements a large portion of the app.bsky.\* appview endpoints that
115
+
are required for pointing the main app to it and having it work reasonably
116
+
well.
117
+
118
+
To accomplish this you will need a few things:
119
+
120
+
### Service DID
121
+
122
+
You will need a DID, preferably a did:web for your appview that points at a
123
+
public endpoint where your appview is accessible via https.
124
+
I'll get into the https proxy next, but for the did, I've just pointed a domain
125
+
I own (in my case appview1.bluesky.day) to a VPS, and used caddy to host a file
126
+
at `/.well-known/did.json`.
127
+
That file should look like this:
128
+
129
+
```json
130
+
{
131
+
"@context": [
132
+
"https://www.w3.org/ns/did/v1",
133
+
"https://w3id.org/security/multikey/v1"
134
+
],
135
+
"id": "did:web:appview1.bluesky.day",
136
+
"verificationMethod": [
137
+
{
138
+
"id": "did:web:api.bsky.app#atproto",
139
+
"type": "Multikey",
140
+
"controller": "did:web:api.bsky.app",
141
+
"publicKeyMultibase": "zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"
142
+
}
143
+
],
144
+
"service": [
145
+
{
146
+
"id": "#bsky_notif",
147
+
"type": "BskyNotificationService",
148
+
"serviceEndpoint": "YOUR APPVIEW HTTPS URL"
149
+
},
150
+
{
151
+
"id": "#bsky_appview",
152
+
"type": "BskyAppView",
153
+
"serviceEndpoint": "YOUR APPVIEW HTTPS URL"
154
+
}
155
+
]
156
+
}
157
+
```
158
+
159
+
The verificationMethod isn't used but i'm not sure if _something_ is required
160
+
there or not, so i'm just leaving that there, it works on my machine.
161
+
162
+
### HTTPS Endpoint
163
+
164
+
I've been using ngrok to proxy traffic from a publicly accessible https url to my appview.
165
+
You can simply run `ngrok http 4446` and it will give you an https url that you
166
+
can then put in your DID doc above.
167
+
168
+
### The Social App
169
+
170
+
Now, clone and build the social app:
171
+
172
+
```
173
+
git clone https://github.com/bluesky-social/social-app
174
+
cd social-app
175
+
yarn
176
+
```
177
+
178
+
And then set this environment variable that tells it to use your appview:
179
+
180
+
```
181
+
export EXPO_PUBLIC_BLUESKY_PROXY_DID=did:web:YOURDIDWEB
182
+
```
183
+
184
+
And finally run the app:
185
+
186
+
```
187
+
yarn web
188
+
```
189
+
190
+
This takes a while on first load since its building everything.
191
+
After that, load the localhost url it gives you and it _should_ work.
192
+
193
+
## Selective Backfill
194
+
195
+
If you'd like to backfill a particular repo, just hit the following endpoint:
196
+
197
+
```
198
+
curl http://localhost:4444/rescan/<DID OR HANDLE>
199
+
200
+
```
201
+
202
+
It will take a minute but it should pull all records from that user.
203
+
204
+
## Upstream Firehose Configuration
205
+
206
+
Konbini supports both standard firehose endpoints as well as jetstream. If
207
+
bandwidth and CPU usage is a concern, and you trust the jetstream endpoint,
208
+
then it may be worth trying that out.
209
+
210
+
The configuration file is formatted as follows:
211
+
212
+
```json
213
+
{
214
+
"backends": [
215
+
{
216
+
"type": "jetstream",
217
+
"host": "jetstream1.us-west.bsky.network"
218
+
}
219
+
]
220
+
}
221
+
```
222
+
223
+
The default (implicit) configuration file looks like this:
224
+
225
+
```json
226
+
{
227
+
"backends": [
228
+
{
229
+
"type": "firehose",
230
+
"host": "bsky.network"
231
+
}
232
+
]
233
+
}
234
+
```
235
+
236
+
Note that this is an array of backends, you can specify multiple upstreams, and
237
+
konbini will read from all of them. The main intended purpose of this is to be
238
+
able to subscribe directly to PDSs. PDSs currently only support the full
239
+
firehose endpoint, not jetstream, so be sure to specify a type of "firehose"
240
+
for individual PDS endpoints.
241
+
112
242
## License
113
243
114
244
MIT (whyrusleeping)
245
+
246
+
```
247
+
248
+
```
+536
backend/backend.go
+536
backend/backend.go
···
1
+
package backend
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"strings"
8
+
"sync"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/bluesky-social/indigo/atproto/identity"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/bluesky-social/indigo/util"
16
+
"github.com/bluesky-social/indigo/xrpc"
17
+
lru "github.com/hashicorp/golang-lru/v2"
18
+
"github.com/jackc/pgx/v5"
19
+
"github.com/jackc/pgx/v5/pgconn"
20
+
"github.com/jackc/pgx/v5/pgxpool"
21
+
. "github.com/whyrusleeping/konbini/models"
22
+
"github.com/whyrusleeping/market/models"
23
+
"gorm.io/gorm"
24
+
"gorm.io/gorm/clause"
25
+
"gorm.io/gorm/logger"
26
+
)
27
+
28
+
// PostgresBackend handles database operations
29
+
type PostgresBackend struct {
30
+
db *gorm.DB
31
+
pgx *pgxpool.Pool
32
+
33
+
dir identity.Directory
34
+
35
+
client *xrpc.Client
36
+
37
+
mydid string
38
+
myrepo *models.Repo
39
+
40
+
relevantDids map[string]bool
41
+
rdLk sync.Mutex
42
+
43
+
revCache *lru.TwoQueueCache[uint, string]
44
+
45
+
repoCache *lru.TwoQueueCache[string, *Repo]
46
+
reposLk sync.Mutex
47
+
48
+
didByIDCache *lru.TwoQueueCache[uint, string]
49
+
50
+
postInfoCache *lru.TwoQueueCache[string, cachedPostInfo]
51
+
52
+
missingRecords chan MissingRecord
53
+
}
54
+
55
+
type cachedPostInfo struct {
56
+
ID uint
57
+
Author uint
58
+
}
59
+
60
+
// NewPostgresBackend creates a new PostgresBackend
61
+
func NewPostgresBackend(mydid string, db *gorm.DB, pgx *pgxpool.Pool, client *xrpc.Client, dir identity.Directory) (*PostgresBackend, error) {
62
+
rc, _ := lru.New2Q[string, *Repo](1_000_000)
63
+
pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000)
64
+
revc, _ := lru.New2Q[uint, string](1_000_000)
65
+
dbic, _ := lru.New2Q[uint, string](1_000_000)
66
+
67
+
b := &PostgresBackend{
68
+
client: client,
69
+
mydid: mydid,
70
+
db: db,
71
+
pgx: pgx,
72
+
relevantDids: make(map[string]bool),
73
+
repoCache: rc,
74
+
postInfoCache: pc,
75
+
revCache: revc,
76
+
didByIDCache: dbic,
77
+
dir: dir,
78
+
79
+
missingRecords: make(chan MissingRecord, 1000),
80
+
}
81
+
82
+
r, err := b.GetOrCreateRepo(context.TODO(), mydid)
83
+
if err != nil {
84
+
return nil, err
85
+
}
86
+
87
+
b.myrepo = r
88
+
89
+
go b.missingRecordFetcher()
90
+
return b, nil
91
+
}
92
+
93
+
// TrackMissingRecord implements the RecordTracker interface
94
+
func (b *PostgresBackend) TrackMissingRecord(identifier string, wait bool) {
95
+
mr := MissingRecord{
96
+
Type: mrTypeFromIdent(identifier),
97
+
Identifier: identifier,
98
+
Wait: wait,
99
+
}
100
+
101
+
b.addMissingRecord(context.TODO(), mr)
102
+
}
103
+
104
+
func mrTypeFromIdent(ident string) MissingRecordType {
105
+
if strings.HasPrefix(ident, "did:") {
106
+
return MissingRecordTypeProfile
107
+
}
108
+
109
+
puri, _ := syntax.ParseATURI(ident)
110
+
switch puri.Collection().String() {
111
+
case "app.bsky.feed.post":
112
+
return MissingRecordTypePost
113
+
case "app.bsky.feed.generator":
114
+
return MissingRecordTypeFeedGenerator
115
+
default:
116
+
return MissingRecordTypeUnknown
117
+
}
118
+
119
+
}
120
+
121
+
// DidToID converts a DID to a database ID
122
+
func (b *PostgresBackend) DidToID(ctx context.Context, did string) (uint, error) {
123
+
r, err := b.GetOrCreateRepo(ctx, did)
124
+
if err != nil {
125
+
return 0, err
126
+
}
127
+
return r.ID, nil
128
+
}
129
+
130
+
func (b *PostgresBackend) GetOrCreateRepo(ctx context.Context, did string) (*Repo, error) {
131
+
r, ok := b.repoCache.Get(did)
132
+
if !ok {
133
+
b.reposLk.Lock()
134
+
135
+
r, ok = b.repoCache.Get(did)
136
+
if !ok {
137
+
r = &Repo{}
138
+
r.Did = did
139
+
b.repoCache.Add(did, r)
140
+
}
141
+
142
+
b.reposLk.Unlock()
143
+
}
144
+
145
+
r.Lk.Lock()
146
+
defer r.Lk.Unlock()
147
+
if r.Setup {
148
+
return r, nil
149
+
}
150
+
151
+
row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did)
152
+
153
+
err := row.Scan(&r.ID, &r.CreatedAt, &r.Did)
154
+
if err == nil {
155
+
// found it!
156
+
r.Setup = true
157
+
return r, nil
158
+
}
159
+
160
+
if err != pgx.ErrNoRows {
161
+
return nil, err
162
+
}
163
+
164
+
r.Did = did
165
+
if err := b.db.Create(r).Error; err != nil {
166
+
return nil, err
167
+
}
168
+
169
+
r.Setup = true
170
+
171
+
return r, nil
172
+
}
173
+
174
+
func (b *PostgresBackend) GetOrCreateList(ctx context.Context, uri string) (*List, error) {
175
+
puri, err := util.ParseAtUri(uri)
176
+
if err != nil {
177
+
return nil, err
178
+
}
179
+
180
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
181
+
if err != nil {
182
+
return nil, err
183
+
}
184
+
185
+
// TODO: needs upsert treatment when we actually find the list
186
+
var list List
187
+
if err := b.db.FirstOrCreate(&list, map[string]any{
188
+
"author": r.ID,
189
+
"rkey": puri.Rkey,
190
+
}).Error; err != nil {
191
+
return nil, err
192
+
}
193
+
return &list, nil
194
+
}
195
+
196
+
func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) {
197
+
// getPostByUri implicitly fills the cache
198
+
p, err := b.postInfoForUri(ctx, uri)
199
+
if err != nil {
200
+
return 0, err
201
+
}
202
+
203
+
return p.ID, nil
204
+
}
205
+
206
+
func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) {
207
+
v, ok := b.postInfoCache.Get(uri)
208
+
if ok {
209
+
return v, nil
210
+
}
211
+
212
+
// getPostByUri implicitly fills the cache
213
+
p, err := b.getOrCreatePostBare(ctx, uri)
214
+
if err != nil {
215
+
return cachedPostInfo{}, err
216
+
}
217
+
218
+
return cachedPostInfo{ID: p.ID, Author: p.Author}, nil
219
+
}
220
+
221
+
func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) {
222
+
var p Post
223
+
q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2"
224
+
if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil {
225
+
if errors.Is(err, pgx.ErrNoRows) {
226
+
return nil, nil
227
+
}
228
+
return nil, err
229
+
}
230
+
231
+
return &p, nil
232
+
}
233
+
234
+
func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) {
235
+
puri, err := util.ParseAtUri(uri)
236
+
if err != nil {
237
+
return nil, err
238
+
}
239
+
240
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
241
+
if err != nil {
242
+
return nil, err
243
+
}
244
+
245
+
post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
246
+
if err != nil {
247
+
return nil, err
248
+
}
249
+
250
+
if post == nil {
251
+
post = &Post{
252
+
Rkey: puri.Rkey,
253
+
Author: r.ID,
254
+
NotFound: true,
255
+
}
256
+
257
+
err := b.pgx.QueryRow(ctx, "INSERT INTO posts (rkey, author, not_found) VALUES ($1, $2, $3) RETURNING id", puri.Rkey, r.ID, true).Scan(&post.ID)
258
+
if err != nil {
259
+
pgErr, ok := err.(*pgconn.PgError)
260
+
if !ok || pgErr.Code != "23505" {
261
+
return nil, err
262
+
}
263
+
264
+
out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
265
+
if err != nil {
266
+
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
267
+
}
268
+
if out == nil {
269
+
return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey)
270
+
}
271
+
272
+
post = out
273
+
}
274
+
275
+
}
276
+
277
+
b.postInfoCache.Add(uri, cachedPostInfo{
278
+
ID: post.ID,
279
+
Author: post.Author,
280
+
})
281
+
282
+
return post, nil
283
+
}
284
+
285
+
func (b *PostgresBackend) GetPostByUri(ctx context.Context, uri string, fields string) (*Post, error) {
286
+
puri, err := util.ParseAtUri(uri)
287
+
if err != nil {
288
+
return nil, err
289
+
}
290
+
291
+
r, err := b.GetOrCreateRepo(ctx, puri.Did)
292
+
if err != nil {
293
+
return nil, err
294
+
}
295
+
296
+
q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?"
297
+
298
+
var post Post
299
+
if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil {
300
+
return nil, err
301
+
}
302
+
303
+
if post.ID == 0 {
304
+
post.Rkey = puri.Rkey
305
+
post.Author = r.ID
306
+
post.NotFound = true
307
+
308
+
if err := b.db.Session(&gorm.Session{
309
+
Logger: logger.Default.LogMode(logger.Silent),
310
+
}).Create(&post).Error; err != nil {
311
+
if !errors.Is(err, gorm.ErrDuplicatedKey) {
312
+
return nil, err
313
+
}
314
+
if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil {
315
+
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
316
+
}
317
+
}
318
+
319
+
}
320
+
321
+
b.postInfoCache.Add(uri, cachedPostInfo{
322
+
ID: post.ID,
323
+
Author: post.Author,
324
+
})
325
+
326
+
return &post, nil
327
+
}
328
+
329
+
func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) {
330
+
lrev, ok := b.revCache.Get(rr.ID)
331
+
if ok {
332
+
return lrev, nil
333
+
}
334
+
335
+
var rev string
336
+
if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil {
337
+
if errors.Is(err, pgx.ErrNoRows) {
338
+
return "", nil
339
+
}
340
+
return "", err
341
+
}
342
+
343
+
if rev != "" {
344
+
b.revCache.Add(rr.ID, rev)
345
+
}
346
+
return rev, nil
347
+
}
348
+
349
+
func (b *PostgresBackend) AddRelevantDid(did string) {
350
+
b.rdLk.Lock()
351
+
defer b.rdLk.Unlock()
352
+
b.relevantDids[did] = true
353
+
}
354
+
355
+
func (b *PostgresBackend) DidIsRelevant(did string) bool {
356
+
b.rdLk.Lock()
357
+
defer b.rdLk.Unlock()
358
+
return b.relevantDids[did]
359
+
}
360
+
361
+
func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool {
362
+
for _, id := range idents {
363
+
if strings.HasPrefix(id, "did:") {
364
+
if b.DidIsRelevant(id) {
365
+
return true
366
+
}
367
+
} else if strings.HasPrefix(id, "at://") {
368
+
puri, err := syntax.ParseATURI(id)
369
+
if err != nil {
370
+
continue
371
+
}
372
+
373
+
if b.DidIsRelevant(puri.Authority().String()) {
374
+
return true
375
+
}
376
+
}
377
+
}
378
+
379
+
return false
380
+
}
381
+
382
+
func (b *PostgresBackend) GetRelevantDids() []string {
383
+
b.rdLk.Lock()
384
+
var out []string
385
+
for k := range b.relevantDids {
386
+
out = append(out, k)
387
+
}
388
+
b.rdLk.Unlock()
389
+
return out
390
+
}
391
+
392
+
func (b *PostgresBackend) GetRepoByID(ctx context.Context, id uint) (*models.Repo, error) {
393
+
var r models.Repo
394
+
if err := b.db.Find(&r, "id = ?", id).Error; err != nil {
395
+
return nil, err
396
+
}
397
+
398
+
return &r, nil
399
+
}
400
+
401
+
func (b *PostgresBackend) DidFromID(ctx context.Context, uid uint) (string, error) {
402
+
val, ok := b.didByIDCache.Get(uid)
403
+
if ok {
404
+
return val, nil
405
+
}
406
+
407
+
r, err := b.GetRepoByID(ctx, uid)
408
+
if err != nil {
409
+
return "", err
410
+
}
411
+
412
+
b.didByIDCache.Add(uid, r.Did)
413
+
return r.Did, nil
414
+
}
415
+
416
+
func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) {
417
+
var id uint
418
+
var notfound bool
419
+
if err := b.pgx.QueryRow(ctx, "SELECT id, not_found FROM posts WHERE author = $1 AND rkey = $2", repo.ID, rkey).Scan(&id, ¬found); err != nil {
420
+
if errors.Is(err, pgx.ErrNoRows) {
421
+
return false, nil
422
+
}
423
+
return false, err
424
+
}
425
+
426
+
if id != 0 && !notfound {
427
+
return true, nil
428
+
}
429
+
430
+
return false, nil
431
+
}
432
+
433
+
func (b *PostgresBackend) LoadRelevantDids() error {
434
+
ctx := context.TODO()
435
+
436
+
if err := b.ensureFollowsScraped(ctx, b.mydid); err != nil {
437
+
return fmt.Errorf("failed to scrape follows: %w", err)
438
+
}
439
+
440
+
r, err := b.GetOrCreateRepo(ctx, b.mydid)
441
+
if err != nil {
442
+
return err
443
+
}
444
+
445
+
var dids []string
446
+
if err := b.db.Raw("select did from follows left join repos on follows.subject = repos.id where follows.author = ?", r.ID).Scan(&dids).Error; err != nil {
447
+
return err
448
+
}
449
+
450
+
b.relevantDids[b.mydid] = true
451
+
for _, d := range dids {
452
+
fmt.Println("adding did: ", d)
453
+
b.relevantDids[d] = true
454
+
}
455
+
456
+
return nil
457
+
}
458
+
459
+
type SyncInfo struct {
460
+
Repo uint `gorm:"index"`
461
+
FollowsSynced bool
462
+
Rev string
463
+
}
464
+
465
+
func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error {
466
+
r, err := b.GetOrCreateRepo(ctx, user)
467
+
if err != nil {
468
+
return err
469
+
}
470
+
471
+
var si SyncInfo
472
+
if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil {
473
+
return err
474
+
}
475
+
476
+
// not found
477
+
if si.Repo == 0 {
478
+
if err := b.db.Create(&SyncInfo{
479
+
Repo: r.ID,
480
+
}).Error; err != nil {
481
+
return err
482
+
}
483
+
}
484
+
485
+
if si.FollowsSynced {
486
+
return nil
487
+
}
488
+
489
+
var follows []Follow
490
+
var cursor string
491
+
for {
492
+
resp, err := atproto.RepoListRecords(ctx, b.client, "app.bsky.graph.follow", cursor, 100, b.mydid, false)
493
+
if err != nil {
494
+
return err
495
+
}
496
+
497
+
for _, rec := range resp.Records {
498
+
if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok {
499
+
fr, err := b.GetOrCreateRepo(ctx, fol.Subject)
500
+
if err != nil {
501
+
return err
502
+
}
503
+
504
+
puri, err := syntax.ParseATURI(rec.Uri)
505
+
if err != nil {
506
+
return err
507
+
}
508
+
509
+
follows = append(follows, Follow{
510
+
Created: time.Now(),
511
+
Indexed: time.Now(),
512
+
Rkey: puri.RecordKey().String(),
513
+
Author: r.ID,
514
+
Subject: fr.ID,
515
+
})
516
+
}
517
+
}
518
+
519
+
if resp.Cursor == nil || len(resp.Records) == 0 {
520
+
break
521
+
}
522
+
cursor = *resp.Cursor
523
+
}
524
+
525
+
if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil {
526
+
return err
527
+
}
528
+
529
+
if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil {
530
+
return err
531
+
}
532
+
533
+
fmt.Println("Got follows: ", len(follows))
534
+
535
+
return nil
536
+
}
+1208
backend/events.go
+1208
backend/events.go
···
1
+
package backend
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"log/slog"
9
+
"strings"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/api/bsky"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
lexutil "github.com/bluesky-social/indigo/lex/util"
16
+
"github.com/bluesky-social/indigo/repo"
17
+
jsmodels "github.com/bluesky-social/jetstream/pkg/models"
18
+
"github.com/ipfs/go-cid"
19
+
"github.com/jackc/pgx/v5/pgconn"
20
+
"github.com/prometheus/client_golang/prometheus"
21
+
"github.com/prometheus/client_golang/prometheus/promauto"
22
+
23
+
. "github.com/whyrusleeping/konbini/models"
24
+
)
25
+
26
+
var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{
27
+
Name: "handle_op_duration",
28
+
Help: "A histogram of op handling durations",
29
+
Buckets: prometheus.ExponentialBuckets(1, 2, 15),
30
+
}, []string{"op", "collection"})
31
+
32
+
func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error {
33
+
r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks))
34
+
if err != nil {
35
+
return fmt.Errorf("failed to read event repo: %w", err)
36
+
}
37
+
38
+
for _, op := range evt.Ops {
39
+
switch op.Action {
40
+
case "create":
41
+
c, rec, err := r.GetRecordBytes(ctx, op.Path)
42
+
if err != nil {
43
+
return err
44
+
}
45
+
if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
46
+
return fmt.Errorf("create record failed: %w", err)
47
+
}
48
+
case "update":
49
+
c, rec, err := r.GetRecordBytes(ctx, op.Path)
50
+
if err != nil {
51
+
return err
52
+
}
53
+
if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
54
+
return fmt.Errorf("update record failed: %w", err)
55
+
}
56
+
case "delete":
57
+
if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil {
58
+
return fmt.Errorf("delete record failed: %w", err)
59
+
}
60
+
}
61
+
}
62
+
63
+
// TODO: sync with the Since field to make sure we don't miss events we care about
64
+
/*
65
+
if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil {
66
+
return fmt.Errorf("failed to update rev: %w", err)
67
+
}
68
+
*/
69
+
70
+
return nil
71
+
}
72
+
73
+
func cborBytesFromEvent(evt *jsmodels.Event) ([]byte, error) {
74
+
val, err := lexutil.NewFromType(evt.Commit.Collection)
75
+
if err != nil {
76
+
return nil, fmt.Errorf("failed to load event record type: %w", err)
77
+
}
78
+
79
+
if err := json.Unmarshal(evt.Commit.Record, val); err != nil {
80
+
return nil, err
81
+
}
82
+
83
+
cval, ok := val.(lexutil.CBOR)
84
+
if !ok {
85
+
return nil, fmt.Errorf("decoded type was not cbor marshalable")
86
+
}
87
+
88
+
buf := new(bytes.Buffer)
89
+
if err := cval.MarshalCBOR(buf); err != nil {
90
+
return nil, fmt.Errorf("failed to marshal event to cbor: %w", err)
91
+
}
92
+
93
+
rec := buf.Bytes()
94
+
return rec, nil
95
+
}
96
+
97
+
func (b *PostgresBackend) HandleEventJetstream(ctx context.Context, evt *jsmodels.Event) error {
98
+
99
+
path := evt.Commit.Collection + "/" + evt.Commit.RKey
100
+
switch evt.Commit.Operation {
101
+
case jsmodels.CommitOperationCreate:
102
+
rec, err := cborBytesFromEvent(evt)
103
+
if err != nil {
104
+
return err
105
+
}
106
+
107
+
c, err := cid.Decode(evt.Commit.CID)
108
+
if err != nil {
109
+
return err
110
+
}
111
+
112
+
if err := b.HandleCreate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil {
113
+
return fmt.Errorf("create record failed: %w", err)
114
+
}
115
+
case jsmodels.CommitOperationUpdate:
116
+
rec, err := cborBytesFromEvent(evt)
117
+
if err != nil {
118
+
return err
119
+
}
120
+
121
+
c, err := cid.Decode(evt.Commit.CID)
122
+
if err != nil {
123
+
return err
124
+
}
125
+
126
+
if err := b.HandleUpdate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil {
127
+
return fmt.Errorf("update record failed: %w", err)
128
+
}
129
+
case jsmodels.CommitOperationDelete:
130
+
if err := b.HandleDelete(ctx, evt.Did, evt.Commit.Rev, path); err != nil {
131
+
return fmt.Errorf("delete record failed: %w", err)
132
+
}
133
+
}
134
+
135
+
return nil
136
+
}
137
+
138
+
func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
139
+
start := time.Now()
140
+
141
+
rr, err := b.GetOrCreateRepo(ctx, repo)
142
+
if err != nil {
143
+
return fmt.Errorf("get user failed: %w", err)
144
+
}
145
+
146
+
lrev, err := b.revForRepo(rr)
147
+
if err != nil {
148
+
return err
149
+
}
150
+
if lrev != "" {
151
+
if rev < lrev {
152
+
slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
153
+
return nil
154
+
}
155
+
}
156
+
157
+
parts := strings.Split(path, "/")
158
+
if len(parts) != 2 {
159
+
return fmt.Errorf("invalid path in HandleCreate: %q", path)
160
+
}
161
+
col := parts[0]
162
+
rkey := parts[1]
163
+
164
+
defer func() {
165
+
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
166
+
}()
167
+
168
+
if rkey == "" {
169
+
fmt.Printf("messed up path: %q\n", rkey)
170
+
}
171
+
172
+
switch col {
173
+
case "app.bsky.feed.post":
174
+
if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
175
+
return err
176
+
}
177
+
case "app.bsky.feed.like":
178
+
if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
179
+
return err
180
+
}
181
+
case "app.bsky.feed.repost":
182
+
if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
183
+
return err
184
+
}
185
+
case "app.bsky.graph.follow":
186
+
if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
187
+
return err
188
+
}
189
+
case "app.bsky.graph.block":
190
+
if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
191
+
return err
192
+
}
193
+
case "app.bsky.graph.list":
194
+
if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
195
+
return err
196
+
}
197
+
case "app.bsky.graph.listitem":
198
+
if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
199
+
return err
200
+
}
201
+
case "app.bsky.graph.listblock":
202
+
if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
203
+
return err
204
+
}
205
+
case "app.bsky.actor.profile":
206
+
if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
207
+
return err
208
+
}
209
+
case "app.bsky.feed.generator":
210
+
if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
211
+
return err
212
+
}
213
+
case "app.bsky.feed.threadgate":
214
+
if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
215
+
return err
216
+
}
217
+
case "chat.bsky.actor.declaration":
218
+
if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
219
+
return err
220
+
}
221
+
case "app.bsky.feed.postgate":
222
+
if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil {
223
+
return err
224
+
}
225
+
case "app.bsky.graph.starterpack":
226
+
if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil {
227
+
return err
228
+
}
229
+
default:
230
+
slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev)
231
+
}
232
+
233
+
b.revCache.Add(rr.ID, rev)
234
+
return nil
235
+
}
236
+
237
+
func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
238
+
exists, err := b.checkPostExists(ctx, repo, rkey)
239
+
if err != nil {
240
+
return err
241
+
}
242
+
243
+
// still technically a race condition if two creates for the same post happen concurrently... probably fine
244
+
if exists {
245
+
return nil
246
+
}
247
+
248
+
var rec bsky.FeedPost
249
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
250
+
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
251
+
slog.Warn("skipping post with malformed data", "uri", uri, "error", err)
252
+
return nil // Skip this post rather than failing the entire event
253
+
}
254
+
255
+
reldids := []string{repo.Did}
256
+
// care about a post if its in a thread of a user we are interested in
257
+
if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
258
+
reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri)
259
+
}
260
+
// TODO: maybe also care if its mentioning a user we care about or quoting a user we care about?
261
+
if !b.anyRelevantIdents(reldids...) {
262
+
return nil
263
+
}
264
+
265
+
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
266
+
slog.Warn("adding post", "uri", uri)
267
+
268
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
269
+
if err != nil {
270
+
return fmt.Errorf("invalid timestamp: %w", err)
271
+
}
272
+
273
+
p := Post{
274
+
Created: created.Time(),
275
+
Indexed: time.Now(),
276
+
Author: repo.ID,
277
+
Rkey: rkey,
278
+
Raw: recb,
279
+
Cid: cc.String(),
280
+
}
281
+
282
+
if rec.Reply != nil && rec.Reply.Parent != nil {
283
+
if rec.Reply.Root == nil {
284
+
return fmt.Errorf("post reply had nil root")
285
+
}
286
+
287
+
pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri)
288
+
if err != nil {
289
+
return fmt.Errorf("getting reply parent: %w", err)
290
+
}
291
+
292
+
p.ReplyTo = pinfo.ID
293
+
p.ReplyToUsr = pinfo.Author
294
+
295
+
thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri)
296
+
if err != nil {
297
+
return fmt.Errorf("getting thread root: %w", err)
298
+
}
299
+
300
+
p.InThread = thread
301
+
302
+
r, err := b.GetOrCreateRepo(ctx, b.mydid)
303
+
if err != nil {
304
+
return err
305
+
}
306
+
307
+
if p.ReplyToUsr == r.ID {
308
+
if err := b.AddNotification(ctx, r.ID, p.Author, uri, cc, NotifKindReply); err != nil {
309
+
slog.Warn("failed to create notification", "uri", uri, "error", err)
310
+
}
311
+
}
312
+
}
313
+
314
+
if rec.Embed != nil {
315
+
var rpref string
316
+
if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil {
317
+
rpref = rec.Embed.EmbedRecord.Record.Uri
318
+
}
319
+
if rec.Embed.EmbedRecordWithMedia != nil &&
320
+
rec.Embed.EmbedRecordWithMedia.Record != nil &&
321
+
rec.Embed.EmbedRecordWithMedia.Record.Record != nil {
322
+
rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri
323
+
}
324
+
325
+
if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") {
326
+
rp, err := b.postIDForUri(ctx, rpref)
327
+
if err != nil {
328
+
return fmt.Errorf("getting quote subject: %w", err)
329
+
}
330
+
331
+
p.Reposting = rp
332
+
}
333
+
}
334
+
335
+
if err := b.doPostCreate(ctx, &p); err != nil {
336
+
return err
337
+
}
338
+
339
+
// Check for mentions and create notifications
340
+
if rec.Facets != nil {
341
+
for _, facet := range rec.Facets {
342
+
for _, feature := range facet.Features {
343
+
if feature.RichtextFacet_Mention != nil {
344
+
mentionDid := feature.RichtextFacet_Mention.Did
345
+
// This is a mention
346
+
mentionedRepo, err := b.GetOrCreateRepo(ctx, mentionDid)
347
+
if err != nil {
348
+
slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err)
349
+
continue
350
+
}
351
+
352
+
// Create notification if the mentioned user is the current user
353
+
if mentionedRepo.ID == b.myrepo.ID {
354
+
if err := b.AddNotification(ctx, b.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil {
355
+
slog.Warn("failed to create mention notification", "uri", uri, "error", err)
356
+
}
357
+
}
358
+
}
359
+
}
360
+
}
361
+
}
362
+
363
+
b.postInfoCache.Add(uri, cachedPostInfo{
364
+
ID: p.ID,
365
+
Author: p.Author,
366
+
})
367
+
368
+
return nil
369
+
}
370
+
371
+
func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error {
372
+
/*
373
+
if err := b.db.Clauses(clause.OnConflict{
374
+
Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}},
375
+
DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}),
376
+
}).Create(p).Error; err != nil {
377
+
return err
378
+
}
379
+
*/
380
+
381
+
query := `
382
+
INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread)
383
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
384
+
ON CONFLICT (author, rkey)
385
+
DO UPDATE SET
386
+
cid = $3,
387
+
not_found = $4,
388
+
raw = $5,
389
+
created = $6,
390
+
indexed = $7,
391
+
reposting = $8,
392
+
reply_to = $9,
393
+
reply_to_usr = $10,
394
+
in_thread = $11
395
+
RETURNING id
396
+
`
397
+
398
+
// Execute the query with parameters from the Post struct
399
+
if err := b.pgx.QueryRow(
400
+
ctx,
401
+
query,
402
+
p.Author,
403
+
p.Rkey,
404
+
p.Cid,
405
+
p.NotFound,
406
+
p.Raw,
407
+
p.Created,
408
+
p.Indexed,
409
+
p.Reposting,
410
+
p.ReplyTo,
411
+
p.ReplyToUsr,
412
+
p.InThread,
413
+
).Scan(&p.ID); err != nil {
414
+
return err
415
+
}
416
+
417
+
return nil
418
+
}
419
+
420
+
func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
421
+
var rec bsky.FeedLike
422
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
423
+
return err
424
+
}
425
+
426
+
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
427
+
return nil
428
+
}
429
+
430
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
431
+
if err != nil {
432
+
return fmt.Errorf("invalid timestamp: %w", err)
433
+
}
434
+
435
+
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
436
+
if err != nil {
437
+
return fmt.Errorf("getting like subject: %w", err)
438
+
}
439
+
440
+
if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID, cc.String()); err != nil {
441
+
pgErr, ok := err.(*pgconn.PgError)
442
+
if ok && pgErr.Code == "23505" {
443
+
return nil
444
+
}
445
+
return err
446
+
}
447
+
448
+
// Create notification if the liked post belongs to the current user
449
+
if pinfo.Author == b.myrepo.ID {
450
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey)
451
+
if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil {
452
+
slog.Warn("failed to create like notification", "uri", uri, "error", err)
453
+
}
454
+
}
455
+
456
+
return nil
457
+
}
458
+
459
+
func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
460
+
var rec bsky.FeedRepost
461
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
462
+
return err
463
+
}
464
+
465
+
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
466
+
return nil
467
+
}
468
+
469
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
470
+
if err != nil {
471
+
return fmt.Errorf("invalid timestamp: %w", err)
472
+
}
473
+
474
+
pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri)
475
+
if err != nil {
476
+
return fmt.Errorf("getting repost subject: %w", err)
477
+
}
478
+
479
+
if _, err := b.pgx.Exec(ctx, `INSERT INTO "reposts" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID); err != nil {
480
+
pgErr, ok := err.(*pgconn.PgError)
481
+
if ok && pgErr.Code == "23505" {
482
+
return nil
483
+
}
484
+
return err
485
+
}
486
+
487
+
// Create notification if the reposted post belongs to the current user
488
+
if pinfo.Author == b.myrepo.ID {
489
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey)
490
+
if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil {
491
+
slog.Warn("failed to create repost notification", "uri", uri, "error", err)
492
+
}
493
+
}
494
+
495
+
return nil
496
+
}
497
+
498
+
func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
499
+
var rec bsky.GraphFollow
500
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
501
+
return err
502
+
}
503
+
504
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
505
+
return nil
506
+
}
507
+
508
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
509
+
if err != nil {
510
+
return fmt.Errorf("invalid timestamp: %w", err)
511
+
}
512
+
513
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
514
+
if err != nil {
515
+
return err
516
+
}
517
+
518
+
if _, err := b.pgx.Exec(ctx, "INSERT INTO follows (created, indexed, author, rkey, subject) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", created.Time(), time.Now(), repo.ID, rkey, subj.ID); err != nil {
519
+
return err
520
+
}
521
+
522
+
return nil
523
+
}
524
+
525
+
func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
526
+
var rec bsky.GraphBlock
527
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
528
+
return err
529
+
}
530
+
531
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
532
+
return nil
533
+
}
534
+
535
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
536
+
if err != nil {
537
+
return fmt.Errorf("invalid timestamp: %w", err)
538
+
}
539
+
540
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
541
+
if err != nil {
542
+
return err
543
+
}
544
+
545
+
if err := b.db.Create(&Block{
546
+
Created: created.Time(),
547
+
Indexed: time.Now(),
548
+
Author: repo.ID,
549
+
Rkey: rkey,
550
+
Subject: subj.ID,
551
+
}).Error; err != nil {
552
+
return err
553
+
}
554
+
555
+
return nil
556
+
}
557
+
558
+
func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
559
+
var rec bsky.GraphList
560
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
561
+
return err
562
+
}
563
+
564
+
if !b.anyRelevantIdents(repo.Did) {
565
+
return nil
566
+
}
567
+
568
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
569
+
if err != nil {
570
+
return fmt.Errorf("invalid timestamp: %w", err)
571
+
}
572
+
573
+
if err := b.db.Create(&List{
574
+
Created: created.Time(),
575
+
Indexed: time.Now(),
576
+
Author: repo.ID,
577
+
Rkey: rkey,
578
+
Raw: recb,
579
+
}).Error; err != nil {
580
+
return err
581
+
}
582
+
583
+
return nil
584
+
}
585
+
586
+
func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
587
+
var rec bsky.GraphListitem
588
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
589
+
return err
590
+
}
591
+
if !b.anyRelevantIdents(repo.Did) {
592
+
return nil
593
+
}
594
+
595
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
596
+
if err != nil {
597
+
return fmt.Errorf("invalid timestamp: %w", err)
598
+
}
599
+
600
+
subj, err := b.GetOrCreateRepo(ctx, rec.Subject)
601
+
if err != nil {
602
+
return err
603
+
}
604
+
605
+
list, err := b.GetOrCreateList(ctx, rec.List)
606
+
if err != nil {
607
+
return err
608
+
}
609
+
610
+
if err := b.db.Create(&ListItem{
611
+
Created: created.Time(),
612
+
Indexed: time.Now(),
613
+
Author: repo.ID,
614
+
Rkey: rkey,
615
+
Subject: subj.ID,
616
+
List: list.ID,
617
+
}).Error; err != nil {
618
+
return err
619
+
}
620
+
621
+
return nil
622
+
}
623
+
624
+
func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
625
+
var rec bsky.GraphListblock
626
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
627
+
return err
628
+
}
629
+
630
+
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
631
+
return nil
632
+
}
633
+
634
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
635
+
if err != nil {
636
+
return fmt.Errorf("invalid timestamp: %w", err)
637
+
}
638
+
639
+
list, err := b.GetOrCreateList(ctx, rec.Subject)
640
+
if err != nil {
641
+
return err
642
+
}
643
+
644
+
if err := b.db.Create(&ListBlock{
645
+
Created: created.Time(),
646
+
Indexed: time.Now(),
647
+
Author: repo.ID,
648
+
Rkey: rkey,
649
+
List: list.ID,
650
+
}).Error; err != nil {
651
+
return err
652
+
}
653
+
654
+
return nil
655
+
}
656
+
657
+
func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
658
+
if !b.anyRelevantIdents(repo.Did) {
659
+
return nil
660
+
}
661
+
662
+
if err := b.db.Create(&Profile{
663
+
//Created: created.Time(),
664
+
Indexed: time.Now(),
665
+
Repo: repo.ID,
666
+
Raw: recb,
667
+
Rev: rev,
668
+
}).Error; err != nil {
669
+
return err
670
+
}
671
+
672
+
return nil
673
+
}
674
+
675
+
func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
676
+
if !b.anyRelevantIdents(repo.Did) {
677
+
return nil
678
+
}
679
+
680
+
if err := b.db.Create(&Profile{
681
+
Indexed: time.Now(),
682
+
Repo: repo.ID,
683
+
Raw: recb,
684
+
Rev: rev,
685
+
}).Error; err != nil {
686
+
return err
687
+
}
688
+
689
+
return nil
690
+
}
691
+
692
+
func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
693
+
if !b.anyRelevantIdents(repo.Did) {
694
+
return nil
695
+
}
696
+
697
+
var rec bsky.FeedGenerator
698
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
699
+
return err
700
+
}
701
+
702
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
703
+
if err != nil {
704
+
return fmt.Errorf("invalid timestamp: %w", err)
705
+
}
706
+
707
+
if err := b.db.Create(&FeedGenerator{
708
+
Created: created.Time(),
709
+
Indexed: time.Now(),
710
+
Author: repo.ID,
711
+
Rkey: rkey,
712
+
Did: rec.Did,
713
+
Raw: recb,
714
+
}).Error; err != nil {
715
+
return err
716
+
}
717
+
718
+
return nil
719
+
}
720
+
721
+
func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
722
+
if !b.anyRelevantIdents(repo.Did) {
723
+
return nil
724
+
}
725
+
var rec bsky.FeedThreadgate
726
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
727
+
return err
728
+
}
729
+
730
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
731
+
if err != nil {
732
+
return fmt.Errorf("invalid timestamp: %w", err)
733
+
}
734
+
735
+
pid, err := b.postIDForUri(ctx, rec.Post)
736
+
if err != nil {
737
+
return err
738
+
}
739
+
740
+
if err := b.db.Create(&ThreadGate{
741
+
Created: created.Time(),
742
+
Indexed: time.Now(),
743
+
Author: repo.ID,
744
+
Rkey: rkey,
745
+
Post: pid,
746
+
}).Error; err != nil {
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
}
752
+
753
+
func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
754
+
// TODO: maybe track these?
755
+
return nil
756
+
}
757
+
758
+
func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
759
+
if !b.anyRelevantIdents(repo.Did) {
760
+
return nil
761
+
}
762
+
var rec bsky.FeedPostgate
763
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
764
+
return err
765
+
}
766
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
767
+
if err != nil {
768
+
return fmt.Errorf("invalid timestamp: %w", err)
769
+
}
770
+
771
+
refPost, err := b.postInfoForUri(ctx, rec.Post)
772
+
if err != nil {
773
+
return err
774
+
}
775
+
776
+
if err := b.db.Create(&PostGate{
777
+
Created: created.Time(),
778
+
Indexed: time.Now(),
779
+
Author: repo.ID,
780
+
Rkey: rkey,
781
+
Subject: refPost.ID,
782
+
Raw: recb,
783
+
}).Error; err != nil {
784
+
return err
785
+
}
786
+
787
+
return nil
788
+
}
789
+
790
+
func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
791
+
if !b.anyRelevantIdents(repo.Did) {
792
+
return nil
793
+
}
794
+
var rec bsky.GraphStarterpack
795
+
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
796
+
return err
797
+
}
798
+
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
799
+
if err != nil {
800
+
return fmt.Errorf("invalid timestamp: %w", err)
801
+
}
802
+
803
+
list, err := b.GetOrCreateList(ctx, rec.List)
804
+
if err != nil {
805
+
return err
806
+
}
807
+
808
+
if err := b.db.Create(&StarterPack{
809
+
Created: created.Time(),
810
+
Indexed: time.Now(),
811
+
Author: repo.ID,
812
+
Rkey: rkey,
813
+
Raw: recb,
814
+
List: list.ID,
815
+
}).Error; err != nil {
816
+
return err
817
+
}
818
+
819
+
return nil
820
+
}
821
+
822
+
func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
823
+
start := time.Now()
824
+
825
+
rr, err := b.GetOrCreateRepo(ctx, repo)
826
+
if err != nil {
827
+
return fmt.Errorf("get user failed: %w", err)
828
+
}
829
+
830
+
lrev, err := b.revForRepo(rr)
831
+
if err != nil {
832
+
return err
833
+
}
834
+
if lrev != "" {
835
+
if rev < lrev {
836
+
//slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
837
+
return nil
838
+
}
839
+
}
840
+
841
+
parts := strings.Split(path, "/")
842
+
if len(parts) != 2 {
843
+
return fmt.Errorf("invalid path in HandleCreate: %q", path)
844
+
}
845
+
col := parts[0]
846
+
rkey := parts[1]
847
+
848
+
defer func() {
849
+
handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds()))
850
+
}()
851
+
852
+
if rkey == "" {
853
+
fmt.Printf("messed up path: %q\n", rkey)
854
+
}
855
+
856
+
switch col {
857
+
/*
858
+
case "app.bsky.feed.post":
859
+
if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
860
+
return err
861
+
}
862
+
case "app.bsky.feed.like":
863
+
if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
864
+
return err
865
+
}
866
+
case "app.bsky.feed.repost":
867
+
if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
868
+
return err
869
+
}
870
+
case "app.bsky.graph.follow":
871
+
if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
872
+
return err
873
+
}
874
+
case "app.bsky.graph.block":
875
+
if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
876
+
return err
877
+
}
878
+
case "app.bsky.graph.list":
879
+
if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
880
+
return err
881
+
}
882
+
case "app.bsky.graph.listitem":
883
+
if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
884
+
return err
885
+
}
886
+
case "app.bsky.graph.listblock":
887
+
if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
888
+
return err
889
+
}
890
+
*/
891
+
case "app.bsky.actor.profile":
892
+
if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
893
+
return err
894
+
}
895
+
/*
896
+
case "app.bsky.feed.generator":
897
+
if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
898
+
return err
899
+
}
900
+
case "app.bsky.feed.threadgate":
901
+
if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
902
+
return err
903
+
}
904
+
case "chat.bsky.actor.declaration":
905
+
if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
906
+
return err
907
+
}
908
+
*/
909
+
default:
910
+
slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev)
911
+
}
912
+
913
+
return nil
914
+
}
915
+
916
+
func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error {
917
+
start := time.Now()
918
+
919
+
rr, err := b.GetOrCreateRepo(ctx, repo)
920
+
if err != nil {
921
+
return fmt.Errorf("get user failed: %w", err)
922
+
}
923
+
924
+
lrev, ok := b.revCache.Get(rr.ID)
925
+
if ok {
926
+
if rev < lrev {
927
+
//slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev)
928
+
return nil
929
+
}
930
+
}
931
+
932
+
parts := strings.Split(path, "/")
933
+
if len(parts) != 2 {
934
+
return fmt.Errorf("invalid path in HandleDelete: %q", path)
935
+
}
936
+
col := parts[0]
937
+
rkey := parts[1]
938
+
939
+
defer func() {
940
+
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
941
+
}()
942
+
943
+
switch col {
944
+
case "app.bsky.feed.post":
945
+
if err := b.HandleDeletePost(ctx, rr, rkey); err != nil {
946
+
return err
947
+
}
948
+
case "app.bsky.feed.like":
949
+
if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil {
950
+
return err
951
+
}
952
+
case "app.bsky.feed.repost":
953
+
if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil {
954
+
return err
955
+
}
956
+
case "app.bsky.graph.follow":
957
+
if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil {
958
+
return err
959
+
}
960
+
case "app.bsky.graph.block":
961
+
if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil {
962
+
return err
963
+
}
964
+
case "app.bsky.graph.list":
965
+
if err := b.HandleDeleteList(ctx, rr, rkey); err != nil {
966
+
return err
967
+
}
968
+
case "app.bsky.graph.listitem":
969
+
if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil {
970
+
return err
971
+
}
972
+
case "app.bsky.graph.listblock":
973
+
if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil {
974
+
return err
975
+
}
976
+
case "app.bsky.actor.profile":
977
+
if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil {
978
+
return err
979
+
}
980
+
case "app.bsky.feed.generator":
981
+
if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil {
982
+
return err
983
+
}
984
+
case "app.bsky.feed.threadgate":
985
+
if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil {
986
+
return err
987
+
}
988
+
default:
989
+
slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev)
990
+
}
991
+
992
+
b.revCache.Add(rr.ID, rev)
993
+
return nil
994
+
}
995
+
996
+
func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error {
997
+
var p Post
998
+
if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
999
+
return err
1000
+
}
1001
+
1002
+
if p.ID == 0 {
1003
+
//slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey)
1004
+
return nil
1005
+
}
1006
+
1007
+
if err := b.db.Delete(&Post{}, p.ID).Error; err != nil {
1008
+
return err
1009
+
}
1010
+
1011
+
return nil
1012
+
}
1013
+
1014
+
func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error {
1015
+
var like Like
1016
+
if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1017
+
return err
1018
+
}
1019
+
1020
+
if like.ID == 0 {
1021
+
//slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey)
1022
+
return nil
1023
+
}
1024
+
1025
+
if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil {
1026
+
return err
1027
+
}
1028
+
1029
+
return nil
1030
+
}
1031
+
1032
+
func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error {
1033
+
var repost Repost
1034
+
if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1035
+
return err
1036
+
}
1037
+
1038
+
if repost.ID == 0 {
1039
+
//return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey)
1040
+
return nil
1041
+
}
1042
+
1043
+
if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil {
1044
+
return err
1045
+
}
1046
+
1047
+
return nil
1048
+
}
1049
+
1050
+
func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error {
1051
+
var follow Follow
1052
+
if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1053
+
return err
1054
+
}
1055
+
1056
+
if follow.ID == 0 {
1057
+
//slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey)
1058
+
return nil
1059
+
}
1060
+
1061
+
if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil {
1062
+
return err
1063
+
}
1064
+
1065
+
return nil
1066
+
}
1067
+
1068
+
func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error {
1069
+
var block Block
1070
+
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1071
+
return err
1072
+
}
1073
+
1074
+
if block.ID == 0 {
1075
+
//slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey)
1076
+
return nil
1077
+
}
1078
+
1079
+
if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil {
1080
+
return err
1081
+
}
1082
+
1083
+
return nil
1084
+
}
1085
+
1086
+
func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error {
1087
+
var list List
1088
+
if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1089
+
return err
1090
+
}
1091
+
1092
+
if list.ID == 0 {
1093
+
return nil
1094
+
//return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey)
1095
+
}
1096
+
1097
+
if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil {
1098
+
return err
1099
+
}
1100
+
1101
+
return nil
1102
+
}
1103
+
1104
+
func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error {
1105
+
var item ListItem
1106
+
if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1107
+
return err
1108
+
}
1109
+
1110
+
if item.ID == 0 {
1111
+
return nil
1112
+
//return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey)
1113
+
}
1114
+
1115
+
if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil {
1116
+
return err
1117
+
}
1118
+
1119
+
return nil
1120
+
}
1121
+
1122
+
func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error {
1123
+
var block ListBlock
1124
+
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1125
+
return err
1126
+
}
1127
+
1128
+
if block.ID == 0 {
1129
+
return nil
1130
+
//return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey)
1131
+
}
1132
+
1133
+
if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil {
1134
+
return err
1135
+
}
1136
+
1137
+
return nil
1138
+
}
1139
+
1140
+
func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error {
1141
+
var feedgen FeedGenerator
1142
+
if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1143
+
return err
1144
+
}
1145
+
1146
+
if feedgen.ID == 0 {
1147
+
return nil
1148
+
//return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey)
1149
+
}
1150
+
1151
+
if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil {
1152
+
return err
1153
+
}
1154
+
1155
+
return nil
1156
+
}
1157
+
1158
+
func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error {
1159
+
var threadgate ThreadGate
1160
+
if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1161
+
return err
1162
+
}
1163
+
1164
+
if threadgate.ID == 0 {
1165
+
return nil
1166
+
//return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey)
1167
+
}
1168
+
1169
+
if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil {
1170
+
return err
1171
+
}
1172
+
1173
+
return nil
1174
+
}
1175
+
1176
+
func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error {
1177
+
var profile Profile
1178
+
if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil {
1179
+
return err
1180
+
}
1181
+
1182
+
if profile.ID == 0 {
1183
+
return nil
1184
+
}
1185
+
1186
+
if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil {
1187
+
return err
1188
+
}
1189
+
1190
+
return nil
1191
+
}
1192
+
1193
+
const (
1194
+
NotifKindReply = "reply"
1195
+
NotifKindLike = "like"
1196
+
NotifKindMention = "mention"
1197
+
NotifKindRepost = "repost"
1198
+
)
1199
+
1200
+
func (b *PostgresBackend) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error {
1201
+
return b.db.Create(&Notification{
1202
+
For: forUser,
1203
+
Author: author,
1204
+
Source: recordUri,
1205
+
SourceCid: recordCid.String(),
1206
+
Kind: kind,
1207
+
}).Error
1208
+
}
+12
backend/interface.go
+12
backend/interface.go
···
1
+
package backend
2
+
3
+
// RecordTracker is an interface for tracking missing records that need to be fetched
4
+
type RecordTracker interface {
5
+
// TrackMissingRecord queues a missing record for fetching
6
+
// identifier can be:
7
+
// - A DID (e.g., "did:plc:...") for actors/profiles
8
+
// - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.post/...") for posts
9
+
// - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.generator/...") for feed generators
10
+
// wait: if true, blocks until the record is fetched
11
+
TrackMissingRecord(identifier string, wait bool)
12
+
}
+211
backend/missing.go
+211
backend/missing.go
···
1
+
package backend
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
9
+
"github.com/bluesky-social/indigo/api/atproto"
10
+
"github.com/bluesky-social/indigo/api/bsky"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
xrpclib "github.com/bluesky-social/indigo/xrpc"
13
+
"github.com/ipfs/go-cid"
14
+
)
15
+
16
+
type MissingRecordType string
17
+
18
+
const (
19
+
MissingRecordTypeProfile MissingRecordType = "profile"
20
+
MissingRecordTypePost MissingRecordType = "post"
21
+
MissingRecordTypeFeedGenerator MissingRecordType = "feedgenerator"
22
+
MissingRecordTypeUnknown MissingRecordType = "unknown"
23
+
)
24
+
25
+
type MissingRecord struct {
26
+
Type MissingRecordType
27
+
Identifier string // DID for profiles, AT-URI for posts/feedgens
28
+
Wait bool
29
+
30
+
waitch chan struct{}
31
+
}
32
+
33
+
func (b *PostgresBackend) addMissingRecord(ctx context.Context, rec MissingRecord) {
34
+
if rec.Wait {
35
+
rec.waitch = make(chan struct{})
36
+
}
37
+
38
+
select {
39
+
case b.missingRecords <- rec:
40
+
case <-ctx.Done():
41
+
}
42
+
43
+
if rec.Wait {
44
+
select {
45
+
case <-rec.waitch:
46
+
case <-ctx.Done():
47
+
}
48
+
}
49
+
}
50
+
51
+
func (b *PostgresBackend) missingRecordFetcher() {
52
+
for rec := range b.missingRecords {
53
+
var err error
54
+
switch rec.Type {
55
+
case MissingRecordTypeProfile:
56
+
err = b.fetchMissingProfile(context.TODO(), rec.Identifier)
57
+
case MissingRecordTypePost:
58
+
err = b.fetchMissingPost(context.TODO(), rec.Identifier)
59
+
case MissingRecordTypeFeedGenerator:
60
+
err = b.fetchMissingFeedGenerator(context.TODO(), rec.Identifier)
61
+
default:
62
+
slog.Error("unknown missing record type", "type", rec.Type)
63
+
continue
64
+
}
65
+
66
+
if err != nil {
67
+
slog.Warn("failed to fetch missing record", "type", rec.Type, "identifier", rec.Identifier, "error", err)
68
+
}
69
+
70
+
if rec.Wait {
71
+
close(rec.waitch)
72
+
}
73
+
}
74
+
}
75
+
76
+
func (b *PostgresBackend) fetchMissingProfile(ctx context.Context, did string) error {
77
+
b.AddRelevantDid(did)
78
+
79
+
repo, err := b.GetOrCreateRepo(ctx, did)
80
+
if err != nil {
81
+
return err
82
+
}
83
+
84
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
85
+
if err != nil {
86
+
return err
87
+
}
88
+
89
+
c := &xrpclib.Client{
90
+
Host: resp.PDSEndpoint(),
91
+
}
92
+
93
+
rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self")
94
+
if err != nil {
95
+
return err
96
+
}
97
+
98
+
prof, ok := rec.Value.Val.(*bsky.ActorProfile)
99
+
if !ok {
100
+
return fmt.Errorf("record we got back wasnt a profile somehow")
101
+
}
102
+
103
+
buf := new(bytes.Buffer)
104
+
if err := prof.MarshalCBOR(buf); err != nil {
105
+
return err
106
+
}
107
+
108
+
cc, err := cid.Decode(*rec.Cid)
109
+
if err != nil {
110
+
return err
111
+
}
112
+
113
+
return b.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc)
114
+
}
115
+
116
+
func (b *PostgresBackend) fetchMissingPost(ctx context.Context, uri string) error {
117
+
puri, err := syntax.ParseATURI(uri)
118
+
if err != nil {
119
+
return fmt.Errorf("invalid AT URI: %s", uri)
120
+
}
121
+
122
+
did := puri.Authority().String()
123
+
collection := puri.Collection().String()
124
+
rkey := puri.RecordKey().String()
125
+
126
+
b.AddRelevantDid(did)
127
+
128
+
repo, err := b.GetOrCreateRepo(ctx, did)
129
+
if err != nil {
130
+
return err
131
+
}
132
+
133
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
134
+
if err != nil {
135
+
return err
136
+
}
137
+
138
+
c := &xrpclib.Client{
139
+
Host: resp.PDSEndpoint(),
140
+
}
141
+
142
+
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
143
+
if err != nil {
144
+
return err
145
+
}
146
+
147
+
post, ok := rec.Value.Val.(*bsky.FeedPost)
148
+
if !ok {
149
+
return fmt.Errorf("record we got back wasn't a post somehow")
150
+
}
151
+
152
+
buf := new(bytes.Buffer)
153
+
if err := post.MarshalCBOR(buf); err != nil {
154
+
return err
155
+
}
156
+
157
+
cc, err := cid.Decode(*rec.Cid)
158
+
if err != nil {
159
+
return err
160
+
}
161
+
162
+
return b.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc)
163
+
}
164
+
165
+
func (b *PostgresBackend) fetchMissingFeedGenerator(ctx context.Context, uri string) error {
166
+
puri, err := syntax.ParseATURI(uri)
167
+
if err != nil {
168
+
return fmt.Errorf("invalid AT URI: %s", uri)
169
+
}
170
+
171
+
did := puri.Authority().String()
172
+
collection := puri.Collection().String()
173
+
rkey := puri.RecordKey().String()
174
+
b.AddRelevantDid(did)
175
+
176
+
repo, err := b.GetOrCreateRepo(ctx, did)
177
+
if err != nil {
178
+
return err
179
+
}
180
+
181
+
resp, err := b.dir.LookupDID(ctx, syntax.DID(did))
182
+
if err != nil {
183
+
return err
184
+
}
185
+
186
+
c := &xrpclib.Client{
187
+
Host: resp.PDSEndpoint(),
188
+
}
189
+
190
+
rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey)
191
+
if err != nil {
192
+
return err
193
+
}
194
+
195
+
feedGen, ok := rec.Value.Val.(*bsky.FeedGenerator)
196
+
if !ok {
197
+
return fmt.Errorf("record we got back wasn't a feed generator somehow")
198
+
}
199
+
200
+
buf := new(bytes.Buffer)
201
+
if err := feedGen.MarshalCBOR(buf); err != nil {
202
+
return err
203
+
}
204
+
205
+
cc, err := cid.Decode(*rec.Cid)
206
+
if err != nil {
207
+
return err
208
+
}
209
+
210
+
return b.HandleCreateFeedGenerator(ctx, repo, rkey, buf.Bytes(), cc)
211
+
}
+2
-4
docker-compose.yml
+2
-4
docker-compose.yml
···
1
-
version: '3.8'
2
-
3
1
services:
4
2
postgres:
5
3
image: postgres:15-alpine
···
25
23
container_name: konbini-backend
26
24
environment:
27
25
- DATABASE_URL=postgres://konbini:konbini_password@postgres:5432/konbini?sslmode=disable
28
-
- BSKY_HANDLE=${BSKY_HANDLE}
29
-
- BSKY_PASSWORD=${BSKY_PASSWORD}
26
+
- BSKY_HANDLE=${BSKY_HANDLE:?}
27
+
- BSKY_PASSWORD=${BSKY_PASSWORD:?}
30
28
ports:
31
29
- "4444:4444"
32
30
depends_on:
+1
-1
frontend/Dockerfile
+1
-1
frontend/Dockerfile
+1
-1
frontend/public/index.html
+1
-1
frontend/public/index.html
···
24
24
work correctly both with client-side routing and a non-root public URL.
25
25
Learn how to configure a non-root public URL by running `npm run build`.
26
26
-->
27
-
<title>React App</title>
27
+
<title>Konbini</title>
28
28
</head>
29
29
<body>
30
30
<noscript>You need to enable JavaScript to run this app.</noscript>
+27
-1
frontend/src/App.tsx
+27
-1
frontend/src/App.tsx
···
1
-
import React, { useState } from 'react';
1
+
import React, { useState, useEffect } from 'react';
2
2
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
3
3
import { FollowingFeed } from './components/FollowingFeed';
4
4
import { ProfilePage } from './components/ProfilePage';
5
5
import { PostView } from './components/PostView';
6
6
import { ThreadView } from './components/ThreadView';
7
7
import { PostComposer } from './components/PostComposer';
8
+
import { NotificationsPage } from './components/NotificationsPage';
9
+
import { ApiClient } from './api';
8
10
import './App.css';
9
11
10
12
function Navigation() {
11
13
const location = useLocation();
14
+
const [myHandle, setMyHandle] = useState<string | null>(null);
15
+
16
+
useEffect(() => {
17
+
ApiClient.getMe().then(data => {
18
+
setMyHandle(data.handle);
19
+
}).catch(err => {
20
+
console.error('Failed to fetch current user:', err);
21
+
});
22
+
}, []);
12
23
13
24
return (
14
25
<nav className="app-nav">
···
23
34
>
24
35
Following
25
36
</Link>
37
+
<Link
38
+
to="/notifications"
39
+
className={`nav-link ${location.pathname === '/notifications' ? 'active' : ''}`}
40
+
>
41
+
Notifications
42
+
</Link>
43
+
{myHandle && (
44
+
<Link
45
+
to={`/profile/${myHandle}`}
46
+
className={`nav-link ${location.pathname.includes('/profile/') ? 'active' : ''}`}
47
+
>
48
+
Profile
49
+
</Link>
50
+
)}
26
51
</div>
27
52
</div>
28
53
</nav>
···
39
64
<main className="app-main">
40
65
<Routes>
41
66
<Route path="/" element={<FollowingFeed />} />
67
+
<Route path="/notifications" element={<NotificationsPage />} />
42
68
<Route path="/profile/:account" element={<ProfilePage />} />
43
69
<Route path="/profile/:account/post/:rkey" element={<PostView />} />
44
70
<Route path="/thread" element={<ThreadView />} />
+20
-1
frontend/src/api.ts
+20
-1
frontend/src/api.ts
···
1
-
import { PostResponse, ActorProfile, ApiError, ThreadResponse, EngagementResponse, FeedResponse } from './types';
1
+
import { PostResponse, ActorProfile, ApiError, ThreadResponse, EngagementResponse, FeedResponse, NotificationsResponse } from './types';
2
2
3
3
const API_BASE_URL = 'http://localhost:4444/api';
4
4
5
5
export class ApiClient {
6
+
static async getMe(): Promise<{did: string, handle: string}> {
7
+
const response = await fetch(`${API_BASE_URL}/me`);
8
+
if (!response.ok) {
9
+
throw new Error(`Failed to fetch current user: ${response.statusText}`);
10
+
}
11
+
return response.json();
12
+
}
13
+
6
14
static async getFollowingFeed(cursor?: string): Promise<FeedResponse> {
7
15
const url = cursor
8
16
? `${API_BASE_URL}/followingfeed?cursor=${encodeURIComponent(cursor)}`
···
107
115
text: text,
108
116
createdAt: new Date().toISOString(),
109
117
});
118
+
}
119
+
120
+
static async getNotifications(cursor?: string): Promise<NotificationsResponse> {
121
+
const url = cursor
122
+
? `${API_BASE_URL}/notifications?cursor=${encodeURIComponent(cursor)}`
123
+
: `${API_BASE_URL}/notifications`;
124
+
const response = await fetch(url);
125
+
if (!response.ok) {
126
+
throw new Error(`Failed to fetch notifications: ${response.statusText}`);
127
+
}
128
+
return response.json();
110
129
}
111
130
}
+184
frontend/src/components/NotificationsPage.css
+184
frontend/src/components/NotificationsPage.css
···
1
+
.notifications-page {
2
+
max-width: 600px;
3
+
margin: 0 auto;
4
+
background: white;
5
+
min-height: 100vh;
6
+
}
7
+
8
+
.notifications-header {
9
+
padding: 16px 20px;
10
+
border-bottom: 1px solid #e1e8ed;
11
+
background: white;
12
+
position: sticky;
13
+
top: 0;
14
+
z-index: 10;
15
+
}
16
+
17
+
.notifications-header h1 {
18
+
margin: 0;
19
+
font-size: 20px;
20
+
font-weight: 700;
21
+
color: #0f1419;
22
+
}
23
+
24
+
.notifications-list {
25
+
padding-bottom: 20px;
26
+
}
27
+
28
+
.notification {
29
+
border-bottom: 1px solid #e1e8ed;
30
+
transition: background-color 0.2s;
31
+
}
32
+
33
+
.notification:hover {
34
+
background-color: #f7f9fa;
35
+
}
36
+
37
+
.notification-link,
38
+
.notification-inner {
39
+
display: flex;
40
+
padding: 16px 20px;
41
+
text-decoration: none;
42
+
color: inherit;
43
+
gap: 12px;
44
+
}
45
+
46
+
.notification-link:hover {
47
+
text-decoration: none;
48
+
}
49
+
50
+
.notification-icon {
51
+
font-size: 24px;
52
+
flex-shrink: 0;
53
+
width: 32px;
54
+
height: 32px;
55
+
display: flex;
56
+
align-items: center;
57
+
justify-content: center;
58
+
}
59
+
60
+
.notification-like .notification-icon {
61
+
color: #e0245e;
62
+
}
63
+
64
+
.notification-reply .notification-icon {
65
+
color: #1da1f2;
66
+
}
67
+
68
+
.notification-repost .notification-icon {
69
+
color: #17bf63;
70
+
}
71
+
72
+
.notification-mention .notification-icon {
73
+
color: #794bc4;
74
+
}
75
+
76
+
.notification-content {
77
+
flex: 1;
78
+
min-width: 0;
79
+
}
80
+
81
+
.notification-author {
82
+
display: flex;
83
+
align-items: flex-start;
84
+
gap: 8px;
85
+
margin-bottom: 8px;
86
+
}
87
+
88
+
.notification-avatar {
89
+
width: 32px;
90
+
height: 32px;
91
+
border-radius: 50%;
92
+
object-fit: cover;
93
+
flex-shrink: 0;
94
+
}
95
+
96
+
.notification-text {
97
+
flex: 1;
98
+
font-size: 15px;
99
+
line-height: 1.4;
100
+
}
101
+
102
+
.notification-author-name {
103
+
font-weight: 600;
104
+
color: #0f1419;
105
+
text-decoration: none;
106
+
}
107
+
108
+
.notification-author-name:hover {
109
+
text-decoration: underline;
110
+
}
111
+
112
+
.notification-action {
113
+
color: #536471;
114
+
}
115
+
116
+
.notification-preview {
117
+
padding: 12px;
118
+
margin-top: 8px;
119
+
background-color: #f7f9fa;
120
+
border-radius: 8px;
121
+
font-size: 14px;
122
+
color: #0f1419;
123
+
line-height: 1.4;
124
+
white-space: pre-wrap;
125
+
overflow: hidden;
126
+
}
127
+
128
+
.notification-time {
129
+
font-size: 13px;
130
+
color: #657786;
131
+
margin-top: 4px;
132
+
}
133
+
134
+
.loading {
135
+
text-align: center;
136
+
padding: 40px;
137
+
font-size: 16px;
138
+
color: #536471;
139
+
}
140
+
141
+
.error {
142
+
text-align: center;
143
+
padding: 40px;
144
+
margin: 20px;
145
+
font-size: 16px;
146
+
color: #d63939;
147
+
background-color: #fef2f2;
148
+
border: 1px solid #fecaca;
149
+
border-radius: 8px;
150
+
}
151
+
152
+
.empty-notifications {
153
+
text-align: center;
154
+
padding: 60px 20px;
155
+
color: #536471;
156
+
}
157
+
158
+
.empty-notifications p {
159
+
margin: 0;
160
+
font-size: 16px;
161
+
}
162
+
163
+
.load-more-trigger {
164
+
min-height: 20px;
165
+
padding: 20px 0;
166
+
}
167
+
168
+
.loading-more {
169
+
text-align: center;
170
+
padding: 20px;
171
+
color: #536471;
172
+
font-size: 14px;
173
+
}
174
+
175
+
.end-of-notifications {
176
+
text-align: center;
177
+
padding: 40px 20px;
178
+
color: #657786;
179
+
font-size: 14px;
180
+
}
181
+
182
+
.end-of-notifications p {
183
+
margin: 0;
184
+
}
+208
frontend/src/components/NotificationsPage.tsx
+208
frontend/src/components/NotificationsPage.tsx
···
1
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+
import { Notification } from '../types';
3
+
import { ApiClient } from '../api';
4
+
import { formatRelativeTime, getBlobUrl, getPostUrl } from '../utils';
5
+
import { Link } from 'react-router-dom';
6
+
import './NotificationsPage.css';
7
+
8
+
export const NotificationsPage: React.FC = () => {
9
+
const [notifications, setNotifications] = useState<Notification[]>([]);
10
+
const [loading, setLoading] = useState(true);
11
+
const [loadingMore, setLoadingMore] = useState(false);
12
+
const [error, setError] = useState<string | null>(null);
13
+
const [cursor, setCursor] = useState<string | null>(null);
14
+
const [hasMore, setHasMore] = useState(true);
15
+
const observerTarget = useRef<HTMLDivElement>(null);
16
+
17
+
useEffect(() => {
18
+
const fetchNotifications = async () => {
19
+
try {
20
+
setLoading(true);
21
+
const data = await ApiClient.getNotifications();
22
+
setNotifications(data.notifications);
23
+
setCursor(data.cursor || null);
24
+
setHasMore(!!(data.cursor && data.notifications.length > 0));
25
+
} catch (err) {
26
+
setError(err instanceof Error ? err.message : 'Failed to load notifications');
27
+
} finally {
28
+
setLoading(false);
29
+
}
30
+
};
31
+
32
+
fetchNotifications();
33
+
}, []);
34
+
35
+
const fetchMoreNotifications = useCallback(async (cursorToUse: string) => {
36
+
if (loadingMore || !hasMore) return;
37
+
38
+
try {
39
+
setLoadingMore(true);
40
+
const data = await ApiClient.getNotifications(cursorToUse);
41
+
setNotifications(prev => [...prev, ...data.notifications]);
42
+
setCursor(data.cursor || null);
43
+
setHasMore(!!(data.cursor && data.notifications.length > 0));
44
+
} catch (err) {
45
+
console.error('Failed to fetch more notifications:', err);
46
+
} finally {
47
+
setLoadingMore(false);
48
+
}
49
+
}, [loadingMore, hasMore]);
50
+
51
+
useEffect(() => {
52
+
const observer = new IntersectionObserver(
53
+
(entries) => {
54
+
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading && cursor) {
55
+
fetchMoreNotifications(cursor);
56
+
}
57
+
},
58
+
{ threshold: 0.1 }
59
+
);
60
+
61
+
const currentTarget = observerTarget.current;
62
+
if (currentTarget) {
63
+
observer.observe(currentTarget);
64
+
}
65
+
66
+
return () => {
67
+
if (currentTarget) {
68
+
observer.unobserve(currentTarget);
69
+
}
70
+
};
71
+
}, [hasMore, loadingMore, loading, cursor, fetchMoreNotifications]);
72
+
73
+
const getNotificationIcon = (kind: string) => {
74
+
switch (kind) {
75
+
case 'like':
76
+
return 'โฅ';
77
+
case 'reply':
78
+
return '๐ฌ';
79
+
case 'repost':
80
+
return '๐';
81
+
case 'mention':
82
+
return '@';
83
+
default:
84
+
return '๐';
85
+
}
86
+
};
87
+
88
+
const getNotificationText = (notif: Notification) => {
89
+
switch (notif.kind) {
90
+
case 'like':
91
+
return 'liked your post';
92
+
case 'reply':
93
+
return 'replied to your post';
94
+
case 'repost':
95
+
return 'reposted your post';
96
+
case 'mention':
97
+
return 'mentioned you in a post';
98
+
default:
99
+
return 'interacted with your post';
100
+
}
101
+
};
102
+
103
+
const getNotificationLink = (notif: Notification) => {
104
+
// For replies and mentions, link to the post
105
+
// For likes and reposts, we could link to the original post but we don't have it easily
106
+
if (notif.kind === 'reply' || notif.kind === 'mention') {
107
+
return getPostUrl(notif.source);
108
+
}
109
+
return null;
110
+
};
111
+
112
+
if (loading) {
113
+
return (
114
+
<div className="notifications-page">
115
+
<div className="notifications-header">
116
+
<h1>Notifications</h1>
117
+
</div>
118
+
<div className="loading">Loading notifications...</div>
119
+
</div>
120
+
);
121
+
}
122
+
123
+
if (error && notifications.length === 0) {
124
+
return (
125
+
<div className="notifications-page">
126
+
<div className="notifications-header">
127
+
<h1>Notifications</h1>
128
+
</div>
129
+
<div className="error">Error: {error}</div>
130
+
</div>
131
+
);
132
+
}
133
+
134
+
return (
135
+
<div className="notifications-page">
136
+
<div className="notifications-header">
137
+
<h1>Notifications</h1>
138
+
</div>
139
+
<div className="notifications-list">
140
+
{notifications.map((notif) => {
141
+
const link = getNotificationLink(notif);
142
+
const content = (
143
+
<>
144
+
<div className="notification-icon">
145
+
{getNotificationIcon(notif.kind)}
146
+
</div>
147
+
<div className="notification-content">
148
+
<div className="notification-author">
149
+
{notif.author.profile?.avatar && (
150
+
<img
151
+
src={getBlobUrl(notif.author.profile.avatar, notif.author.did, 'avatar_thumbnail')}
152
+
alt="Avatar"
153
+
className="notification-avatar"
154
+
/>
155
+
)}
156
+
<div className="notification-text">
157
+
<Link to={`/profile/${notif.author.handle}`} className="notification-author-name">
158
+
{notif.author.profile?.displayName || notif.author.handle}
159
+
</Link>
160
+
{' '}
161
+
<span className="notification-action">{getNotificationText(notif)}</span>
162
+
</div>
163
+
</div>
164
+
{notif.sourcePost && (
165
+
<div className="notification-preview">
166
+
{notif.sourcePost.text}
167
+
</div>
168
+
)}
169
+
<div className="notification-time">
170
+
{formatRelativeTime(notif.createdAt)}
171
+
</div>
172
+
</div>
173
+
</>
174
+
);
175
+
176
+
return (
177
+
<div key={notif.id} className={`notification notification-${notif.kind}`}>
178
+
{link ? (
179
+
<Link to={link} className="notification-link">
180
+
{content}
181
+
</Link>
182
+
) : (
183
+
<div className="notification-inner">
184
+
{content}
185
+
</div>
186
+
)}
187
+
</div>
188
+
);
189
+
})}
190
+
{notifications.length === 0 && !loading && (
191
+
<div className="empty-notifications">
192
+
<p>No notifications yet</p>
193
+
</div>
194
+
)}
195
+
{hasMore && (
196
+
<div ref={observerTarget} className="load-more-trigger">
197
+
{loadingMore && <div className="loading-more">Loading more...</div>}
198
+
</div>
199
+
)}
200
+
{!hasMore && notifications.length > 0 && (
201
+
<div className="end-of-notifications">
202
+
<p>You're all caught up!</p>
203
+
</div>
204
+
)}
205
+
</div>
206
+
</div>
207
+
);
208
+
};
+42
frontend/src/components/PostView.tsx
+42
frontend/src/components/PostView.tsx
···
3
3
import { PostResponse } from '../types';
4
4
import { ApiClient } from '../api';
5
5
import { PostCard } from './PostCard';
6
+
import { EngagementModal } from './EngagementModal';
6
7
import './PostView.css';
7
8
8
9
export const PostView: React.FC = () => {
···
11
12
const [threadPosts, setThreadPosts] = useState<PostResponse[]>([]);
12
13
const [loading, setLoading] = useState(true);
13
14
const [error, setError] = useState<string | null>(null);
15
+
const [showEngagementModal, setShowEngagementModal] = useState<'likes' | 'reposts' | 'replies' | null>(null);
14
16
15
17
useEffect(() => {
16
18
// Scroll to top when navigating to a post
···
96
98
<PostCard postResponse={mainPost} showThreadIndicator={false} />
97
99
</div>
98
100
101
+
{mainPost.counts && (mainPost.counts.likes > 0 || mainPost.counts.reposts > 0 || mainPost.counts.replies > 0) && (
102
+
<div className="post-engagement-detail">
103
+
{mainPost.counts.likes > 0 && (
104
+
<button
105
+
className="engagement-detail-item"
106
+
onClick={() => setShowEngagementModal('likes')}
107
+
>
108
+
<span className="engagement-detail-count">{mainPost.counts.likes}</span>
109
+
<span className="engagement-detail-label">{mainPost.counts.likes === 1 ? 'Like' : 'Likes'}</span>
110
+
</button>
111
+
)}
112
+
{mainPost.counts.reposts > 0 && (
113
+
<button
114
+
className="engagement-detail-item"
115
+
onClick={() => setShowEngagementModal('reposts')}
116
+
>
117
+
<span className="engagement-detail-count">{mainPost.counts.reposts}</span>
118
+
<span className="engagement-detail-label">{mainPost.counts.reposts === 1 ? 'Repost' : 'Reposts'}</span>
119
+
</button>
120
+
)}
121
+
{mainPost.counts.replies > 0 && (
122
+
<button
123
+
className="engagement-detail-item"
124
+
onClick={() => setShowEngagementModal('replies')}
125
+
>
126
+
<span className="engagement-detail-count">{mainPost.counts.replies}</span>
127
+
<span className="engagement-detail-label">{mainPost.counts.replies === 1 ? 'Reply' : 'Replies'}</span>
128
+
</button>
129
+
)}
130
+
</div>
131
+
)}
132
+
99
133
{threadPosts.length > 0 && (
100
134
<div className="thread-replies">
101
135
<div className="replies-header">
···
109
143
</div>
110
144
)}
111
145
</div>
146
+
147
+
{showEngagementModal && (
148
+
<EngagementModal
149
+
postId={mainPost.id}
150
+
type={showEngagementModal}
151
+
onClose={() => setShowEngagementModal(null)}
152
+
/>
153
+
)}
112
154
</div>
113
155
);
114
156
};
+51
frontend/src/components/ProfilePage.css
+51
frontend/src/components/ProfilePage.css
···
147
147
padding: 0 20px;
148
148
}
149
149
150
+
.profile-tabs {
151
+
display: flex;
152
+
border-bottom: 1px solid #e1e8ed;
153
+
margin-bottom: 16px;
154
+
}
155
+
156
+
.profile-tab {
157
+
flex: 1;
158
+
padding: 16px;
159
+
background: none;
160
+
border: none;
161
+
border-bottom: 2px solid transparent;
162
+
font-size: 15px;
163
+
font-weight: 600;
164
+
color: #536471;
165
+
cursor: pointer;
166
+
transition: all 0.2s;
167
+
}
168
+
169
+
.profile-tab:hover {
170
+
background-color: #f7f9fa;
171
+
}
172
+
173
+
.profile-tab--active {
174
+
color: #1da1f2;
175
+
border-bottom-color: #1da1f2;
176
+
}
177
+
150
178
.posts-header {
151
179
padding: 16px 0;
152
180
border-bottom: 1px solid #e1e8ed;
···
191
219
.empty-posts p {
192
220
margin: 0;
193
221
font-size: 16px;
222
+
}
223
+
224
+
.load-more-trigger {
225
+
min-height: 20px;
226
+
padding: 20px 0;
227
+
}
228
+
229
+
.loading-more {
230
+
text-align: center;
231
+
padding: 20px;
232
+
color: #536471;
233
+
font-size: 14px;
234
+
}
235
+
236
+
.end-of-feed {
237
+
text-align: center;
238
+
padding: 40px 20px;
239
+
color: #657786;
240
+
font-size: 14px;
241
+
}
242
+
243
+
.end-of-feed p {
244
+
margin: 0;
194
245
}
195
246
196
247
@media (max-width: 600px) {
+42
-23
frontend/src/components/ProfilePage.tsx
+42
-23
frontend/src/components/ProfilePage.tsx
···
1
-
import React, { useState, useEffect, useRef } from 'react';
1
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
import { useParams } from 'react-router-dom';
3
3
import { ActorProfile, PostResponse } from '../types';
4
4
import { ApiClient } from '../api';
···
16
16
const [userDid, setUserDid] = useState<string | null>(null);
17
17
const [cursor, setCursor] = useState<string | null>(null);
18
18
const [hasMore, setHasMore] = useState(true);
19
+
const [activeTab, setActiveTab] = useState<'posts' | 'replies'>('posts');
19
20
const observerTarget = useRef<HTMLDivElement>(null);
20
21
21
22
useEffect(() => {
···
66
67
fetchProfile();
67
68
}, [account]);
68
69
69
-
const fetchMorePosts = async (cursor: string) => {
70
+
const fetchMorePosts = useCallback(async (cursorToUse: string) => {
70
71
if (!account || loadingMore || !hasMore) return;
71
72
72
73
try {
73
74
setLoadingMore(true);
74
-
const data = await ApiClient.getProfilePosts(account, cursor);
75
+
const data = await ApiClient.getProfilePosts(account, cursorToUse);
75
76
setPosts(prev => [...prev, ...data.posts]);
76
77
setCursor(data.cursor || null);
77
78
setHasMore(!!(data.cursor && data.posts.length > 0));
···
80
81
} finally {
81
82
setLoadingMore(false);
82
83
}
83
-
};
84
+
}, [account, loadingMore, hasMore]);
84
85
85
86
useEffect(() => {
86
87
const observer = new IntersectionObserver(
87
88
(entries) => {
88
-
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
89
-
if (cursor) {
90
-
fetchMorePosts(cursor);
91
-
}
89
+
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading && cursor) {
90
+
fetchMorePosts(cursor);
92
91
}
93
92
},
94
93
{ threshold: 0.1 }
95
94
);
96
95
97
-
if (observerTarget.current) {
98
-
observer.observe(observerTarget.current);
96
+
const currentTarget = observerTarget.current;
97
+
if (currentTarget) {
98
+
observer.observe(currentTarget);
99
99
}
100
100
101
101
return () => {
102
-
if (observerTarget.current) {
103
-
observer.unobserve(observerTarget.current);
102
+
if (currentTarget) {
103
+
observer.unobserve(currentTarget);
104
104
}
105
105
};
106
-
}, [hasMore, loadingMore, loading, cursor]);
106
+
}, [hasMore, loadingMore, loading, cursor, fetchMorePosts]);
107
107
108
108
if (loading) {
109
109
return (
···
189
189
</div>
190
190
191
191
<div className="profile-content">
192
-
<div className="posts-header">
193
-
<h2>Posts ({posts.length})</h2>
192
+
<div className="profile-tabs">
193
+
<button
194
+
className={`profile-tab ${activeTab === 'posts' ? 'profile-tab--active' : ''}`}
195
+
onClick={() => setActiveTab('posts')}
196
+
>
197
+
Posts
198
+
</button>
199
+
<button
200
+
className={`profile-tab ${activeTab === 'replies' ? 'profile-tab--active' : ''}`}
201
+
onClick={() => setActiveTab('replies')}
202
+
>
203
+
Replies
204
+
</button>
194
205
</div>
195
206
196
207
<div className="posts-list">
197
-
{posts.map((post, index) => (
198
-
<PostCard key={post.uri || index} postResponse={post} />
199
-
))}
200
-
{posts.length === 0 && !loading && (
208
+
{posts
209
+
.filter(post => activeTab === 'posts' ? !post.replyTo : !!post.replyTo)
210
+
.map((post, index) => (
211
+
<PostCard key={post.uri || index} postResponse={post} />
212
+
))}
213
+
{posts.filter(post => activeTab === 'posts' ? !post.replyTo : !!post.replyTo).length === 0 && !loading && (
201
214
<div className="empty-posts">
202
-
<p>No posts yet</p>
215
+
<p>{activeTab === 'posts' ? 'No posts yet' : 'No replies yet'}</p>
203
216
</div>
204
217
)}
205
-
{hasMore && <div ref={observerTarget} style={{ height: '20px' }} />}
206
-
{loadingMore && (
207
-
<div className="loading-more">Loading more posts...</div>
218
+
{hasMore && (
219
+
<div ref={observerTarget} className="load-more-trigger">
220
+
{loadingMore && <div className="loading-more">Loading more posts...</div>}
221
+
</div>
222
+
)}
223
+
{!hasMore && posts.length > 0 && (
224
+
<div className="end-of-feed">
225
+
<p>You've reached the end!</p>
226
+
</div>
208
227
)}
209
228
</div>
210
229
</div>
+17
frontend/src/types.ts
+17
frontend/src/types.ts
···
138
138
export interface FeedResponse {
139
139
posts: PostResponse[];
140
140
cursor: string;
141
+
}
142
+
143
+
export interface Notification {
144
+
id: number;
145
+
kind: 'reply' | 'like' | 'mention' | 'repost';
146
+
author: AuthorInfo;
147
+
source: string;
148
+
sourcePost?: {
149
+
text: string;
150
+
uri: string;
151
+
};
152
+
createdAt: string;
153
+
}
154
+
155
+
export interface NotificationsResponse {
156
+
notifications: Notification[];
157
+
cursor: string;
141
158
}
+19
-9
go.mod
+19
-9
go.mod
···
3
3
go 1.25.1
4
4
5
5
require (
6
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f
6
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
7
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1
7
8
github.com/gorilla/websocket v1.5.1
8
9
github.com/hashicorp/golang-lru/v2 v2.0.7
9
10
github.com/ipfs/go-cid v0.4.1
10
11
github.com/jackc/pgx/v5 v5.6.0
11
12
github.com/labstack/echo/v4 v4.11.3
13
+
github.com/labstack/gommon v0.4.1
14
+
github.com/lestrrat-go/jwx/v2 v2.0.12
15
+
github.com/multiformats/go-multihash v0.2.3
12
16
github.com/prometheus/client_golang v1.19.1
13
17
github.com/urfave/cli/v2 v2.27.7
18
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
14
19
github.com/whyrusleeping/market v0.0.0-20250711215409-cc684a207f15
20
+
go.opentelemetry.io/otel v1.34.0
21
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0
22
+
go.opentelemetry.io/otel/sdk v1.34.0
15
23
gorm.io/gorm v1.31.0
16
24
)
17
25
···
23
31
github.com/cespare/xxhash/v2 v2.3.0 // indirect
24
32
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
25
33
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
34
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
26
35
github.com/felixge/httpsnoop v1.0.4 // indirect
27
36
github.com/go-logr/logr v1.4.2 // indirect
28
37
github.com/go-logr/stdr v1.2.2 // indirect
38
+
github.com/go-redis/cache/v9 v9.0.0 // indirect
29
39
github.com/goccy/go-json v0.10.5 // indirect
30
40
github.com/gogo/protobuf v1.3.2 // indirect
31
41
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
···
51
61
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
52
62
github.com/ipfs/go-peertaskqueue v0.8.1 // indirect
53
63
github.com/ipfs/go-verifcid v0.0.3 // indirect
54
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 // indirect
64
+
github.com/ipld/go-car v0.6.2 // indirect
55
65
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
56
66
github.com/ipld/go-ipld-prime v0.21.0 // indirect
57
67
github.com/jackc/pgpassfile v1.0.0 // indirect
···
60
70
github.com/jbenet/goprocess v0.1.4 // indirect
61
71
github.com/jinzhu/inflection v1.0.0 // indirect
62
72
github.com/jinzhu/now v1.1.5 // indirect
73
+
github.com/klauspost/compress v1.17.9 // indirect
63
74
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
64
-
github.com/labstack/gommon v0.4.1 // indirect
65
75
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
66
76
github.com/lestrrat-go/httpcc v1.0.1 // indirect
67
77
github.com/lestrrat-go/httprc v1.0.4 // indirect
68
78
github.com/lestrrat-go/iter v1.0.2 // indirect
69
-
github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect
70
79
github.com/lestrrat-go/option v1.0.1 // indirect
71
80
github.com/libp2p/go-libp2p v0.25.1 // indirect
72
81
github.com/mattn/go-colorable v0.1.13 // indirect
···
78
87
github.com/multiformats/go-base36 v0.2.0 // indirect
79
88
github.com/multiformats/go-multiaddr v0.8.0 // indirect
80
89
github.com/multiformats/go-multibase v0.2.0 // indirect
81
-
github.com/multiformats/go-multihash v0.2.3 // indirect
82
90
github.com/multiformats/go-varint v0.0.7 // indirect
83
91
github.com/opentracing/opentracing-go v1.2.0 // indirect
84
92
github.com/orandin/slog-gorm v1.3.2 // indirect
85
93
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
86
94
github.com/prometheus/client_model v0.6.1 // indirect
87
-
github.com/prometheus/common v0.48.0 // indirect
88
-
github.com/prometheus/procfs v0.12.0 // indirect
95
+
github.com/prometheus/common v0.54.0 // indirect
96
+
github.com/prometheus/procfs v0.15.1 // indirect
97
+
github.com/redis/go-redis/v9 v9.3.0 // indirect
89
98
github.com/russross/blackfriday/v2 v2.1.0 // indirect
90
99
github.com/segmentio/asm v1.2.0 // indirect
91
100
github.com/spaolacci/murmur3 v1.1.0 // indirect
92
101
github.com/valyala/bytebufferpool v1.0.0 // indirect
93
102
github.com/valyala/fasttemplate v1.2.2 // indirect
94
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
103
+
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
104
+
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
105
+
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
95
106
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
96
107
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
97
108
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
98
109
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
99
110
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
100
111
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
101
-
go.opentelemetry.io/otel v1.34.0 // indirect
102
112
go.opentelemetry.io/otel/metric v1.34.0 // indirect
103
113
go.opentelemetry.io/otel/trace v1.34.0 // indirect
104
114
go.uber.org/atomic v1.11.0 // indirect
+155
-8
go.sum
+155
-8
go.sum
···
6
6
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
7
7
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
8
8
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
9
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f h1:FugOoTzh0nCMTWGqNGsjttFWVPcwxaaGD3p/nE9V8qY=
10
-
github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
9
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
10
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
11
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1 h1:ovcRKN1iXZnY5WApVg+0Hw2RkwMH0ziA7lSAA8vellU=
12
+
github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1/go.mod h1:5PtGi4r/PjEVBBl+0xWuQn4mBEjr9h6xsfDBADS6cHs=
11
13
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
12
14
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
15
+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
16
+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
17
+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
18
+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
13
19
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
14
20
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
21
+
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
22
+
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15
23
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
16
24
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
25
+
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
26
+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
27
+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
17
28
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
18
29
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
19
30
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
31
+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
20
32
github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0=
21
33
github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis=
22
34
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
···
25
37
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
26
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
27
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
40
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
41
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
28
42
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
29
43
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
30
44
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
31
45
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
46
+
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
47
+
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
48
+
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
49
+
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
32
50
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
51
+
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
33
52
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
34
53
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
35
54
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
36
55
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
56
+
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
57
+
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
37
58
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
38
59
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
60
+
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
39
61
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
40
62
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
41
63
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
···
44
66
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
45
67
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
46
68
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
69
+
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
70
+
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
71
+
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
72
+
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
73
+
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
74
+
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
75
+
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
76
+
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
77
+
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
78
+
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
79
+
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
80
+
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
81
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
82
+
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
83
+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
47
84
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
48
85
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
49
86
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
50
87
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
88
+
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
51
89
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
52
90
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
53
91
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
···
67
105
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
68
106
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
69
107
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
108
+
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
70
109
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
71
110
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
111
+
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
72
112
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
73
113
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
74
114
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
···
120
160
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
121
161
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
122
162
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
123
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
124
-
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4/go.mod h1:6nkFF8OmR5wLKBzRKi7/YFJpyYR7+oEn1DX+mMWnlLA=
163
+
github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc=
164
+
github.com/ipld/go-car v0.6.2/go.mod h1:oEGXdwp6bmxJCZ+rARSkDliTeYnVzv3++eXajZ+Bmr8=
125
165
github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4=
126
166
github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo=
127
167
github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc=
···
149
189
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
150
190
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
151
191
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
192
+
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
193
+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
194
+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
152
195
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
153
196
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
154
197
github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8=
155
198
github.com/koron/go-ssdp v0.0.3/go.mod h1:b2MxI6yh02pKrsyNoQUsk4+YNikaGhe4894J+Q5lDvA=
156
199
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
200
+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
157
201
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
158
202
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
159
203
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
···
229
273
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
230
274
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
231
275
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
276
+
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
277
+
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
278
+
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
279
+
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
280
+
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
281
+
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
282
+
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
283
+
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
284
+
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
285
+
github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU=
286
+
github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk=
287
+
github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0=
288
+
github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo=
289
+
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
290
+
github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo=
291
+
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
292
+
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
293
+
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
294
+
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
295
+
github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo=
296
+
github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc=
297
+
github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM=
298
+
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
299
+
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
300
+
github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y=
301
+
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
232
302
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
233
303
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
234
304
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
···
244
314
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
245
315
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
246
316
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
247
-
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
248
-
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
249
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
250
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
317
+
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
318
+
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
319
+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
320
+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
321
+
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
322
+
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
323
+
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
251
324
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
325
+
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
252
326
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
253
327
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
254
328
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
···
265
339
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
266
340
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
267
341
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
342
+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
268
343
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
269
344
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
270
345
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
271
346
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
347
+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
272
348
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
273
349
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
274
350
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
275
351
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
352
+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
276
353
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
277
354
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
278
355
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
···
283
360
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
284
361
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
285
362
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
363
+
github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI=
364
+
github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q=
365
+
github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
366
+
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
367
+
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
368
+
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
369
+
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
286
370
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
287
371
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
288
372
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
···
300
384
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
301
385
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
302
386
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
387
+
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
303
388
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
304
389
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
305
390
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
···
311
396
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
312
397
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
313
398
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
399
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
400
+
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
314
401
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
315
402
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
403
+
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
404
+
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
316
405
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
317
406
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
318
407
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
···
336
425
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
337
426
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
338
427
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
428
+
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
339
429
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
340
430
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
341
431
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
···
346
436
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
347
437
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
348
438
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
439
+
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
349
440
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
441
+
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
442
+
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
350
443
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
351
444
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
352
445
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
446
+
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
353
447
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
354
448
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
355
449
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
356
450
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
451
+
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
357
452
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
358
453
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
359
454
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
455
+
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
456
+
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
457
+
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
458
+
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
360
459
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
460
+
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
461
+
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
462
+
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
463
+
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
361
464
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
362
465
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
363
466
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
364
467
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
468
+
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
365
469
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
366
470
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
367
471
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
370
474
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
371
475
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
372
476
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
477
+
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
373
478
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
374
479
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
480
+
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
481
+
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
482
+
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
483
+
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
484
+
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
375
485
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
376
486
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
487
+
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
377
488
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
489
+
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
378
490
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
379
491
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
380
492
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
493
+
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
494
+
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
495
+
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
381
496
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
497
+
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
382
498
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
383
499
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
384
500
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
501
+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
502
+
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
503
+
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
504
+
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
385
505
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
386
506
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
387
507
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
390
510
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
391
511
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
392
512
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
513
+
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
514
+
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
515
+
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
516
+
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
393
517
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
394
518
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
395
519
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
396
520
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
397
521
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
522
+
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
398
523
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
524
+
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
525
+
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
526
+
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
399
527
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
400
528
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
401
529
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
···
411
539
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
412
540
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
413
541
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
542
+
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
414
543
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
415
544
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
545
+
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
416
546
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
547
+
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
548
+
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
417
549
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
418
550
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
419
551
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
···
423
555
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
424
556
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
425
557
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
558
+
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
559
+
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
560
+
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
561
+
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
562
+
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
563
+
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
564
+
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
565
+
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
566
+
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
426
567
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
427
568
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
428
569
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
429
570
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
571
+
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
430
572
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
431
573
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
432
574
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
575
+
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
576
+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
577
+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
433
578
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
579
+
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
434
580
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
581
+
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
435
582
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
436
583
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
437
584
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+277
-79
handlers.go
+277
-79
handlers.go
···
11
11
12
12
"github.com/bluesky-social/indigo/api/bsky"
13
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"github.com/bluesky-social/indigo/xrpc"
14
+
xrpclib "github.com/bluesky-social/indigo/xrpc"
15
15
"github.com/labstack/echo/v4"
16
16
"github.com/labstack/echo/v4/middleware"
17
17
"github.com/labstack/gommon/log"
18
18
"github.com/whyrusleeping/market/models"
19
+
20
+
"github.com/whyrusleeping/konbini/backend"
21
+
. "github.com/whyrusleeping/konbini/models"
19
22
)
20
23
21
24
func (s *Server) runApiServer() error {
···
23
26
e := echo.New()
24
27
e.Use(middleware.CORS())
25
28
e.GET("/debug", s.handleGetDebugInfo)
29
+
e.GET("/reldids", s.handleGetRelevantDids)
30
+
e.GET("/rescan/:did", s.handleRescanDid)
26
31
27
32
views := e.Group("/api")
33
+
views.GET("/me", s.handleGetMe)
34
+
views.GET("/notifications", s.handleGetNotifications)
28
35
views.GET("/profile/:account/post/:rkey", s.handleGetPost)
29
36
views.GET("/profile/:account", s.handleGetProfileView)
30
37
views.GET("/profile/:account/posts", s.handleGetProfilePosts)
···
48
55
})
49
56
}
50
57
58
+
func (s *Server) handleGetRelevantDids(e echo.Context) error {
59
+
return e.JSON(200, map[string]any{
60
+
"dids": s.backend.GetRelevantDids(),
61
+
})
62
+
}
63
+
64
+
func (s *Server) handleRescanDid(e echo.Context) error {
65
+
didparam := e.Param("did")
66
+
67
+
ctx := e.Request().Context()
68
+
did, err := s.resolveAccountIdent(ctx, didparam)
69
+
if err != nil {
70
+
return err
71
+
}
72
+
73
+
if err := s.rescanRepo(ctx, did); err != nil {
74
+
return err
75
+
}
76
+
77
+
return e.JSON(200, map[string]any{"status": "ok"})
78
+
}
79
+
80
+
func (s *Server) handleGetMe(e echo.Context) error {
81
+
ctx := e.Request().Context()
82
+
83
+
resp, err := s.dir.LookupDID(ctx, syntax.DID(s.mydid))
84
+
if err != nil {
85
+
return e.JSON(500, map[string]any{
86
+
"error": "failed to lookup handle",
87
+
})
88
+
}
89
+
90
+
return e.JSON(200, map[string]any{
91
+
"did": s.mydid,
92
+
"handle": resp.Handle.String(),
93
+
})
94
+
}
95
+
51
96
func (s *Server) handleGetPost(e echo.Context) error {
52
97
ctx := e.Request().Context()
53
98
···
61
106
62
107
postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
63
108
64
-
p, err := s.backend.getPostByUri(ctx, postUri, "*")
109
+
p, err := s.backend.GetPostByUri(ctx, postUri, "*")
65
110
if err != nil {
66
111
return err
67
112
}
···
90
135
return err
91
136
}
92
137
93
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
138
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
94
139
if err != nil {
95
140
return err
96
141
}
97
142
98
143
var profile models.Profile
99
-
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 {
100
145
return err
101
146
}
102
147
103
148
if profile.Raw == nil || len(profile.Raw) == 0 {
104
-
s.addMissingProfile(ctx, accdid)
149
+
s.backend.TrackMissingRecord(accdid, false)
105
150
return e.JSON(404, map[string]any{
106
151
"error": "missing profile info for user",
107
152
})
···
125
170
return err
126
171
}
127
172
128
-
r, err := s.backend.getOrCreateRepo(ctx, accdid)
173
+
r, err := s.backend.GetOrCreateRepo(ctx, accdid)
129
174
if err != nil {
130
175
return err
131
176
}
132
177
133
178
// Get cursor from query parameter (timestamp in RFC3339 format)
134
179
cursor := e.QueryParam("cursor")
135
-
limit := 20
180
+
limit := 50
136
181
137
182
tcursor := time.Now()
138
183
if cursor != "" {
···
144
189
}
145
190
146
191
var dbposts []models.Post
147
-
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 {
148
193
return err
149
194
}
150
195
···
214
259
func (s *Server) handleGetFollowingFeed(e echo.Context) error {
215
260
ctx := e.Request().Context()
216
261
217
-
myr, err := s.backend.getOrCreateRepo(ctx, s.mydid)
262
+
myr, err := s.backend.GetOrCreateRepo(ctx, s.mydid)
218
263
if err != nil {
219
264
return err
220
265
}
···
232
277
tcursor = t
233
278
}
234
279
var dbposts []models.Post
235
-
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 {
236
281
return err
237
282
}
238
283
···
252
297
253
298
func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) {
254
299
var profile models.Profile
255
-
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 {
256
301
return nil, err
257
302
}
258
303
···
262
307
}
263
308
264
309
if profile.Raw == nil || len(profile.Raw) == 0 {
265
-
s.addMissingProfile(ctx, r.Did)
310
+
s.backend.TrackMissingRecord(r.Did, false)
266
311
return &authorInfo{
267
312
Handle: resp.Handle.String(),
268
313
Did: r.Did,
···
289
334
290
335
go func() {
291
336
defer wg.Done()
292
-
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 {
293
338
slog.Error("failed to get likes count", "post", pid, "error", err)
294
339
}
295
340
}()
296
341
297
342
go func() {
298
343
defer wg.Done()
299
-
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 {
300
345
slog.Error("failed to get reposts count", "post", pid, "error", err)
301
346
}
302
347
}()
303
348
304
349
go func() {
305
350
defer wg.Done()
306
-
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 {
307
352
slog.Error("failed to get replies count", "post", pid, "error", err)
308
353
}
309
354
}()
···
322
367
go func(ix int) {
323
368
defer wg.Done()
324
369
p := dbposts[ix]
325
-
r, err := s.backend.getRepoByID(ctx, p.Author)
370
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
326
371
if err != nil {
327
372
fmt.Println("failed to get repo: ", err)
328
373
posts[ix] = postResponse{
···
334
379
335
380
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey)
336
381
if len(p.Raw) == 0 || p.NotFound {
382
+
s.backend.TrackMissingRecord(uri, false)
337
383
posts[ix] = postResponse{
338
384
Uri: uri,
339
385
Missing: true,
···
389
435
390
436
func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike {
391
437
var like Like
392
-
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 {
393
439
slog.Error("failed to lookup like", "error", err)
394
440
return nil
395
441
}
···
418
464
view.Langs = fp.Langs
419
465
}
420
466
421
-
// Hydrate embed if present
422
467
if fp.Embed != nil {
423
-
slog.Info("processing embed", "hasImages", fp.Embed.EmbedImages != nil, "hasExternal", fp.Embed.EmbedExternal != nil, "hasRecord", fp.Embed.EmbedRecord != nil)
424
-
if fp.Embed.EmbedImages != nil {
425
-
view.Embed = fp.Embed.EmbedImages
426
-
} else if fp.Embed.EmbedExternal != nil {
427
-
view.Embed = fp.Embed.EmbedExternal
428
-
} else if fp.Embed.EmbedRecord != nil {
429
-
// Hydrate quoted post
430
-
quotedURI := fp.Embed.EmbedRecord.Record.Uri
431
-
quotedCid := fp.Embed.EmbedRecord.Record.Cid
432
-
slog.Info("hydrating quoted post", "uri", quotedURI, "cid", quotedCid)
468
+
view.Embed = s.hydrateEmbed(ctx, fp.Embed)
469
+
}
470
+
471
+
return view
472
+
}
473
+
474
+
func (s *Server) hydrateEmbed(ctx context.Context, embed *bsky.FeedPost_Embed) interface{} {
475
+
switch {
476
+
case embed.EmbedImages != nil:
477
+
return embed.EmbedImages
478
+
case embed.EmbedExternal != nil:
479
+
return embed.EmbedExternal
480
+
case embed.EmbedRecord != nil:
481
+
return s.hydrateQuotedPost(ctx, embed.EmbedRecord)
482
+
case embed.EmbedRecordWithMedia != nil:
483
+
return s.hydrateRecordWithMedia(ctx, embed.EmbedRecordWithMedia)
484
+
default:
485
+
return nil
486
+
}
487
+
}
433
488
434
-
quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*")
435
-
if err != nil {
436
-
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
437
-
}
438
-
if err == nil && quotedPost != nil && quotedPost.Raw != nil && len(quotedPost.Raw) > 0 && !quotedPost.NotFound {
439
-
slog.Info("found quoted post, hydrating")
440
-
var quotedFP bsky.FeedPost
441
-
if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err == nil {
442
-
quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author)
443
-
if err == nil {
444
-
quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo)
445
-
if err == nil {
446
-
view.Embed = map[string]interface{}{
447
-
"$type": "app.bsky.embed.record",
448
-
"record": &embedRecordView{
449
-
Type: "app.bsky.embed.record#viewRecord",
450
-
Uri: quotedURI,
451
-
Cid: quotedCid,
452
-
Author: quotedAuthor,
453
-
Value: "edFP,
454
-
},
455
-
}
456
-
}
457
-
}
458
-
}
459
-
}
489
+
func (s *Server) hydrateRecordWithMedia(ctx context.Context, rwm *bsky.EmbedRecordWithMedia) interface{} {
490
+
result := map[string]interface{}{
491
+
"$type": "app.bsky.embed.recordWithMedia",
492
+
}
460
493
461
-
// Fallback if hydration failed - show basic info
462
-
if view.Embed == nil {
463
-
slog.Info("quoted post not in database, using fallback")
464
-
view.Embed = map[string]interface{}{
465
-
"$type": "app.bsky.embed.record",
466
-
"record": map[string]interface{}{
467
-
"uri": quotedURI,
468
-
"cid": quotedCid,
469
-
},
470
-
}
471
-
}
494
+
// Hydrate media
495
+
if rwm.Media != nil {
496
+
if rwm.Media.EmbedImages != nil {
497
+
result["media"] = rwm.Media.EmbedImages
498
+
} else if rwm.Media.EmbedExternal != nil {
499
+
result["media"] = rwm.Media.EmbedExternal
472
500
}
473
501
}
474
502
475
-
return view
503
+
// Hydrate record
504
+
if rwm.Record != nil {
505
+
result["record"] = s.hydrateQuotedPost(ctx, rwm.Record)
506
+
}
507
+
508
+
return result
509
+
}
510
+
511
+
func (s *Server) hydrateQuotedPost(ctx context.Context, embedRecord *bsky.EmbedRecord) interface{} {
512
+
quotedURI := embedRecord.Record.Uri
513
+
quotedCid := embedRecord.Record.Cid
514
+
515
+
quotedPost, err := s.backend.GetPostByUri(ctx, quotedURI, "*")
516
+
if err != nil {
517
+
slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err)
518
+
s.backend.TrackMissingRecord(quotedURI, false)
519
+
return s.buildQuoteFallback(quotedURI, quotedCid)
520
+
}
521
+
522
+
if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound {
523
+
s.backend.TrackMissingRecord(quotedURI, false)
524
+
return s.buildQuoteFallback(quotedURI, quotedCid)
525
+
}
526
+
527
+
var quotedFP bsky.FeedPost
528
+
if err := quotedFP.UnmarshalCBOR(bytes.NewReader(quotedPost.Raw)); err != nil {
529
+
slog.Warn("failed to unmarshal quoted post", "error", err)
530
+
return s.buildQuoteFallback(quotedURI, quotedCid)
531
+
}
532
+
533
+
quotedRepo, err := s.backend.GetRepoByID(ctx, quotedPost.Author)
534
+
if err != nil {
535
+
slog.Warn("failed to get quoted post author", "error", err)
536
+
return s.buildQuoteFallback(quotedURI, quotedCid)
537
+
}
538
+
539
+
quotedAuthor, err := s.getAuthorInfo(ctx, quotedRepo)
540
+
if err != nil {
541
+
slog.Warn("failed to get quoted post author info", "error", err)
542
+
return s.buildQuoteFallback(quotedURI, quotedCid)
543
+
}
544
+
545
+
return map[string]interface{}{
546
+
"$type": "app.bsky.embed.record",
547
+
"record": &embedRecordView{
548
+
Type: "app.bsky.embed.record#viewRecord",
549
+
Uri: quotedURI,
550
+
Cid: quotedCid,
551
+
Author: quotedAuthor,
552
+
Value: "edFP,
553
+
},
554
+
}
555
+
}
556
+
557
+
func (s *Server) buildQuoteFallback(uri, cid string) map[string]interface{} {
558
+
return map[string]interface{}{
559
+
"$type": "app.bsky.embed.record",
560
+
"record": map[string]interface{}{
561
+
"uri": uri,
562
+
"cid": cid,
563
+
},
564
+
}
476
565
}
477
566
478
567
func (s *Server) handleGetThread(e echo.Context) error {
···
488
577
489
578
// Get the requested post to find the thread root
490
579
var requestedPost models.Post
491
-
if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
580
+
if err := s.db.Find(&requestedPost, "id = ?", postID).Error; err != nil {
492
581
return err
493
582
}
494
583
···
507
596
// Get all posts in this thread
508
597
var dbposts []models.Post
509
598
query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC"
510
-
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 {
511
600
return err
512
601
}
513
602
514
603
// Build response for each post
515
604
posts := []postResponse{}
516
605
for _, p := range dbposts {
517
-
r, err := s.backend.getRepoByID(ctx, p.Author)
606
+
r, err := s.backend.GetRepoByID(ctx, p.Author)
518
607
if err != nil {
519
608
return err
520
609
}
···
588
677
589
678
// Get all likes for this post
590
679
var likes []models.Like
591
-
if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil {
680
+
if err := s.db.Find(&likes, "subject = ?", postID).Error; err != nil {
592
681
return err
593
682
}
594
683
595
684
users := []engagementUser{}
596
685
for _, like := range likes {
597
-
r, err := s.backend.getRepoByID(ctx, like.Author)
686
+
r, err := s.backend.GetRepoByID(ctx, like.Author)
598
687
if err != nil {
599
688
slog.Error("failed to get repo for like author", "error", err)
600
689
continue
···
609
698
610
699
// Get profile if available
611
700
var profile models.Profile
612
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
701
+
s.db.Find(&profile, "repo = ?", r.ID)
613
702
614
703
var prof *bsky.ActorProfile
615
704
if len(profile.Raw) > 0 {
···
617
706
if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil {
618
707
prof = &p
619
708
}
709
+
} else {
710
+
s.backend.TrackMissingRecord(r.Did, false)
620
711
}
621
712
622
713
users = append(users, engagementUser{
···
646
737
647
738
// Get all reposts for this post
648
739
var reposts []models.Repost
649
-
if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
740
+
if err := s.db.Find(&reposts, "subject = ?", postID).Error; err != nil {
650
741
return err
651
742
}
652
743
653
744
users := []engagementUser{}
654
745
for _, repost := range reposts {
655
-
r, err := s.backend.getRepoByID(ctx, repost.Author)
746
+
r, err := s.backend.GetRepoByID(ctx, repost.Author)
656
747
if err != nil {
657
748
slog.Error("failed to get repo for repost author", "error", err)
658
749
continue
···
667
758
668
759
// Get profile if available
669
760
var profile models.Profile
670
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
761
+
s.db.Find(&profile, "repo = ?", r.ID)
671
762
672
763
var prof *bsky.ActorProfile
673
764
if len(profile.Raw) > 0 {
···
675
766
if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil {
676
767
prof = &p
677
768
}
769
+
} else {
770
+
s.backend.TrackMissingRecord(r.Did, false)
678
771
}
679
772
680
773
users = append(users, engagementUser{
···
704
797
705
798
// Get all replies to this post
706
799
var replies []models.Post
707
-
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 {
708
801
return err
709
802
}
710
803
···
718
811
}
719
812
seen[reply.Author] = true
720
813
721
-
r, err := s.backend.getRepoByID(ctx, reply.Author)
814
+
r, err := s.backend.GetRepoByID(ctx, reply.Author)
722
815
if err != nil {
723
816
slog.Error("failed to get repo for reply author", "error", err)
724
817
continue
···
733
826
734
827
// Get profile if available
735
828
var profile models.Profile
736
-
s.backend.db.Find(&profile, "repo = ?", r.ID)
829
+
s.db.Find(&profile, "repo = ?", r.ID)
737
830
738
831
var prof *bsky.ActorProfile
739
832
if len(profile.Raw) > 0 {
···
741
834
if err := p.UnmarshalCBOR(bytes.NewReader(profile.Raw)); err == nil {
742
835
prof = &p
743
836
}
837
+
} else {
838
+
s.backend.TrackMissingRecord(r.Did, false)
744
839
}
745
840
746
841
users = append(users, engagementUser{
···
794
889
}
795
890
796
891
var resp createRecordResponse
797
-
if err := s.client.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil {
892
+
if err := s.client.Do(ctx, xrpclib.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &resp); err != nil {
798
893
slog.Error("failed to create record", "error", err)
799
894
return e.JSON(500, map[string]any{
800
895
"error": "failed to create record",
···
804
899
805
900
return e.JSON(200, resp)
806
901
}
902
+
903
+
type notificationResponse struct {
904
+
ID uint `json:"id"`
905
+
Kind string `json:"kind"`
906
+
Author *authorInfo `json:"author"`
907
+
Source string `json:"source"`
908
+
SourcePost *struct {
909
+
Text string `json:"text"`
910
+
Uri string `json:"uri"`
911
+
} `json:"sourcePost,omitempty"`
912
+
CreatedAt string `json:"createdAt"`
913
+
}
914
+
915
+
func (s *Server) handleGetNotifications(e echo.Context) error {
916
+
ctx := e.Request().Context()
917
+
918
+
// Get cursor from query parameter (notification ID)
919
+
cursor := e.QueryParam("cursor")
920
+
limit := 50
921
+
922
+
var cursorID uint
923
+
if cursor != "" {
924
+
if _, err := fmt.Sscanf(cursor, "%d", &cursorID); err != nil {
925
+
return e.JSON(400, map[string]any{
926
+
"error": "invalid cursor",
927
+
})
928
+
}
929
+
}
930
+
931
+
// Query notifications
932
+
var notifications []Notification
933
+
query := `SELECT * FROM notifications WHERE "for" = ?`
934
+
if cursorID > 0 {
935
+
query += ` AND id < ?`
936
+
if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(¬ifications).Error; err != nil {
937
+
return err
938
+
}
939
+
} else {
940
+
if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(¬ifications).Error; err != nil {
941
+
return err
942
+
}
943
+
}
944
+
945
+
// Hydrate notifications
946
+
results := []notificationResponse{}
947
+
for _, notif := range notifications {
948
+
// Get author info
949
+
author, err := s.backend.GetRepoByID(ctx, notif.Author)
950
+
if err != nil {
951
+
slog.Error("failed to get repo for notification author", "error", err)
952
+
continue
953
+
}
954
+
955
+
authorInfo, err := s.getAuthorInfo(ctx, author)
956
+
if err != nil {
957
+
slog.Error("failed to get author info", "error", err)
958
+
continue
959
+
}
960
+
961
+
resp := notificationResponse{
962
+
ID: notif.ID,
963
+
Kind: notif.Kind,
964
+
Author: authorInfo,
965
+
Source: notif.Source,
966
+
CreatedAt: notif.CreatedAt.Format(time.RFC3339),
967
+
}
968
+
969
+
// Try to get source post preview for reply/mention notifications
970
+
if notif.Kind == backend.NotifKindReply || notif.Kind == backend.NotifKindMention {
971
+
// Parse URI to get post
972
+
p, err := s.backend.GetPostByUri(ctx, notif.Source, "*")
973
+
if err == nil && p.Raw != nil && len(p.Raw) > 0 {
974
+
var fp bsky.FeedPost
975
+
if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err == nil {
976
+
preview := fp.Text
977
+
if len(preview) > 100 {
978
+
preview = preview[:100] + "..."
979
+
}
980
+
resp.SourcePost = &struct {
981
+
Text string `json:"text"`
982
+
Uri string `json:"uri"`
983
+
}{
984
+
Text: preview,
985
+
Uri: notif.Source,
986
+
}
987
+
}
988
+
}
989
+
}
990
+
991
+
results = append(results, resp)
992
+
}
993
+
994
+
// Generate next cursor
995
+
var nextCursor string
996
+
if len(notifications) > 0 {
997
+
nextCursor = fmt.Sprintf("%d", notifications[len(notifications)-1].ID)
998
+
}
999
+
1000
+
return e.JSON(200, map[string]any{
1001
+
"notifications": results,
1002
+
"cursor": nextCursor,
1003
+
})
1004
+
}
+263
hydration/actor.go
+263
hydration/actor.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"strings"
9
+
"sync"
10
+
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"github.com/whyrusleeping/market/models"
14
+
)
15
+
16
+
// ActorInfo contains hydrated actor information
17
+
type ActorInfo struct {
18
+
DID string
19
+
Handle string
20
+
Profile *bsky.ActorProfile
21
+
}
22
+
23
+
// HydrateActor hydrates full actor information
24
+
func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) {
25
+
ctx, span := tracer.Start(ctx, "hydrateActor")
26
+
defer span.End()
27
+
28
+
// Look up handle
29
+
resp, err := h.dir.LookupDID(ctx, syntax.DID(did))
30
+
if err != nil {
31
+
return nil, fmt.Errorf("failed to lookup DID: %w", err)
32
+
}
33
+
34
+
info := &ActorInfo{
35
+
DID: did,
36
+
Handle: resp.Handle.String(),
37
+
}
38
+
39
+
// Load profile from database
40
+
var dbProfile struct {
41
+
Repo uint
42
+
Raw []byte
43
+
}
44
+
err = h.db.Raw("SELECT repo, raw FROM profiles WHERE repo = (SELECT id FROM repos WHERE did = ?)", did).
45
+
Scan(&dbProfile).Error
46
+
if err != nil {
47
+
slog.Error("failed to fetch user profile", "error", err)
48
+
} else {
49
+
if len(dbProfile.Raw) > 0 {
50
+
var profile bsky.ActorProfile
51
+
if err := profile.UnmarshalCBOR(bytes.NewReader(dbProfile.Raw)); err == nil {
52
+
info.Profile = &profile
53
+
}
54
+
} else {
55
+
h.addMissingActor(did)
56
+
}
57
+
}
58
+
59
+
return info, nil
60
+
}
61
+
62
+
type ActorInfoDetailed struct {
63
+
ActorInfo
64
+
FollowCount int64
65
+
FollowerCount int64
66
+
PostCount int64
67
+
ViewerState *bsky.ActorDefs_ViewerState
68
+
}
69
+
70
+
func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) {
71
+
act, err := h.HydrateActor(ctx, did)
72
+
if err != nil {
73
+
return nil, err
74
+
}
75
+
76
+
actd := ActorInfoDetailed{
77
+
ActorInfo: *act,
78
+
}
79
+
80
+
var wg sync.WaitGroup
81
+
wg.Go(func() {
82
+
c, err := h.getFollowCountForUser(ctx, did)
83
+
if err != nil {
84
+
slog.Error("failed to get follow count", "did", did, "error", err)
85
+
}
86
+
actd.FollowCount = c
87
+
})
88
+
wg.Go(func() {
89
+
c, err := h.getFollowerCountForUser(ctx, did)
90
+
if err != nil {
91
+
slog.Error("failed to get follower count", "did", did, "error", err)
92
+
}
93
+
actd.FollowerCount = c
94
+
})
95
+
wg.Go(func() {
96
+
c, err := h.getPostCountForUser(ctx, did)
97
+
if err != nil {
98
+
slog.Error("failed to get post count", "did", did, "error", err)
99
+
}
100
+
actd.PostCount = c
101
+
})
102
+
103
+
if viewer != "" {
104
+
wg.Go(func() {
105
+
vs, err := h.getProfileViewerState(ctx, did, viewer)
106
+
if err != nil {
107
+
slog.Error("failed to get viewer state", "did", did, "viewer", viewer, "error", err)
108
+
}
109
+
actd.ViewerState = vs
110
+
})
111
+
}
112
+
113
+
wg.Wait()
114
+
115
+
return &actd, nil
116
+
}
117
+
118
+
func (h *Hydrator) getProfileViewerState(ctx context.Context, did, viewer string) (*bsky.ActorDefs_ViewerState, error) {
119
+
vs := &bsky.ActorDefs_ViewerState{}
120
+
121
+
var wg sync.WaitGroup
122
+
123
+
// Check if viewer is blocked by the target account
124
+
wg.Go(func() {
125
+
blockedBy, err := h.getBlockPair(ctx, did, viewer)
126
+
if err != nil {
127
+
slog.Error("failed to get blockedBy relationship", "did", did, "viewer", viewer, "error", err)
128
+
return
129
+
}
130
+
131
+
if blockedBy != nil {
132
+
v := true
133
+
vs.BlockedBy = &v
134
+
}
135
+
})
136
+
137
+
// Check if viewer is blocking the target account
138
+
wg.Go(func() {
139
+
blocking, err := h.getBlockPair(ctx, viewer, did)
140
+
if err != nil {
141
+
slog.Error("failed to get blocking relationship", "did", did, "viewer", viewer, "error", err)
142
+
return
143
+
}
144
+
145
+
if blocking != nil {
146
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.block/%s", viewer, blocking.Rkey)
147
+
vs.Blocking = &uri
148
+
}
149
+
})
150
+
151
+
// Check if viewer is following the target account
152
+
wg.Go(func() {
153
+
following, err := h.getFollowPair(ctx, viewer, did)
154
+
if err != nil {
155
+
slog.Error("failed to get following relationship", "did", did, "viewer", viewer, "error", err)
156
+
return
157
+
}
158
+
159
+
if following != nil {
160
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", viewer, following.Rkey)
161
+
vs.Following = &uri
162
+
}
163
+
})
164
+
165
+
// Check if target account is following the viewer
166
+
wg.Go(func() {
167
+
followedBy, err := h.getFollowPair(ctx, did, viewer)
168
+
if err != nil {
169
+
slog.Error("failed to get followedBy relationship", "did", did, "viewer", viewer, "error", err)
170
+
return
171
+
}
172
+
173
+
if followedBy != nil {
174
+
uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", did, followedBy.Rkey)
175
+
vs.FollowedBy = &uri
176
+
}
177
+
})
178
+
179
+
wg.Wait()
180
+
181
+
return vs, nil
182
+
}
183
+
184
+
func (h *Hydrator) getBlockPair(ctx context.Context, a, b string) (*models.Block, error) {
185
+
var blk models.Block
186
+
if err := h.db.Raw("SELECT * FROM blocks WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&blk).Error; err != nil {
187
+
return nil, err
188
+
}
189
+
if blk.ID == 0 {
190
+
return nil, nil
191
+
}
192
+
193
+
return &blk, nil
194
+
}
195
+
196
+
func (h *Hydrator) getFollowPair(ctx context.Context, a, b string) (*models.Follow, error) {
197
+
var fol models.Follow
198
+
if err := h.db.Raw("SELECT * FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&fol).Error; err != nil {
199
+
return nil, err
200
+
}
201
+
if fol.ID == 0 {
202
+
return nil, nil
203
+
}
204
+
205
+
return &fol, nil
206
+
}
207
+
208
+
func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) {
209
+
var count int64
210
+
if err := h.db.Raw("SELECT count(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
211
+
return 0, err
212
+
}
213
+
214
+
return count, nil
215
+
}
216
+
217
+
func (h *Hydrator) getFollowerCountForUser(ctx context.Context, did string) (int64, error) {
218
+
var count int64
219
+
if err := h.db.Raw("SELECT count(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
220
+
return 0, err
221
+
}
222
+
223
+
return count, nil
224
+
}
225
+
226
+
func (h *Hydrator) getPostCountForUser(ctx context.Context, did string) (int64, error) {
227
+
var count int64
228
+
if err := h.db.Raw("SELECT count(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
229
+
return 0, err
230
+
}
231
+
232
+
return count, nil
233
+
}
234
+
235
+
// HydrateActors hydrates multiple actors
236
+
func (h *Hydrator) HydrateActors(ctx context.Context, dids []string) (map[string]*ActorInfo, error) {
237
+
result := make(map[string]*ActorInfo, len(dids))
238
+
for _, did := range dids {
239
+
info, err := h.HydrateActor(ctx, did)
240
+
if err != nil {
241
+
// Skip actors that fail to hydrate rather than failing the whole batch
242
+
continue
243
+
}
244
+
result[did] = info
245
+
}
246
+
return result, nil
247
+
}
248
+
249
+
// ResolveDID resolves a handle or DID to a DID
250
+
func (h *Hydrator) ResolveDID(ctx context.Context, actor string) (string, error) {
251
+
// If it's already a DID, return it
252
+
if strings.HasPrefix(actor, "did:") {
253
+
return actor, nil
254
+
}
255
+
256
+
// Otherwise, resolve the handle
257
+
resp, err := h.dir.LookupHandle(ctx, syntax.Handle(actor))
258
+
if err != nil {
259
+
return "", fmt.Errorf("failed to resolve handle: %w", err)
260
+
}
261
+
262
+
return resp.DID.String(), nil
263
+
}
+47
hydration/hydrator.go
+47
hydration/hydrator.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"github.com/bluesky-social/indigo/atproto/identity"
5
+
"github.com/whyrusleeping/konbini/backend"
6
+
"gorm.io/gorm"
7
+
)
8
+
9
+
// Hydrator handles data hydration from the database
10
+
type Hydrator struct {
11
+
db *gorm.DB
12
+
dir identity.Directory
13
+
backend *backend.PostgresBackend
14
+
}
15
+
16
+
// NewHydrator creates a new Hydrator
17
+
func NewHydrator(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Hydrator {
18
+
return &Hydrator{
19
+
db: db,
20
+
dir: dir,
21
+
backend: backend,
22
+
}
23
+
}
24
+
25
+
// AddMissingRecord reports a missing record that needs to be fetched
26
+
func (h *Hydrator) AddMissingRecord(identifier string, wait bool) {
27
+
if h.backend != nil {
28
+
h.backend.TrackMissingRecord(identifier, wait)
29
+
}
30
+
}
31
+
32
+
// addMissingActor is a convenience method for adding missing actors
33
+
func (h *Hydrator) addMissingActor(did string) {
34
+
h.AddMissingRecord(did, false)
35
+
}
36
+
37
+
// HydrateCtx contains context for hydration operations
38
+
type HydrateCtx struct {
39
+
Viewer string
40
+
}
41
+
42
+
// NewHydrateCtx creates a new hydration context
43
+
func NewHydrateCtx(viewer string) *HydrateCtx {
44
+
return &HydrateCtx{
45
+
Viewer: viewer,
46
+
}
47
+
}
+501
hydration/post.go
+501
hydration/post.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"sync"
9
+
10
+
"github.com/bluesky-social/indigo/api/bsky"
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
"github.com/whyrusleeping/market/models"
13
+
"go.opentelemetry.io/otel"
14
+
)
15
+
16
+
var tracer = otel.Tracer("hydrator")
17
+
18
+
// PostInfo contains hydrated post information
19
+
type PostInfo struct {
20
+
ID uint
21
+
URI string
22
+
Cid string
23
+
Post *bsky.FeedPost
24
+
Author string // DID
25
+
ReplyTo uint
26
+
ReplyToUsr uint
27
+
InThread uint
28
+
LikeCount int
29
+
RepostCount int
30
+
ReplyCount int
31
+
ViewerLike string // URI of viewer's like, if any
32
+
33
+
EmbedInfo *bsky.FeedDefs_PostView_Embed
34
+
}
35
+
36
+
const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu"
37
+
38
+
// HydratePost hydrates a single post by URI
39
+
func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) {
40
+
ctx, span := tracer.Start(ctx, "hydratePost")
41
+
defer span.End()
42
+
43
+
p, err := h.backend.GetPostByUri(ctx, uri, "*")
44
+
if err != nil {
45
+
return nil, err
46
+
}
47
+
48
+
return h.HydratePostDB(ctx, uri, p, viewerDID)
49
+
}
50
+
51
+
func (h *Hydrator) HydratePostDB(ctx context.Context, uri string, dbPost *models.Post, viewerDID string) (*PostInfo, error) {
52
+
autoFetch, _ := ctx.Value("auto-fetch").(bool)
53
+
54
+
authorDid := extractDIDFromURI(uri)
55
+
r, err := h.backend.GetOrCreateRepo(ctx, authorDid)
56
+
if err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
if dbPost.NotFound || len(dbPost.Raw) == 0 {
61
+
if autoFetch {
62
+
h.AddMissingRecord(uri, true)
63
+
if err := h.db.Raw(`SELECT * FROM posts WHERE author = ? AND rkey = ?`, r.ID, extractRkeyFromURI(uri)).Scan(&dbPost).Error; err != nil {
64
+
return nil, fmt.Errorf("failed to query post: %w", err)
65
+
}
66
+
if dbPost.NotFound || len(dbPost.Raw) == 0 {
67
+
return nil, fmt.Errorf("post not found")
68
+
}
69
+
} else {
70
+
return nil, fmt.Errorf("post not found")
71
+
}
72
+
}
73
+
74
+
// Unmarshal post record
75
+
var feedPost bsky.FeedPost
76
+
if err := feedPost.UnmarshalCBOR(bytes.NewReader(dbPost.Raw)); err != nil {
77
+
return nil, fmt.Errorf("failed to unmarshal post: %w", err)
78
+
}
79
+
80
+
var wg sync.WaitGroup
81
+
82
+
authorDID := r.Did
83
+
84
+
// Get engagement counts
85
+
var likes, reposts, replies int
86
+
wg.Go(func() {
87
+
_, span := tracer.Start(ctx, "likeCounts")
88
+
defer span.End()
89
+
h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes)
90
+
})
91
+
wg.Go(func() {
92
+
_, span := tracer.Start(ctx, "repostCounts")
93
+
defer span.End()
94
+
h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts)
95
+
})
96
+
wg.Go(func() {
97
+
_, span := tracer.Start(ctx, "replyCounts")
98
+
defer span.End()
99
+
h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies)
100
+
})
101
+
102
+
// Check if viewer liked this post
103
+
var likeRkey string
104
+
if viewerDID != "" {
105
+
wg.Go(func() {
106
+
_, span := tracer.Start(ctx, "viewerLikeState")
107
+
defer span.End()
108
+
h.db.Raw(`
109
+
SELECT l.rkey FROM likes l
110
+
WHERE l.subject = ?
111
+
AND l.author = (SELECT id FROM repos WHERE did = ?)
112
+
`, dbPost.ID, viewerDID).Scan(&likeRkey)
113
+
})
114
+
}
115
+
116
+
var ei *bsky.FeedDefs_PostView_Embed
117
+
if feedPost.Embed != nil {
118
+
wg.Go(func() {
119
+
ei = h.formatEmbed(ctx, feedPost.Embed, authorDID, viewerDID)
120
+
})
121
+
}
122
+
123
+
wg.Wait()
124
+
125
+
info := &PostInfo{
126
+
ID: dbPost.ID,
127
+
URI: uri,
128
+
Cid: dbPost.Cid,
129
+
Post: &feedPost,
130
+
Author: authorDID,
131
+
ReplyTo: dbPost.ReplyTo,
132
+
ReplyToUsr: dbPost.ReplyToUsr,
133
+
InThread: dbPost.InThread,
134
+
LikeCount: likes,
135
+
RepostCount: reposts,
136
+
ReplyCount: replies,
137
+
EmbedInfo: ei,
138
+
}
139
+
140
+
if likeRkey != "" {
141
+
info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey)
142
+
}
143
+
144
+
if info.Cid == "" {
145
+
slog.Error("MISSING CID", "uri", uri)
146
+
info.Cid = fakeCid
147
+
}
148
+
149
+
// Hydrate embed
150
+
151
+
return info, nil
152
+
}
153
+
154
+
// HydratePosts hydrates multiple posts
155
+
func (h *Hydrator) HydratePosts(ctx context.Context, uris []string, viewerDID string) (map[string]*PostInfo, error) {
156
+
result := make(map[string]*PostInfo, len(uris))
157
+
for _, uri := range uris {
158
+
info, err := h.HydratePost(ctx, uri, viewerDID)
159
+
if err != nil {
160
+
// Skip posts that fail to hydrate
161
+
continue
162
+
}
163
+
result[uri] = info
164
+
}
165
+
return result, nil
166
+
}
167
+
168
+
// Helper functions to extract DID and rkey from AT URI
169
+
func extractDIDFromURI(uri string) string {
170
+
// URI format: at://did:plc:xxx/collection/rkey
171
+
if len(uri) < 5 || uri[:5] != "at://" {
172
+
return ""
173
+
}
174
+
parts := []rune(uri[5:])
175
+
for i, r := range parts {
176
+
if r == '/' {
177
+
return string(parts[:i])
178
+
}
179
+
}
180
+
return string(parts)
181
+
}
182
+
183
+
func extractRkeyFromURI(uri string) string {
184
+
// URI format: at://did:plc:xxx/collection/rkey
185
+
if len(uri) < 5 || uri[:5] != "at://" {
186
+
return ""
187
+
}
188
+
// Find last slash
189
+
for i := len(uri) - 1; i >= 5; i-- {
190
+
if uri[i] == '/' {
191
+
return uri[i+1:]
192
+
}
193
+
}
194
+
return ""
195
+
}
196
+
197
+
func (h *Hydrator) formatEmbed(ctx context.Context, embed *bsky.FeedPost_Embed, authorDID string, viewerDID string) *bsky.FeedDefs_PostView_Embed {
198
+
if embed == nil {
199
+
return nil
200
+
}
201
+
_, span := tracer.Start(ctx, "formatEmbed")
202
+
defer span.End()
203
+
204
+
result := &bsky.FeedDefs_PostView_Embed{}
205
+
206
+
// Handle images
207
+
if embed.EmbedImages != nil {
208
+
viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedImages.Images))
209
+
for i, img := range embed.EmbedImages.Images {
210
+
// Convert blob to CDN URLs
211
+
fullsize := ""
212
+
thumb := ""
213
+
if img.Image != nil {
214
+
// CDN URL format for feed images
215
+
cid := img.Image.Ref.String()
216
+
fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid)
217
+
thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
218
+
}
219
+
220
+
viewImages[i] = &bsky.EmbedImages_ViewImage{
221
+
Alt: img.Alt,
222
+
AspectRatio: img.AspectRatio,
223
+
Fullsize: fullsize,
224
+
Thumb: thumb,
225
+
}
226
+
}
227
+
result.EmbedImages_View = &bsky.EmbedImages_View{
228
+
LexiconTypeID: "app.bsky.embed.images#view",
229
+
Images: viewImages,
230
+
}
231
+
return result
232
+
}
233
+
234
+
// Handle external links
235
+
if embed.EmbedExternal != nil && embed.EmbedExternal.External != nil {
236
+
// Convert blob thumb to CDN URL if present
237
+
var thumbURL *string
238
+
if embed.EmbedExternal.External.Thumb != nil {
239
+
// CDN URL for external link thumbnails
240
+
cid := embed.EmbedExternal.External.Thumb.Ref.String()
241
+
url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
242
+
thumbURL = &url
243
+
}
244
+
245
+
result.EmbedExternal_View = &bsky.EmbedExternal_View{
246
+
LexiconTypeID: "app.bsky.embed.external#view",
247
+
External: &bsky.EmbedExternal_ViewExternal{
248
+
Uri: embed.EmbedExternal.External.Uri,
249
+
Title: embed.EmbedExternal.External.Title,
250
+
Description: embed.EmbedExternal.External.Description,
251
+
Thumb: thumbURL,
252
+
},
253
+
}
254
+
return result
255
+
}
256
+
257
+
// Handle video
258
+
if embed.EmbedVideo != nil && embed.EmbedVideo.Video != nil {
259
+
cid := embed.EmbedVideo.Video.Ref.String()
260
+
// URL-encode the DID (replace : with %3A)
261
+
encodedDID := ""
262
+
for _, ch := range authorDID {
263
+
if ch == ':' {
264
+
encodedDID += "%3A"
265
+
} else {
266
+
encodedDID += string(ch)
267
+
}
268
+
}
269
+
270
+
playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid)
271
+
thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid)
272
+
273
+
result.EmbedVideo_View = &bsky.EmbedVideo_View{
274
+
LexiconTypeID: "app.bsky.embed.video#view",
275
+
Cid: cid,
276
+
Playlist: playlist,
277
+
Thumbnail: &thumbnail,
278
+
Alt: embed.EmbedVideo.Alt,
279
+
AspectRatio: embed.EmbedVideo.AspectRatio,
280
+
}
281
+
return result
282
+
}
283
+
284
+
// Handle record (quote posts, etc.)
285
+
if embed.EmbedRecord != nil && embed.EmbedRecord.Record != nil {
286
+
rec := embed.EmbedRecord.Record
287
+
288
+
result.EmbedRecord_View = &bsky.EmbedRecord_View{
289
+
LexiconTypeID: "app.bsky.embed.record#view",
290
+
Record: h.hydrateEmbeddedRecord(ctx, rec.Uri, viewerDID),
291
+
}
292
+
return result
293
+
}
294
+
295
+
// Handle record with media (quote post with images/external)
296
+
if embed.EmbedRecordWithMedia != nil {
297
+
recordView := &bsky.EmbedRecordWithMedia_View{
298
+
LexiconTypeID: "app.bsky.embed.recordWithMedia#view",
299
+
}
300
+
301
+
// Hydrate the record part
302
+
if embed.EmbedRecordWithMedia.Record != nil && embed.EmbedRecordWithMedia.Record.Record != nil {
303
+
recordView.Record = &bsky.EmbedRecord_View{
304
+
LexiconTypeID: "app.bsky.embed.record#view",
305
+
Record: h.hydrateEmbeddedRecord(ctx, embed.EmbedRecordWithMedia.Record.Record.Uri, viewerDID),
306
+
}
307
+
}
308
+
309
+
// Hydrate the media part (images or external)
310
+
if embed.EmbedRecordWithMedia.Media != nil {
311
+
if embed.EmbedRecordWithMedia.Media.EmbedImages != nil {
312
+
viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedRecordWithMedia.Media.EmbedImages.Images))
313
+
for i, img := range embed.EmbedRecordWithMedia.Media.EmbedImages.Images {
314
+
fullsize := ""
315
+
thumb := ""
316
+
if img.Image != nil {
317
+
cid := img.Image.Ref.String()
318
+
fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid)
319
+
thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
320
+
}
321
+
322
+
viewImages[i] = &bsky.EmbedImages_ViewImage{
323
+
Alt: img.Alt,
324
+
AspectRatio: img.AspectRatio,
325
+
Fullsize: fullsize,
326
+
Thumb: thumb,
327
+
}
328
+
}
329
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
330
+
EmbedImages_View: &bsky.EmbedImages_View{
331
+
LexiconTypeID: "app.bsky.embed.images#view",
332
+
Images: viewImages,
333
+
},
334
+
}
335
+
} else if embed.EmbedRecordWithMedia.Media.EmbedExternal != nil && embed.EmbedRecordWithMedia.Media.EmbedExternal.External != nil {
336
+
var thumbURL *string
337
+
if embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb != nil {
338
+
cid := embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb.Ref.String()
339
+
url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid)
340
+
thumbURL = &url
341
+
}
342
+
343
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
344
+
EmbedExternal_View: &bsky.EmbedExternal_View{
345
+
LexiconTypeID: "app.bsky.embed.external#view",
346
+
External: &bsky.EmbedExternal_ViewExternal{
347
+
Uri: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Uri,
348
+
Title: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Title,
349
+
Description: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Description,
350
+
Thumb: thumbURL,
351
+
},
352
+
},
353
+
}
354
+
} else if embed.EmbedRecordWithMedia.Media.EmbedVideo != nil && embed.EmbedRecordWithMedia.Media.EmbedVideo.Video != nil {
355
+
cid := embed.EmbedRecordWithMedia.Media.EmbedVideo.Video.Ref.String()
356
+
// URL-encode the DID (replace : with %3A)
357
+
encodedDID := ""
358
+
for _, ch := range authorDID {
359
+
if ch == ':' {
360
+
encodedDID += "%3A"
361
+
} else {
362
+
encodedDID += string(ch)
363
+
}
364
+
}
365
+
366
+
playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid)
367
+
thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid)
368
+
369
+
recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{
370
+
EmbedVideo_View: &bsky.EmbedVideo_View{
371
+
LexiconTypeID: "app.bsky.embed.video#view",
372
+
Cid: cid,
373
+
Playlist: playlist,
374
+
Thumbnail: &thumbnail,
375
+
Alt: embed.EmbedRecordWithMedia.Media.EmbedVideo.Alt,
376
+
AspectRatio: embed.EmbedRecordWithMedia.Media.EmbedVideo.AspectRatio,
377
+
},
378
+
}
379
+
}
380
+
}
381
+
382
+
result.EmbedRecordWithMedia_View = recordView
383
+
return result
384
+
}
385
+
386
+
return nil
387
+
}
388
+
389
+
// hydrateEmbeddedRecord hydrates an embedded record (for quote posts, etc.)
390
+
func (h *Hydrator) hydrateEmbeddedRecord(ctx context.Context, uri string, viewerDID string) *bsky.EmbedRecord_View_Record {
391
+
ctx, span := tracer.Start(ctx, "hydrateEmbeddedRecord")
392
+
defer span.End()
393
+
394
+
// Check if it's a post URI
395
+
if !isPostURI(uri) {
396
+
// Could be a feed generator, list, labeler, or starter pack
397
+
// For now, return not found for non-post embeds
398
+
return &bsky.EmbedRecord_View_Record{
399
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
400
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
401
+
Uri: uri,
402
+
},
403
+
}
404
+
}
405
+
406
+
// Try to hydrate the post
407
+
quotedPost, err := h.HydratePost(ctx, uri, viewerDID)
408
+
if err != nil {
409
+
// Post not found
410
+
return &bsky.EmbedRecord_View_Record{
411
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
412
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
413
+
Uri: uri,
414
+
NotFound: true,
415
+
},
416
+
}
417
+
}
418
+
419
+
// Hydrate the author
420
+
authorInfo, err := h.HydrateActor(ctx, quotedPost.Author)
421
+
if err != nil {
422
+
// Author not found, treat as not found
423
+
return &bsky.EmbedRecord_View_Record{
424
+
EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{
425
+
LexiconTypeID: "app.bsky.embed.record#viewNotFound",
426
+
Uri: uri,
427
+
NotFound: true,
428
+
},
429
+
}
430
+
}
431
+
432
+
// TODO: Check if viewer has blocked or is blocked by the author
433
+
// For now, just return the record view
434
+
435
+
// Build the author profile view
436
+
authorView := &bsky.ActorDefs_ProfileViewBasic{
437
+
Did: authorInfo.DID,
438
+
Handle: authorInfo.Handle,
439
+
}
440
+
if authorInfo.Profile != nil {
441
+
if authorInfo.Profile.DisplayName != nil && *authorInfo.Profile.DisplayName != "" {
442
+
authorView.DisplayName = authorInfo.Profile.DisplayName
443
+
}
444
+
if authorInfo.Profile.Avatar != nil {
445
+
avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", authorInfo.DID, authorInfo.Profile.Avatar.Ref.String())
446
+
authorView.Avatar = &avatarURL
447
+
}
448
+
}
449
+
450
+
// Build the embedded post view
451
+
embedView := &bsky.EmbedRecord_ViewRecord{
452
+
LexiconTypeID: "app.bsky.embed.record#viewRecord",
453
+
Uri: quotedPost.URI,
454
+
Cid: quotedPost.Cid,
455
+
Author: authorView,
456
+
Value: &util.LexiconTypeDecoder{
457
+
Val: quotedPost.Post,
458
+
},
459
+
IndexedAt: quotedPost.Post.CreatedAt,
460
+
}
461
+
462
+
// Add engagement counts
463
+
if quotedPost.LikeCount > 0 {
464
+
lc := int64(quotedPost.LikeCount)
465
+
embedView.LikeCount = &lc
466
+
}
467
+
if quotedPost.RepostCount > 0 {
468
+
rc := int64(quotedPost.RepostCount)
469
+
embedView.RepostCount = &rc
470
+
}
471
+
if quotedPost.ReplyCount > 0 {
472
+
rpc := int64(quotedPost.ReplyCount)
473
+
embedView.ReplyCount = &rpc
474
+
}
475
+
476
+
// Note: We don't recursively hydrate embeds for quoted posts to avoid deep nesting
477
+
// The official app also doesn't show embeds within quoted posts
478
+
479
+
return &bsky.EmbedRecord_View_Record{
480
+
EmbedRecord_ViewRecord: embedView,
481
+
}
482
+
}
483
+
484
+
// isPostURI checks if a URI is a post URI
485
+
func isPostURI(uri string) bool {
486
+
return len(uri) > 5 && uri[:5] == "at://" && (
487
+
// Check if it contains /app.bsky.feed.post/
488
+
len(uri) > 25 && uri[len(uri)-25:len(uri)-12] == "/app.bsky.feed.post/" ||
489
+
// More flexible check
490
+
contains(uri, "/app.bsky.feed.post/"))
491
+
}
492
+
493
+
// contains checks if a string contains a substring
494
+
func contains(s, substr string) bool {
495
+
for i := 0; i <= len(s)-len(substr); i++ {
496
+
if s[i:i+len(substr)] == substr {
497
+
return true
498
+
}
499
+
}
500
+
return false
501
+
}
+39
hydration/utils.go
+39
hydration/utils.go
···
1
+
package hydration
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/whyrusleeping/market/models"
9
+
)
10
+
11
+
func (h *Hydrator) NormalizeUri(ctx context.Context, uri string) (string, error) {
12
+
puri, err := syntax.ParseATURI(uri)
13
+
if err != nil {
14
+
return "", fmt.Errorf("invalid uri: %w", err)
15
+
}
16
+
17
+
var did string
18
+
if !puri.Authority().IsDID() {
19
+
resp, err := h.dir.LookupHandle(ctx, syntax.Handle(puri.Authority().String()))
20
+
if err != nil {
21
+
return "", err
22
+
}
23
+
24
+
did = resp.DID.String()
25
+
} else {
26
+
did = puri.Authority().String()
27
+
}
28
+
29
+
return fmt.Sprintf("at://%s/%s/%s", did, puri.Collection().String(), puri.RecordKey().String()), nil
30
+
}
31
+
32
+
func (h *Hydrator) UriForPost(ctx context.Context, p *models.Post) (string, error) {
33
+
did, err := h.backend.DidFromID(ctx, p.Author)
34
+
if err != nil {
35
+
return "", err
36
+
}
37
+
38
+
return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, p.Rkey), nil
39
+
}
+136
-1551
main.go
+136
-1551
main.go
···
3
3
import (
4
4
"bytes"
5
5
"context"
6
-
"errors"
6
+
"encoding/json"
7
7
"fmt"
8
8
"log"
9
9
"log/slog"
···
17
17
"time"
18
18
19
19
"github.com/bluesky-social/indigo/api/atproto"
20
-
"github.com/bluesky-social/indigo/api/bsky"
21
20
"github.com/bluesky-social/indigo/atproto/identity"
21
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
22
22
"github.com/bluesky-social/indigo/atproto/syntax"
23
-
"github.com/bluesky-social/indigo/cmd/relay/stream"
24
-
"github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel"
25
23
"github.com/bluesky-social/indigo/repo"
26
-
"github.com/bluesky-social/indigo/util"
27
24
"github.com/bluesky-social/indigo/util/cliutil"
28
-
"github.com/bluesky-social/indigo/xrpc"
29
-
"github.com/gorilla/websocket"
30
-
lru "github.com/hashicorp/golang-lru/v2"
25
+
xrpclib "github.com/bluesky-social/indigo/xrpc"
31
26
"github.com/ipfs/go-cid"
32
-
"github.com/jackc/pgx/v5"
33
-
"github.com/jackc/pgx/v5/pgconn"
34
27
"github.com/jackc/pgx/v5/pgxpool"
35
28
"github.com/prometheus/client_golang/prometheus"
36
29
"github.com/prometheus/client_golang/prometheus/promauto"
37
30
"github.com/urfave/cli/v2"
38
-
"github.com/whyrusleeping/market/models"
31
+
"github.com/whyrusleeping/konbini/backend"
32
+
"github.com/whyrusleeping/konbini/xrpc"
33
+
"go.opentelemetry.io/otel"
34
+
"go.opentelemetry.io/otel/attribute"
35
+
"go.opentelemetry.io/otel/exporters/jaeger"
36
+
"go.opentelemetry.io/otel/sdk/resource"
37
+
tracesdk "go.opentelemetry.io/otel/sdk/trace"
38
+
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
39
39
"gorm.io/gorm"
40
-
"gorm.io/gorm/clause"
41
40
"gorm.io/gorm/logger"
41
+
42
+
. "github.com/whyrusleeping/konbini/models"
42
43
)
43
44
44
-
var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{
45
-
Name: "handle_op_duration",
46
-
Help: "A histogram of op handling durations",
47
-
Buckets: prometheus.ExponentialBuckets(1, 2, 15),
48
-
}, []string{"op", "collection"})
49
-
50
45
var firehoseCursorGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
51
46
Name: "firehose_cursor",
52
47
}, []string{"stage"})
···
60
55
&cli.StringFlag{
61
56
Name: "db-url",
62
57
EnvVars: []string{"DATABASE_URL"},
58
+
},
59
+
&cli.BoolFlag{
60
+
Name: "jaeger",
63
61
},
64
62
&cli.StringFlag{
65
63
Name: "handle",
···
67
65
&cli.IntFlag{
68
66
Name: "max-db-connections",
69
67
Value: runtime.NumCPU(),
68
+
},
69
+
&cli.StringFlag{
70
+
Name: "redis-url",
71
+
},
72
+
&cli.StringFlag{
73
+
Name: "sync-config",
70
74
},
71
75
}
72
76
app.Action = func(cctx *cli.Context) error {
···
82
86
Colorful: true,
83
87
})
84
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
+
85
118
db.AutoMigrate(Repo{})
86
119
db.AutoMigrate(Post{})
87
120
db.AutoMigrate(Follow{})
···
97
130
db.AutoMigrate(Image{})
98
131
db.AutoMigrate(PostGate{})
99
132
db.AutoMigrate(StarterPack{})
100
-
db.AutoMigrate(SyncInfo{})
133
+
db.AutoMigrate(backend.SyncInfo{})
134
+
db.AutoMigrate(Notification{})
135
+
db.AutoMigrate(NotificationSeen{})
136
+
db.AutoMigrate(SequenceTracker{})
137
+
db.Exec("CREATE INDEX IF NOT EXISTS reposts_subject_idx ON reposts (subject)")
138
+
db.Exec("CREATE INDEX IF NOT EXISTS posts_reply_to_idx ON posts (reply_to)")
139
+
db.Exec("CREATE INDEX IF NOT EXISTS posts_in_thread_idx ON posts (in_thread)")
101
140
102
141
ctx := context.TODO()
103
142
104
-
rc, _ := lru.New2Q[string, *Repo](1_000_000)
105
-
pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000)
106
-
revc, _ := lru.New2Q[uint, string](1_000_000)
107
-
108
143
cfg, err := pgxpool.ParseConfig(cctx.String("db-url"))
109
144
if err != nil {
110
145
return err
···
128
163
129
164
dir := identity.DefaultDirectory()
130
165
166
+
if redisURL := cctx.String("redis-url"); redisURL != "" {
167
+
rdir, err := redisdir.NewRedisDirectory(dir, redisURL, time.Minute, time.Second*10, time.Second*10, 100_000)
168
+
if err != nil {
169
+
return err
170
+
}
171
+
dir = rdir
172
+
}
173
+
131
174
resp, err := dir.LookupHandle(ctx, syntax.Handle(handle))
132
175
if err != nil {
133
176
return err
134
177
}
135
178
mydid := resp.DID.String()
136
179
137
-
cc := &xrpc.Client{
180
+
cc := &xrpclib.Client{
138
181
Host: resp.PDSEndpoint(),
139
182
}
140
183
···
146
189
return err
147
190
}
148
191
149
-
cc.Auth = &xrpc.AuthInfo{
192
+
cc.Auth = &xrpclib.AuthInfo{
150
193
AccessJwt: nsess.AccessJwt,
151
194
Did: mydid,
152
195
Handle: nsess.Handle,
···
158
201
client: cc,
159
202
dir: dir,
160
203
161
-
missingProfiles: make(chan string, 1024),
204
+
db: db,
162
205
}
163
206
164
-
pgb := &PostgresBackend{
165
-
relevantDids: make(map[string]bool),
166
-
s: s,
167
-
db: db,
168
-
postInfoCache: pc,
169
-
repoCache: rc,
170
-
revCache: revc,
171
-
pgx: pool,
207
+
pgb, err := backend.NewPostgresBackend(mydid, db, pool, cc, dir)
208
+
if err != nil {
209
+
return err
172
210
}
211
+
173
212
s.backend = pgb
174
213
175
-
myrepo, err := s.backend.getOrCreateRepo(ctx, mydid)
214
+
myrepo, err := s.backend.GetOrCreateRepo(ctx, mydid)
176
215
if err != nil {
177
216
return fmt.Errorf("failed to get repo record for our own did: %w", err)
178
217
}
179
218
s.myrepo = myrepo
180
219
181
-
if err := s.backend.loadRelevantDids(); err != nil {
220
+
if err := s.backend.LoadRelevantDids(); err != nil {
182
221
return fmt.Errorf("failed to load relevant dids set: %w", err)
183
222
}
184
223
224
+
// Start custom API server (for the custom frontend)
185
225
go func() {
186
226
if err := s.runApiServer(); err != nil {
187
227
fmt.Println("failed to start api server: ", err)
188
228
}
189
229
}()
190
230
231
+
// Start XRPC server (for official Bluesky app compatibility)
232
+
go func() {
233
+
xrpcServer := xrpc.NewServer(db, dir, pgb)
234
+
if err := xrpcServer.Start(":4446"); err != nil {
235
+
fmt.Println("failed to start XRPC server: ", err)
236
+
}
237
+
}()
238
+
239
+
// Start pprof server
191
240
go func() {
192
241
http.ListenAndServe(":4445", nil)
193
242
}()
194
243
195
-
go s.missingProfileFetcher()
244
+
sc := SyncConfig{
245
+
Backends: []SyncBackend{
246
+
{
247
+
Type: "firehose",
248
+
Host: "bsky.network",
249
+
},
250
+
},
251
+
}
252
+
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()
196
260
197
-
seqno, err := loadLastSeq("sequence.txt")
198
-
if err != nil {
199
-
fmt.Println("failed to load sequence number, starting over", err)
261
+
var lsc SyncConfig
262
+
if err := json.NewDecoder(scfi).Decode(&lsc); err != nil {
263
+
return err
264
+
}
265
+
sc = lsc
266
+
}
200
267
}
201
268
202
-
return s.startLiveTail(ctx, seqno, 10, 20)
269
+
/*
270
+
sc.Backends[0] = SyncBackend{
271
+
Type: "jetstream",
272
+
Host: "jetstream1.us-west.bsky.network",
273
+
}
274
+
*/
275
+
276
+
return s.StartSyncEngine(ctx, &sc)
277
+
203
278
}
204
279
205
280
app.RunAndExitOnError()
206
281
}
207
282
208
283
type Server struct {
209
-
backend *PostgresBackend
284
+
backend *backend.PostgresBackend
210
285
211
286
dir identity.Directory
212
287
213
-
client *xrpc.Client
288
+
client *xrpclib.Client
214
289
mydid string
215
290
myrepo *Repo
216
291
217
292
seqLk sync.Mutex
218
293
lastSeq int64
219
294
220
-
mpLk sync.Mutex
221
-
missingProfiles chan string
295
+
mpLk sync.Mutex
296
+
297
+
db *gorm.DB
222
298
}
223
299
224
-
func (s *Server) getXrpcClient() (*xrpc.Client, error) {
300
+
func (s *Server) getXrpcClient() (*xrpclib.Client, error) {
225
301
// TODO: handle refreshing the token periodically
226
302
return s.client, nil
227
303
}
228
304
229
-
func (s *Server) startLiveTail(ctx context.Context, curs int, parWorkers, maxQ int) error {
230
-
slog.Info("starting live tail")
231
-
232
-
// Connect to the Relay websocket
233
-
urlStr := fmt.Sprintf("wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", curs)
234
-
235
-
d := websocket.DefaultDialer
236
-
con, _, err := d.Dial(urlStr, http.Header{
237
-
"User-Agent": []string{"market/0.0.1"},
238
-
})
239
-
if err != nil {
240
-
return fmt.Errorf("failed to connect to relay: %w", err)
241
-
}
242
-
243
-
var lelk sync.Mutex
244
-
lastEvent := time.Now()
245
-
246
-
go func() {
247
-
for range time.Tick(time.Second) {
248
-
lelk.Lock()
249
-
let := lastEvent
250
-
lelk.Unlock()
251
-
252
-
if time.Since(let) > time.Second*30 {
253
-
slog.Error("firehose connection timed out")
254
-
con.Close()
255
-
return
256
-
}
257
-
258
-
}
259
-
260
-
}()
261
-
262
-
var cclk sync.Mutex
263
-
var completeCursor int64
264
-
265
-
rsc := &stream.RepoStreamCallbacks{
266
-
RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error {
267
-
ctx := context.Background()
268
-
269
-
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq))
270
-
271
-
s.seqLk.Lock()
272
-
if evt.Seq > s.lastSeq {
273
-
curs = int(evt.Seq)
274
-
s.lastSeq = evt.Seq
275
-
276
-
if evt.Seq%1000 == 0 {
277
-
if err := storeLastSeq("sequence.txt", int(evt.Seq)); err != nil {
278
-
fmt.Println("failed to store seqno: ", err)
279
-
}
280
-
}
281
-
}
282
-
s.seqLk.Unlock()
283
-
284
-
lelk.Lock()
285
-
lastEvent = time.Now()
286
-
lelk.Unlock()
287
-
288
-
if err := s.backend.HandleEvent(ctx, evt); err != nil {
289
-
return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err)
290
-
}
291
-
292
-
cclk.Lock()
293
-
if evt.Seq > completeCursor {
294
-
completeCursor = evt.Seq
295
-
firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq))
296
-
}
297
-
cclk.Unlock()
298
-
299
-
return nil
300
-
},
301
-
RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error {
302
-
return nil
303
-
},
304
-
// TODO: all the other event types
305
-
Error: func(errf *stream.ErrorFrame) error {
306
-
return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message)
307
-
},
308
-
}
309
-
310
-
sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler)
311
-
312
-
//s.eventScheduler = sched
313
-
//s.streamFinished = make(chan struct{})
314
-
315
-
return stream.HandleRepoStream(ctx, con, sched, slog.Default())
316
-
}
317
-
318
305
func (s *Server) resolveAccountIdent(ctx context.Context, acc string) (string, error) {
319
306
unesc, err := url.PathUnescape(acc)
320
307
if err != nil {
···
334
321
return resp.DID.String(), nil
335
322
}
336
323
337
-
func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error {
338
-
r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks))
339
-
if err != nil {
340
-
return fmt.Errorf("failed to read event repo: %w", err)
341
-
}
342
-
343
-
for _, op := range evt.Ops {
344
-
switch op.Action {
345
-
case "create":
346
-
c, rec, err := r.GetRecordBytes(ctx, op.Path)
347
-
if err != nil {
348
-
return err
349
-
}
350
-
if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
351
-
return fmt.Errorf("create record failed: %w", err)
352
-
}
353
-
case "update":
354
-
c, rec, err := r.GetRecordBytes(ctx, op.Path)
355
-
if err != nil {
356
-
return err
357
-
}
358
-
if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil {
359
-
return fmt.Errorf("update record failed: %w", err)
360
-
}
361
-
case "delete":
362
-
if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil {
363
-
return fmt.Errorf("delete record failed: %w", err)
364
-
}
365
-
}
366
-
}
367
-
368
-
// TODO: sync with the Since field to make sure we don't miss events we care about
369
-
/*
370
-
if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil {
371
-
return fmt.Errorf("failed to update rev: %w", err)
372
-
}
373
-
*/
374
-
375
-
return nil
376
-
}
377
-
378
-
func (b *PostgresBackend) getOrCreateRepo(ctx context.Context, did string) (*Repo, error) {
379
-
r, ok := b.repoCache.Get(did)
380
-
if !ok {
381
-
b.reposLk.Lock()
382
-
383
-
r, ok = b.repoCache.Get(did)
384
-
if !ok {
385
-
r = &Repo{}
386
-
r.Did = did
387
-
b.repoCache.Add(did, r)
388
-
}
389
-
390
-
b.reposLk.Unlock()
391
-
}
392
-
393
-
r.Lk.Lock()
394
-
defer r.Lk.Unlock()
395
-
if r.Setup {
396
-
return r, nil
397
-
}
398
-
399
-
row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did)
400
-
401
-
err := row.Scan(&r.ID, &r.CreatedAt, &r.Did)
402
-
if err == nil {
403
-
// found it!
404
-
r.Setup = true
405
-
return r, nil
406
-
}
407
-
408
-
if err != pgx.ErrNoRows {
409
-
return nil, err
410
-
}
411
-
412
-
r.Did = did
413
-
if err := b.db.Create(r).Error; err != nil {
414
-
return nil, err
415
-
}
416
-
417
-
r.Setup = true
418
-
419
-
return r, nil
420
-
}
421
-
422
-
func (b *PostgresBackend) getOrCreateList(ctx context.Context, uri string) (*List, error) {
423
-
puri, err := util.ParseAtUri(uri)
424
-
if err != nil {
425
-
return nil, err
426
-
}
427
-
428
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
429
-
if err != nil {
430
-
return nil, err
431
-
}
432
-
433
-
// TODO: needs upsert treatment when we actually find the list
434
-
var list List
435
-
if err := b.db.FirstOrCreate(&list, map[string]any{
436
-
"author": r.ID,
437
-
"rkey": puri.Rkey,
438
-
}).Error; err != nil {
439
-
return nil, err
440
-
}
441
-
return &list, nil
442
-
}
443
-
444
-
type cachedPostInfo struct {
445
-
ID uint
446
-
Author uint
447
-
}
448
-
449
-
func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) {
450
-
// getPostByUri implicitly fills the cache
451
-
p, err := b.postInfoForUri(ctx, uri)
452
-
if err != nil {
453
-
return 0, err
454
-
}
455
-
456
-
return p.ID, nil
457
-
}
458
-
459
-
func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) {
460
-
v, ok := b.postInfoCache.Get(uri)
461
-
if ok {
462
-
return v, nil
463
-
}
464
-
465
-
// getPostByUri implicitly fills the cache
466
-
p, err := b.getOrCreatePostBare(ctx, uri)
467
-
if err != nil {
468
-
return cachedPostInfo{}, err
469
-
}
470
-
471
-
return cachedPostInfo{ID: p.ID, Author: p.Author}, nil
472
-
}
473
-
474
-
func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) {
475
-
var p Post
476
-
q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2"
477
-
if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil {
478
-
if errors.Is(err, pgx.ErrNoRows) {
479
-
return nil, nil
480
-
}
481
-
return nil, err
482
-
}
483
-
484
-
return &p, nil
485
-
}
486
-
487
-
func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) {
488
-
puri, err := util.ParseAtUri(uri)
489
-
if err != nil {
490
-
return nil, err
491
-
}
492
-
493
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
494
-
if err != nil {
495
-
return nil, err
496
-
}
497
-
498
-
post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
499
-
if err != nil {
500
-
return nil, err
501
-
}
502
-
503
-
if post == nil {
504
-
post = &Post{
505
-
Rkey: puri.Rkey,
506
-
Author: r.ID,
507
-
NotFound: true,
508
-
}
509
-
510
-
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)
511
-
if err != nil {
512
-
pgErr, ok := err.(*pgconn.PgError)
513
-
if !ok || pgErr.Code != "23505" {
514
-
return nil, err
515
-
}
516
-
517
-
out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey)
518
-
if err != nil {
519
-
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
520
-
}
521
-
if out == nil {
522
-
return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey)
523
-
}
524
-
525
-
post = out
526
-
}
527
-
528
-
}
529
-
530
-
b.postInfoCache.Add(uri, cachedPostInfo{
531
-
ID: post.ID,
532
-
Author: post.Author,
533
-
})
534
-
535
-
return post, nil
536
-
}
537
-
538
-
func (b *PostgresBackend) getPostByUri(ctx context.Context, uri string, fields string) (*Post, error) {
539
-
puri, err := util.ParseAtUri(uri)
540
-
if err != nil {
541
-
return nil, err
542
-
}
543
-
544
-
r, err := b.getOrCreateRepo(ctx, puri.Did)
545
-
if err != nil {
546
-
return nil, err
547
-
}
548
-
549
-
q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?"
550
-
551
-
var post Post
552
-
if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil {
553
-
return nil, err
554
-
}
555
-
556
-
if post.ID == 0 {
557
-
post.Rkey = puri.Rkey
558
-
post.Author = r.ID
559
-
post.NotFound = true
560
-
561
-
if err := b.db.Session(&gorm.Session{
562
-
Logger: logger.Default.LogMode(logger.Silent),
563
-
}).Create(&post).Error; err != nil {
564
-
if !errors.Is(err, gorm.ErrDuplicatedKey) {
565
-
return nil, err
566
-
}
567
-
if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil {
568
-
return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err)
569
-
}
570
-
}
571
-
572
-
}
573
-
574
-
b.postInfoCache.Add(uri, cachedPostInfo{
575
-
ID: post.ID,
576
-
Author: post.Author,
577
-
})
578
-
579
-
return &post, nil
580
-
}
581
-
582
-
func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) {
583
-
lrev, ok := b.revCache.Get(rr.ID)
584
-
if ok {
585
-
return lrev, nil
586
-
}
587
-
588
-
var rev string
589
-
if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil {
590
-
if errors.Is(err, pgx.ErrNoRows) {
591
-
return "", nil
592
-
}
593
-
return "", err
594
-
}
595
-
596
-
if rev != "" {
597
-
b.revCache.Add(rr.ID, rev)
598
-
}
599
-
return rev, nil
600
-
}
601
-
602
-
func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
603
-
start := time.Now()
604
-
605
-
rr, err := b.getOrCreateRepo(ctx, repo)
606
-
if err != nil {
607
-
return fmt.Errorf("get user failed: %w", err)
608
-
}
609
-
610
-
lrev, err := b.revForRepo(rr)
611
-
if err != nil {
612
-
return err
613
-
}
614
-
if lrev != "" {
615
-
if rev < lrev {
616
-
slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
617
-
return nil
618
-
}
619
-
}
620
-
621
-
parts := strings.Split(path, "/")
622
-
if len(parts) != 2 {
623
-
return fmt.Errorf("invalid path in HandleCreate: %q", path)
624
-
}
625
-
col := parts[0]
626
-
rkey := parts[1]
627
-
628
-
defer func() {
629
-
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
630
-
}()
631
-
632
-
if rkey == "" {
633
-
fmt.Printf("messed up path: %q\n", rkey)
634
-
}
635
-
636
-
switch col {
637
-
case "app.bsky.feed.post":
638
-
if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
639
-
return err
640
-
}
641
-
case "app.bsky.feed.like":
642
-
if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
643
-
return err
644
-
}
645
-
case "app.bsky.feed.repost":
646
-
if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
647
-
return err
648
-
}
649
-
case "app.bsky.graph.follow":
650
-
if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
651
-
return err
652
-
}
653
-
case "app.bsky.graph.block":
654
-
if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
655
-
return err
656
-
}
657
-
case "app.bsky.graph.list":
658
-
if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
659
-
return err
660
-
}
661
-
case "app.bsky.graph.listitem":
662
-
if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
663
-
return err
664
-
}
665
-
case "app.bsky.graph.listblock":
666
-
if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
667
-
return err
668
-
}
669
-
case "app.bsky.actor.profile":
670
-
if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
671
-
return err
672
-
}
673
-
case "app.bsky.feed.generator":
674
-
if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
675
-
return err
676
-
}
677
-
case "app.bsky.feed.threadgate":
678
-
if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
679
-
return err
680
-
}
681
-
case "chat.bsky.actor.declaration":
682
-
if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
683
-
return err
684
-
}
685
-
case "app.bsky.feed.postgate":
686
-
if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil {
687
-
return err
688
-
}
689
-
case "app.bsky.graph.starterpack":
690
-
if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil {
691
-
return err
692
-
}
693
-
default:
694
-
slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev)
695
-
}
696
-
697
-
b.revCache.Add(rr.ID, rev)
698
-
return nil
699
-
}
700
-
701
-
type PostgresBackend struct {
702
-
db *gorm.DB
703
-
pgx *pgxpool.Pool
704
-
s *Server
705
-
706
-
relevantDids map[string]bool
707
-
rdLk sync.Mutex
708
-
709
-
revCache *lru.TwoQueueCache[uint, string]
710
-
711
-
repoCache *lru.TwoQueueCache[string, *Repo]
712
-
reposLk sync.Mutex
713
-
714
-
postInfoCache *lru.TwoQueueCache[string, cachedPostInfo]
715
-
}
716
-
717
-
func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error {
718
-
r, err := b.getOrCreateRepo(ctx, user)
324
+
func (s *Server) rescanRepo(ctx context.Context, did string) error {
325
+
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
719
326
if err != nil {
720
327
return err
721
328
}
722
329
723
-
var si SyncInfo
724
-
if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil {
725
-
return err
726
-
}
727
-
728
-
// not found
729
-
if si.Repo == 0 {
730
-
if err := b.db.Create(&SyncInfo{
731
-
Repo: r.ID,
732
-
}).Error; err != nil {
733
-
return err
734
-
}
735
-
}
736
-
737
-
if si.FollowsSynced {
738
-
return nil
739
-
}
740
-
741
-
var follows []Follow
742
-
var cursor string
743
-
for {
744
-
resp, err := atproto.RepoListRecords(ctx, b.s.client, "app.bsky.graph.follow", cursor, 100, b.s.mydid, false)
745
-
if err != nil {
746
-
return err
747
-
}
748
-
749
-
for _, rec := range resp.Records {
750
-
if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok {
751
-
fr, err := b.getOrCreateRepo(ctx, fol.Subject)
752
-
if err != nil {
753
-
return err
754
-
}
755
-
756
-
puri, err := syntax.ParseATURI(rec.Uri)
757
-
if err != nil {
758
-
return err
759
-
}
760
-
761
-
follows = append(follows, Follow{
762
-
Created: time.Now(),
763
-
Indexed: time.Now(),
764
-
Rkey: puri.RecordKey().String(),
765
-
Author: r.ID,
766
-
Subject: fr.ID,
767
-
})
768
-
}
769
-
}
770
-
771
-
if resp.Cursor == nil || len(resp.Records) == 0 {
772
-
break
773
-
}
774
-
cursor = *resp.Cursor
775
-
}
330
+
s.backend.AddRelevantDid(did)
776
331
777
-
if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil {
778
-
return err
332
+
c := &xrpclib.Client{
333
+
Host: resp.PDSEndpoint(),
779
334
}
780
335
781
-
if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil {
782
-
return err
783
-
}
784
-
785
-
fmt.Println("Got follows: ", len(follows))
786
-
787
-
return nil
788
-
}
789
-
790
-
func (b *PostgresBackend) loadRelevantDids() error {
791
-
ctx := context.TODO()
792
-
793
-
if err := b.ensureFollowsScraped(ctx, b.s.mydid); err != nil {
794
-
return fmt.Errorf("failed to scrape follows: %w", err)
795
-
}
796
-
797
-
r, err := b.getOrCreateRepo(ctx, b.s.mydid)
336
+
repob, err := atproto.SyncGetRepo(ctx, c, did, "")
798
337
if err != nil {
799
338
return err
800
339
}
801
340
802
-
var dids []string
803
-
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 {
804
-
return err
805
-
}
806
-
807
-
b.relevantDids[b.s.mydid] = true
808
-
for _, d := range dids {
809
-
fmt.Println("adding did: ", d)
810
-
b.relevantDids[d] = true
811
-
}
812
-
813
-
return nil
814
-
}
815
-
816
-
type SyncInfo struct {
817
-
Repo uint `gorm:"index"`
818
-
FollowsSynced bool
819
-
Rev string
820
-
}
821
-
822
-
func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) {
823
-
var id uint
824
-
var notfound bool
825
-
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 {
826
-
if errors.Is(err, pgx.ErrNoRows) {
827
-
return false, nil
828
-
}
829
-
return false, err
830
-
}
831
-
832
-
if id != 0 && !notfound {
833
-
return true, nil
834
-
}
835
-
836
-
return false, nil
837
-
}
838
-
839
-
func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
840
-
exists, err := b.checkPostExists(ctx, repo, rkey)
341
+
rep, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(repob))
841
342
if err != nil {
842
343
return err
843
344
}
844
345
845
-
// still technically a race condition if two creates for the same post happen concurrently... probably fine
846
-
if exists {
847
-
return nil
848
-
}
849
-
850
-
var rec bsky.FeedPost
851
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
852
-
return err
853
-
}
854
-
855
-
reldids := []string{repo.Did}
856
-
// care about a post if its in a thread of a user we are interested in
857
-
if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil {
858
-
reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri)
859
-
}
860
-
// TODO: maybe also care if its mentioning a user we care about or quoting a user we care about?
861
-
if !b.anyRelevantIdents(reldids...) {
862
-
return nil
863
-
}
864
-
865
-
uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey
866
-
slog.Warn("adding post", "uri", uri)
867
-
868
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
869
-
if err != nil {
870
-
return fmt.Errorf("invalid timestamp: %w", err)
871
-
}
872
-
873
-
p := Post{
874
-
Created: created.Time(),
875
-
Indexed: time.Now(),
876
-
Author: repo.ID,
877
-
Rkey: rkey,
878
-
Raw: recb,
879
-
Cid: cc.String(),
880
-
}
881
-
882
-
if rec.Reply != nil && rec.Reply.Parent != nil {
883
-
if rec.Reply.Root == nil {
884
-
return fmt.Errorf("post reply had nil root")
885
-
}
886
-
887
-
pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri)
346
+
return rep.ForEach(ctx, "", func(k string, v cid.Cid) error {
347
+
blk, err := rep.Blockstore().Get(ctx, v)
888
348
if err != nil {
889
-
return fmt.Errorf("getting reply parent: %w", err)
890
-
}
891
-
892
-
p.ReplyTo = pinfo.ID
893
-
p.ReplyToUsr = pinfo.Author
894
-
895
-
thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri)
896
-
if err != nil {
897
-
return fmt.Errorf("getting thread root: %w", err)
898
-
}
899
-
900
-
p.InThread = thread
901
-
}
902
-
903
-
if rec.Embed != nil {
904
-
var rpref string
905
-
if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil {
906
-
rpref = rec.Embed.EmbedRecord.Record.Uri
907
-
}
908
-
if rec.Embed.EmbedRecordWithMedia != nil &&
909
-
rec.Embed.EmbedRecordWithMedia.Record != nil &&
910
-
rec.Embed.EmbedRecordWithMedia.Record.Record != nil {
911
-
rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri
912
-
}
913
-
914
-
if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") {
915
-
rp, err := b.postIDForUri(ctx, rpref)
916
-
if err != nil {
917
-
return fmt.Errorf("getting quote subject: %w", err)
918
-
}
919
-
920
-
p.Reposting = rp
921
-
}
922
-
}
923
-
924
-
if err := b.doPostCreate(ctx, &p); err != nil {
925
-
return err
926
-
}
927
-
928
-
b.postInfoCache.Add(uri, cachedPostInfo{
929
-
ID: p.ID,
930
-
Author: p.Author,
931
-
})
932
-
933
-
return nil
934
-
}
935
-
936
-
func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error {
937
-
/*
938
-
if err := b.db.Clauses(clause.OnConflict{
939
-
Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}},
940
-
DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}),
941
-
}).Create(p).Error; err != nil {
942
-
return err
943
-
}
944
-
*/
945
-
946
-
query := `
947
-
INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread)
948
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
949
-
ON CONFLICT (author, rkey)
950
-
DO UPDATE SET
951
-
cid = $3,
952
-
not_found = $4,
953
-
raw = $5,
954
-
created = $6,
955
-
indexed = $7,
956
-
reposting = $8,
957
-
reply_to = $9,
958
-
reply_to_usr = $10,
959
-
in_thread = $11
960
-
RETURNING id
961
-
`
962
-
963
-
// Execute the query with parameters from the Post struct
964
-
if err := b.pgx.QueryRow(
965
-
ctx,
966
-
query,
967
-
p.Author,
968
-
p.Rkey,
969
-
p.Cid,
970
-
p.NotFound,
971
-
p.Raw,
972
-
p.Created,
973
-
p.Indexed,
974
-
p.Reposting,
975
-
p.ReplyTo,
976
-
p.ReplyToUsr,
977
-
p.InThread,
978
-
).Scan(&p.ID); err != nil {
979
-
return err
980
-
}
981
-
982
-
return nil
983
-
}
984
-
985
-
func (b *PostgresBackend) didIsRelevant(did string) bool {
986
-
b.rdLk.Lock()
987
-
defer b.rdLk.Unlock()
988
-
return b.relevantDids[did]
989
-
}
990
-
991
-
func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool {
992
-
for _, id := range idents {
993
-
if strings.HasPrefix(id, "did:") {
994
-
if b.didIsRelevant(id) {
995
-
return true
996
-
}
997
-
} else if strings.HasPrefix(id, "at://") {
998
-
puri, err := syntax.ParseATURI(id)
999
-
if err != nil {
1000
-
continue
1001
-
}
1002
-
1003
-
if b.didIsRelevant(puri.Authority().String()) {
1004
-
return true
1005
-
}
1006
-
}
1007
-
}
1008
-
1009
-
return false
1010
-
}
1011
-
1012
-
func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1013
-
var rec bsky.FeedLike
1014
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1015
-
return err
1016
-
}
1017
-
1018
-
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
1019
-
return nil
1020
-
}
1021
-
1022
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1023
-
if err != nil {
1024
-
return fmt.Errorf("invalid timestamp: %w", err)
1025
-
}
1026
-
1027
-
pid, err := b.postIDForUri(ctx, rec.Subject.Uri)
1028
-
if err != nil {
1029
-
return fmt.Errorf("getting like subject: %w", err)
1030
-
}
1031
-
1032
-
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, pid, cc.String()); err != nil {
1033
-
pgErr, ok := err.(*pgconn.PgError)
1034
-
if ok && pgErr.Code == "23505" {
349
+
slog.Error("record missing in repo", "path", k, "cid", v, "error", err)
1035
350
return nil
1036
351
}
1037
-
return err
1038
-
}
1039
352
1040
-
return nil
1041
-
}
1042
-
1043
-
func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1044
-
var rec bsky.FeedRepost
1045
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1046
-
return err
1047
-
}
1048
-
1049
-
if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) {
1050
-
return nil
1051
-
}
1052
-
1053
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1054
-
if err != nil {
1055
-
return fmt.Errorf("invalid timestamp: %w", err)
1056
-
}
1057
-
1058
-
pid, err := b.postIDForUri(ctx, rec.Subject.Uri)
1059
-
if err != nil {
1060
-
return fmt.Errorf("getting repost subject: %w", err)
1061
-
}
1062
-
1063
-
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, pid); err != nil {
1064
-
pgErr, ok := err.(*pgconn.PgError)
1065
-
if ok && pgErr.Code == "23505" {
1066
-
return nil
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)
1067
356
}
1068
-
return err
1069
-
}
1070
-
1071
-
return nil
1072
-
}
1073
-
1074
-
func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1075
-
var rec bsky.GraphFollow
1076
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1077
-
return err
1078
-
}
1079
-
1080
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
1081
357
return nil
1082
-
}
1083
-
1084
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1085
-
if err != nil {
1086
-
return fmt.Errorf("invalid timestamp: %w", err)
1087
-
}
1088
-
1089
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
1090
-
if err != nil {
1091
-
return err
1092
-
}
1093
-
1094
-
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 {
1095
-
return err
1096
-
}
1097
-
1098
-
return nil
1099
-
}
1100
-
1101
-
func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1102
-
var rec bsky.GraphBlock
1103
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1104
-
return err
1105
-
}
1106
-
1107
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
1108
-
return nil
1109
-
}
1110
-
1111
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1112
-
if err != nil {
1113
-
return fmt.Errorf("invalid timestamp: %w", err)
1114
-
}
1115
-
1116
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
1117
-
if err != nil {
1118
-
return err
1119
-
}
1120
-
1121
-
if err := b.db.Create(&Block{
1122
-
Created: created.Time(),
1123
-
Indexed: time.Now(),
1124
-
Author: repo.ID,
1125
-
Rkey: rkey,
1126
-
Subject: subj.ID,
1127
-
}).Error; err != nil {
1128
-
return err
1129
-
}
1130
-
1131
-
return nil
1132
-
}
1133
-
1134
-
func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1135
-
var rec bsky.GraphList
1136
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1137
-
return err
1138
-
}
1139
-
1140
-
if !b.anyRelevantIdents(repo.Did) {
1141
-
return nil
1142
-
}
1143
-
1144
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1145
-
if err != nil {
1146
-
return fmt.Errorf("invalid timestamp: %w", err)
1147
-
}
1148
-
1149
-
if err := b.db.Create(&List{
1150
-
Created: created.Time(),
1151
-
Indexed: time.Now(),
1152
-
Author: repo.ID,
1153
-
Rkey: rkey,
1154
-
Raw: recb,
1155
-
}).Error; err != nil {
1156
-
return err
1157
-
}
1158
-
1159
-
return nil
1160
-
}
1161
-
1162
-
func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1163
-
var rec bsky.GraphListitem
1164
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1165
-
return err
1166
-
}
1167
-
if !b.anyRelevantIdents(repo.Did) {
1168
-
return nil
1169
-
}
1170
-
1171
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1172
-
if err != nil {
1173
-
return fmt.Errorf("invalid timestamp: %w", err)
1174
-
}
1175
-
1176
-
subj, err := b.getOrCreateRepo(ctx, rec.Subject)
1177
-
if err != nil {
1178
-
return err
1179
-
}
1180
-
1181
-
list, err := b.getOrCreateList(ctx, rec.List)
1182
-
if err != nil {
1183
-
return err
1184
-
}
1185
-
1186
-
if err := b.db.Create(&ListItem{
1187
-
Created: created.Time(),
1188
-
Indexed: time.Now(),
1189
-
Author: repo.ID,
1190
-
Rkey: rkey,
1191
-
Subject: subj.ID,
1192
-
List: list.ID,
1193
-
}).Error; err != nil {
1194
-
return err
1195
-
}
1196
-
1197
-
return nil
1198
-
}
1199
-
1200
-
func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1201
-
var rec bsky.GraphListblock
1202
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1203
-
return err
1204
-
}
1205
-
1206
-
if !b.anyRelevantIdents(repo.Did, rec.Subject) {
1207
-
return nil
1208
-
}
358
+
})
1209
359
1210
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1211
-
if err != nil {
1212
-
return fmt.Errorf("invalid timestamp: %w", err)
1213
-
}
1214
-
1215
-
list, err := b.getOrCreateList(ctx, rec.Subject)
1216
-
if err != nil {
1217
-
return err
1218
-
}
1219
-
1220
-
if err := b.db.Create(&ListBlock{
1221
-
Created: created.Time(),
1222
-
Indexed: time.Now(),
1223
-
Author: repo.ID,
1224
-
Rkey: rkey,
1225
-
List: list.ID,
1226
-
}).Error; err != nil {
1227
-
return err
1228
-
}
1229
-
1230
-
return nil
1231
-
}
1232
-
1233
-
func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
1234
-
if !b.anyRelevantIdents(repo.Did) {
1235
-
return nil
1236
-
}
1237
-
1238
-
if err := b.db.Create(&Profile{
1239
-
//Created: created.Time(),
1240
-
Indexed: time.Now(),
1241
-
Repo: repo.ID,
1242
-
Raw: recb,
1243
-
Rev: rev,
1244
-
}).Error; err != nil {
1245
-
return err
1246
-
}
1247
-
1248
-
return nil
1249
-
}
1250
-
1251
-
func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error {
1252
-
if !b.anyRelevantIdents(repo.Did) {
1253
-
return nil
1254
-
}
1255
-
1256
-
if err := b.db.Create(&Profile{
1257
-
Indexed: time.Now(),
1258
-
Repo: repo.ID,
1259
-
Raw: recb,
1260
-
Rev: rev,
1261
-
}).Error; err != nil {
1262
-
return err
1263
-
}
1264
-
1265
-
return nil
1266
-
}
1267
-
1268
-
func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1269
-
if !b.anyRelevantIdents(repo.Did) {
1270
-
return nil
1271
-
}
1272
-
1273
-
var rec bsky.FeedGenerator
1274
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1275
-
return err
1276
-
}
1277
-
1278
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1279
-
if err != nil {
1280
-
return fmt.Errorf("invalid timestamp: %w", err)
1281
-
}
1282
-
1283
-
if err := b.db.Create(&FeedGenerator{
1284
-
Created: created.Time(),
1285
-
Indexed: time.Now(),
1286
-
Author: repo.ID,
1287
-
Rkey: rkey,
1288
-
Did: rec.Did,
1289
-
}).Error; err != nil {
1290
-
return err
1291
-
}
1292
-
1293
-
return nil
1294
-
}
1295
-
1296
-
func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1297
-
if !b.anyRelevantIdents(repo.Did) {
1298
-
return nil
1299
-
}
1300
-
var rec bsky.FeedThreadgate
1301
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1302
-
return err
1303
-
}
1304
-
1305
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1306
-
if err != nil {
1307
-
return fmt.Errorf("invalid timestamp: %w", err)
1308
-
}
1309
-
1310
-
pid, err := b.postIDForUri(ctx, rec.Post)
1311
-
if err != nil {
1312
-
return err
1313
-
}
1314
-
1315
-
if err := b.db.Create(&ThreadGate{
1316
-
Created: created.Time(),
1317
-
Indexed: time.Now(),
1318
-
Author: repo.ID,
1319
-
Rkey: rkey,
1320
-
Post: pid,
1321
-
}).Error; err != nil {
1322
-
return err
1323
-
}
1324
-
1325
-
return nil
1326
-
}
1327
-
1328
-
func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1329
-
// TODO: maybe track these?
1330
-
return nil
1331
-
}
1332
-
1333
-
func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1334
-
if !b.anyRelevantIdents(repo.Did) {
1335
-
return nil
1336
-
}
1337
-
var rec bsky.FeedPostgate
1338
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1339
-
return err
1340
-
}
1341
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1342
-
if err != nil {
1343
-
return fmt.Errorf("invalid timestamp: %w", err)
1344
-
}
1345
-
1346
-
refPost, err := b.postInfoForUri(ctx, rec.Post)
1347
-
if err != nil {
1348
-
return err
1349
-
}
1350
-
1351
-
if err := b.db.Create(&PostGate{
1352
-
Created: created.Time(),
1353
-
Indexed: time.Now(),
1354
-
Author: repo.ID,
1355
-
Rkey: rkey,
1356
-
Subject: refPost.ID,
1357
-
Raw: recb,
1358
-
}).Error; err != nil {
1359
-
return err
1360
-
}
1361
-
1362
-
return nil
1363
-
}
1364
-
1365
-
func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error {
1366
-
if !b.anyRelevantIdents(repo.Did) {
1367
-
return nil
1368
-
}
1369
-
var rec bsky.GraphStarterpack
1370
-
if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil {
1371
-
return err
1372
-
}
1373
-
created, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
1374
-
if err != nil {
1375
-
return fmt.Errorf("invalid timestamp: %w", err)
1376
-
}
1377
-
1378
-
list, err := b.getOrCreateList(ctx, rec.List)
1379
-
if err != nil {
1380
-
return err
1381
-
}
1382
-
1383
-
if err := b.db.Create(&StarterPack{
1384
-
Created: created.Time(),
1385
-
Indexed: time.Now(),
1386
-
Author: repo.ID,
1387
-
Rkey: rkey,
1388
-
Raw: recb,
1389
-
List: list.ID,
1390
-
}).Error; err != nil {
1391
-
return err
1392
-
}
1393
-
1394
-
return nil
1395
-
}
1396
-
1397
-
func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error {
1398
-
start := time.Now()
1399
-
1400
-
rr, err := b.getOrCreateRepo(ctx, repo)
1401
-
if err != nil {
1402
-
return fmt.Errorf("get user failed: %w", err)
1403
-
}
1404
-
1405
-
lrev, err := b.revForRepo(rr)
1406
-
if err != nil {
1407
-
return err
1408
-
}
1409
-
if lrev != "" {
1410
-
if rev < lrev {
1411
-
//slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path)
1412
-
return nil
1413
-
}
1414
-
}
1415
-
1416
-
parts := strings.Split(path, "/")
1417
-
if len(parts) != 2 {
1418
-
return fmt.Errorf("invalid path in HandleCreate: %q", path)
1419
-
}
1420
-
col := parts[0]
1421
-
rkey := parts[1]
1422
-
1423
-
defer func() {
1424
-
handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds()))
1425
-
}()
1426
-
1427
-
if rkey == "" {
1428
-
fmt.Printf("messed up path: %q\n", rkey)
1429
-
}
1430
-
1431
-
switch col {
1432
-
/*
1433
-
case "app.bsky.feed.post":
1434
-
if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil {
1435
-
return err
1436
-
}
1437
-
case "app.bsky.feed.like":
1438
-
if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil {
1439
-
return err
1440
-
}
1441
-
case "app.bsky.feed.repost":
1442
-
if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil {
1443
-
return err
1444
-
}
1445
-
case "app.bsky.graph.follow":
1446
-
if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil {
1447
-
return err
1448
-
}
1449
-
case "app.bsky.graph.block":
1450
-
if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil {
1451
-
return err
1452
-
}
1453
-
case "app.bsky.graph.list":
1454
-
if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil {
1455
-
return err
1456
-
}
1457
-
case "app.bsky.graph.listitem":
1458
-
if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil {
1459
-
return err
1460
-
}
1461
-
case "app.bsky.graph.listblock":
1462
-
if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil {
1463
-
return err
1464
-
}
1465
-
*/
1466
-
case "app.bsky.actor.profile":
1467
-
if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil {
1468
-
return err
1469
-
}
1470
-
/*
1471
-
case "app.bsky.feed.generator":
1472
-
if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil {
1473
-
return err
1474
-
}
1475
-
case "app.bsky.feed.threadgate":
1476
-
if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil {
1477
-
return err
1478
-
}
1479
-
case "chat.bsky.actor.declaration":
1480
-
if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil {
1481
-
return err
1482
-
}
1483
-
*/
1484
-
default:
1485
-
slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev)
1486
-
}
1487
-
1488
-
return nil
1489
-
}
1490
-
1491
-
func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error {
1492
-
start := time.Now()
1493
-
1494
-
rr, err := b.getOrCreateRepo(ctx, repo)
1495
-
if err != nil {
1496
-
return fmt.Errorf("get user failed: %w", err)
1497
-
}
1498
-
1499
-
lrev, ok := b.revCache.Get(rr.ID)
1500
-
if ok {
1501
-
if rev < lrev {
1502
-
//slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev)
1503
-
return nil
1504
-
}
1505
-
}
1506
-
1507
-
parts := strings.Split(path, "/")
1508
-
if len(parts) != 2 {
1509
-
return fmt.Errorf("invalid path in HandleDelete: %q", path)
1510
-
}
1511
-
col := parts[0]
1512
-
rkey := parts[1]
1513
-
1514
-
defer func() {
1515
-
handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds()))
1516
-
}()
1517
-
1518
-
switch col {
1519
-
case "app.bsky.feed.post":
1520
-
if err := b.HandleDeletePost(ctx, rr, rkey); err != nil {
1521
-
return err
1522
-
}
1523
-
case "app.bsky.feed.like":
1524
-
if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil {
1525
-
return err
1526
-
}
1527
-
case "app.bsky.feed.repost":
1528
-
if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil {
1529
-
return err
1530
-
}
1531
-
case "app.bsky.graph.follow":
1532
-
if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil {
1533
-
return err
1534
-
}
1535
-
case "app.bsky.graph.block":
1536
-
if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil {
1537
-
return err
1538
-
}
1539
-
case "app.bsky.graph.list":
1540
-
if err := b.HandleDeleteList(ctx, rr, rkey); err != nil {
1541
-
return err
1542
-
}
1543
-
case "app.bsky.graph.listitem":
1544
-
if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil {
1545
-
return err
1546
-
}
1547
-
case "app.bsky.graph.listblock":
1548
-
if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil {
1549
-
return err
1550
-
}
1551
-
case "app.bsky.actor.profile":
1552
-
if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil {
1553
-
return err
1554
-
}
1555
-
case "app.bsky.feed.generator":
1556
-
if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil {
1557
-
return err
1558
-
}
1559
-
case "app.bsky.feed.threadgate":
1560
-
if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil {
1561
-
return err
1562
-
}
1563
-
default:
1564
-
slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev)
1565
-
}
1566
-
1567
-
b.revCache.Add(rr.ID, rev)
1568
-
return nil
1569
-
}
1570
-
1571
-
func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error {
1572
-
var p Post
1573
-
if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1574
-
return err
1575
-
}
1576
-
1577
-
if p.ID == 0 {
1578
-
//slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey)
1579
-
return nil
1580
-
}
1581
-
1582
-
if err := b.db.Delete(&Post{}, p.ID).Error; err != nil {
1583
-
return err
1584
-
}
1585
-
1586
-
return nil
1587
-
}
1588
-
1589
-
func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error {
1590
-
var like Like
1591
-
if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1592
-
return err
1593
-
}
1594
-
1595
-
if like.ID == 0 {
1596
-
//slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey)
1597
-
return nil
1598
-
}
1599
-
1600
-
if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil {
1601
-
return err
1602
-
}
1603
-
1604
-
return nil
1605
-
}
1606
-
1607
-
func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error {
1608
-
var repost Repost
1609
-
if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1610
-
return err
1611
-
}
1612
-
1613
-
if repost.ID == 0 {
1614
-
//return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey)
1615
-
return nil
1616
-
}
1617
-
1618
-
if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil {
1619
-
return err
1620
-
}
1621
-
1622
-
return nil
1623
-
}
1624
-
1625
-
func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error {
1626
-
var follow Follow
1627
-
if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1628
-
return err
1629
-
}
1630
-
1631
-
if follow.ID == 0 {
1632
-
//slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey)
1633
-
return nil
1634
-
}
1635
-
1636
-
if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil {
1637
-
return err
1638
-
}
1639
-
1640
-
return nil
1641
-
}
1642
-
1643
-
func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error {
1644
-
var block Block
1645
-
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1646
-
return err
1647
-
}
1648
-
1649
-
if block.ID == 0 {
1650
-
//slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey)
1651
-
return nil
1652
-
}
1653
-
1654
-
if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil {
1655
-
return err
1656
-
}
1657
-
1658
-
return nil
1659
-
}
1660
-
1661
-
func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error {
1662
-
var list List
1663
-
if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1664
-
return err
1665
-
}
1666
-
1667
-
if list.ID == 0 {
1668
-
return nil
1669
-
//return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey)
1670
-
}
1671
-
1672
-
if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil {
1673
-
return err
1674
-
}
1675
-
1676
-
return nil
1677
-
}
1678
-
1679
-
func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error {
1680
-
var item ListItem
1681
-
if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1682
-
return err
1683
-
}
1684
-
1685
-
if item.ID == 0 {
1686
-
return nil
1687
-
//return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey)
1688
-
}
1689
-
1690
-
if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil {
1691
-
return err
1692
-
}
1693
-
1694
-
return nil
1695
-
}
1696
-
1697
-
func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error {
1698
-
var block ListBlock
1699
-
if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1700
-
return err
1701
-
}
1702
-
1703
-
if block.ID == 0 {
1704
-
return nil
1705
-
//return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey)
1706
-
}
1707
-
1708
-
if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil {
1709
-
return err
1710
-
}
1711
-
1712
-
return nil
1713
-
}
1714
-
1715
-
func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error {
1716
-
var feedgen FeedGenerator
1717
-
if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1718
-
return err
1719
-
}
1720
-
1721
-
if feedgen.ID == 0 {
1722
-
return nil
1723
-
//return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey)
1724
-
}
1725
-
1726
-
if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil {
1727
-
return err
1728
-
}
1729
-
1730
-
return nil
1731
-
}
1732
-
1733
-
func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error {
1734
-
var threadgate ThreadGate
1735
-
if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil {
1736
-
return err
1737
-
}
1738
-
1739
-
if threadgate.ID == 0 {
1740
-
return nil
1741
-
//return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey)
1742
-
}
1743
-
1744
-
if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil {
1745
-
return err
1746
-
}
1747
-
1748
-
return nil
1749
-
}
1750
-
1751
-
func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error {
1752
-
var profile Profile
1753
-
if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil {
1754
-
return err
1755
-
}
1756
-
1757
-
if profile.ID == 0 {
1758
-
return nil
1759
-
}
1760
-
1761
-
if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil {
1762
-
return err
1763
-
}
1764
-
1765
-
return nil
1766
-
}
1767
-
1768
-
func (b *PostgresBackend) getRepoByID(ctx context.Context, id uint) (*models.Repo, error) {
1769
-
var r models.Repo
1770
-
if err := b.db.Find(&r, "id = ?", id).Error; err != nil {
1771
-
return nil, err
1772
-
}
1773
-
1774
-
return &r, nil
1775
360
}
-67
missing.go
-67
missing.go
···
1
-
package main
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"fmt"
7
-
8
-
"github.com/bluesky-social/indigo/api/atproto"
9
-
"github.com/bluesky-social/indigo/api/bsky"
10
-
"github.com/bluesky-social/indigo/atproto/syntax"
11
-
"github.com/bluesky-social/indigo/xrpc"
12
-
"github.com/ipfs/go-cid"
13
-
"github.com/labstack/gommon/log"
14
-
)
15
-
16
-
func (s *Server) addMissingProfile(ctx context.Context, did string) {
17
-
select {
18
-
case s.missingProfiles <- did:
19
-
case <-ctx.Done():
20
-
}
21
-
}
22
-
23
-
func (s *Server) missingProfileFetcher() {
24
-
for did := range s.missingProfiles {
25
-
if err := s.fetchMissingProfile(context.TODO(), did); err != nil {
26
-
log.Warn("failed to fetch missing profile", "did", did, "error", err)
27
-
}
28
-
}
29
-
}
30
-
31
-
func (s *Server) fetchMissingProfile(ctx context.Context, did string) error {
32
-
repo, err := s.backend.getOrCreateRepo(ctx, did)
33
-
if err != nil {
34
-
return err
35
-
}
36
-
37
-
resp, err := s.dir.LookupDID(ctx, syntax.DID(did))
38
-
if err != nil {
39
-
return err
40
-
}
41
-
42
-
c := &xrpc.Client{
43
-
Host: resp.PDSEndpoint(),
44
-
}
45
-
46
-
rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self")
47
-
if err != nil {
48
-
return err
49
-
}
50
-
51
-
prof, ok := rec.Value.Val.(*bsky.ActorProfile)
52
-
if !ok {
53
-
return fmt.Errorf("record we got back wasnt a profile somehow")
54
-
}
55
-
56
-
buf := new(bytes.Buffer)
57
-
if err := prof.MarshalCBOR(buf); err != nil {
58
-
return err
59
-
}
60
-
61
-
cc, err := cid.Decode(*rec.Cid)
62
-
if err != nil {
63
-
return err
64
-
}
65
-
66
-
return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc)
67
-
}
+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
+
}
-32
models.go
-32
models.go
···
1
-
package main
2
-
3
-
import (
4
-
"time"
5
-
6
-
"github.com/whyrusleeping/market/models"
7
-
)
8
-
9
-
type Repo = models.Repo
10
-
type Post = models.Post
11
-
type Follow = models.Follow
12
-
type Block = models.Block
13
-
type Repost = models.Repost
14
-
type List = models.List
15
-
type ListItem = models.ListItem
16
-
type ListBlock = models.ListBlock
17
-
type Profile = models.Profile
18
-
type ThreadGate = models.ThreadGate
19
-
type FeedGenerator = models.FeedGenerator
20
-
type Image = models.Image
21
-
type PostGate = models.PostGate
22
-
type StarterPack = models.StarterPack
23
-
24
-
type Like struct {
25
-
ID uint `gorm:"primarykey"`
26
-
Created time.Time
27
-
Indexed time.Time
28
-
Author uint `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
29
-
Rkey string `gorm:"uniqueIndex:idx_likes_rkeyauthor"`
30
-
Subject uint
31
-
Cid string
32
-
}
+19
-18
seqno.go
+19
-18
seqno.go
···
1
1
package main
2
2
3
3
import (
4
-
"fmt"
5
-
"io/ioutil"
6
-
"strconv"
7
-
"strings"
4
+
"gorm.io/gorm"
5
+
"gorm.io/gorm/clause"
6
+
7
+
. "github.com/whyrusleeping/konbini/models"
8
8
)
9
9
10
-
func storeLastSeq(filename string, seq int) error {
11
-
data := fmt.Sprint(seq)
12
-
return ioutil.WriteFile(filename, []byte(data), 0644)
10
+
func storeLastSeq(db *gorm.DB, key string, seq int64) error {
11
+
return db.Clauses(clause.OnConflict{
12
+
Columns: []clause.Column{{Name: "key"}},
13
+
DoUpdates: clause.AssignmentColumns([]string{"int_val"}),
14
+
}).Create(&SequenceTracker{
15
+
Key: key,
16
+
IntVal: seq,
17
+
}).Error
13
18
}
14
19
15
-
func loadLastSeq(filename string) (int, error) {
16
-
data, err := ioutil.ReadFile(filename)
17
-
if err != nil {
18
-
return 0, err
19
-
}
20
-
21
-
seqStr := strings.TrimSpace(string(data))
22
-
seq, err := strconv.Atoi(seqStr)
23
-
if err != nil {
20
+
func loadLastSeq(db *gorm.DB, key string) (int64, error) {
21
+
var info SequenceTracker
22
+
if err := db.Where("key = ?", key).First(&info).Error; err != nil {
23
+
if err == gorm.ErrRecordNotFound {
24
+
return 0, nil
25
+
}
24
26
return 0, err
25
27
}
26
-
27
-
return seq, nil
28
+
return info.IntVal, nil
28
29
}
+8
sync-config-jetstream.json
+8
sync-config-jetstream.json
+281
sync.go
+281
sync.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"sync"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/cmd/relay/stream"
13
+
"github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel"
14
+
jsclient "github.com/bluesky-social/jetstream/pkg/client"
15
+
jsparallel "github.com/bluesky-social/jetstream/pkg/client/schedulers/parallel"
16
+
"github.com/bluesky-social/jetstream/pkg/models"
17
+
"github.com/gorilla/websocket"
18
+
)
19
+
20
+
type SyncConfig struct {
21
+
Backends []SyncBackend `json:"backends"`
22
+
}
23
+
24
+
type SyncBackend struct {
25
+
Type string `json:"type"`
26
+
Host string `json:"host"`
27
+
MaxWorkers int `json:"max_workers,omitempty"`
28
+
}
29
+
30
+
func (s *Server) StartSyncEngine(ctx context.Context, sc *SyncConfig) error {
31
+
for _, be := range sc.Backends {
32
+
switch be.Type {
33
+
case "firehose":
34
+
go s.runSyncFirehose(ctx, be)
35
+
case "jetstream":
36
+
go s.runSyncJetstream(ctx, be)
37
+
default:
38
+
return fmt.Errorf("unrecognized sync backend type: %q", be.Type)
39
+
}
40
+
}
41
+
42
+
<-ctx.Done()
43
+
return fmt.Errorf("exiting sync routine")
44
+
}
45
+
46
+
const failureTimeInterval = time.Second * 5
47
+
48
+
func (s *Server) runSyncFirehose(ctx context.Context, be SyncBackend) {
49
+
var failures int
50
+
for {
51
+
seqno, err := loadLastSeq(s.db, be.Host)
52
+
if err != nil {
53
+
fmt.Println("failed to load sequence number, starting over", err)
54
+
}
55
+
56
+
maxWorkers := 10
57
+
if be.MaxWorkers != 0 {
58
+
maxWorkers = be.MaxWorkers
59
+
}
60
+
61
+
start := time.Now()
62
+
if err := s.startLiveTail(ctx, be.Host, int(seqno), maxWorkers, 20); err != nil {
63
+
slog.Error("firehose connection lost", "host", be.Host, "error", err)
64
+
}
65
+
66
+
elapsed := time.Since(start)
67
+
68
+
if elapsed > failureTimeInterval {
69
+
failures = 0
70
+
continue
71
+
}
72
+
failures++
73
+
74
+
delay := delayForFailureCount(failures)
75
+
slog.Warn("retrying connection after delay", "host", be.Host, "delay", delay)
76
+
}
77
+
}
78
+
79
+
func (s *Server) runSyncJetstream(ctx context.Context, be SyncBackend) {
80
+
var failures int
81
+
for {
82
+
// Load last cursor (stored as sequence number in same table)
83
+
cursor, err := loadLastSeq(s.db, be.Host)
84
+
if err != nil {
85
+
slog.Warn("failed to load jetstream cursor, starting from live", "error", err)
86
+
cursor = 0
87
+
}
88
+
89
+
maxWorkers := 10
90
+
if be.MaxWorkers != 0 {
91
+
maxWorkers = be.MaxWorkers
92
+
}
93
+
94
+
start := time.Now()
95
+
if err := s.startJetstreamTail(ctx, be.Host, cursor, maxWorkers); err != nil {
96
+
slog.Error("jetstream connection lost", "host", be.Host, "error", err)
97
+
}
98
+
99
+
elapsed := time.Since(start)
100
+
101
+
if elapsed > failureTimeInterval {
102
+
failures = 0
103
+
continue
104
+
}
105
+
failures++
106
+
107
+
delay := delayForFailureCount(failures)
108
+
slog.Warn("retrying jetstream connection after delay", "host", be.Host, "delay", delay)
109
+
time.Sleep(delay)
110
+
}
111
+
}
112
+
113
+
func delayForFailureCount(n int) time.Duration {
114
+
if n < 5 {
115
+
return (time.Second * 5) + (time.Second * 2 * time.Duration(n))
116
+
}
117
+
118
+
return time.Second * 30
119
+
}
120
+
121
+
func (s *Server) startLiveTail(ctx context.Context, host string, curs int, parWorkers, maxQ int) error {
122
+
ctx, cancel := context.WithCancel(ctx)
123
+
defer cancel()
124
+
125
+
slog.Info("starting live tail")
126
+
127
+
// Connect to the Relay websocket
128
+
urlStr := fmt.Sprintf("wss://%s/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", host, curs)
129
+
130
+
d := websocket.DefaultDialer
131
+
con, _, err := d.Dial(urlStr, http.Header{
132
+
"User-Agent": []string{"konbini/0.0.1"},
133
+
})
134
+
if err != nil {
135
+
return fmt.Errorf("failed to connect to relay: %w", err)
136
+
}
137
+
138
+
var lelk sync.Mutex
139
+
lastEvent := time.Now()
140
+
141
+
go func() {
142
+
tick := time.NewTicker(time.Second)
143
+
defer tick.Stop()
144
+
for {
145
+
select {
146
+
case <-tick.C:
147
+
lelk.Lock()
148
+
let := lastEvent
149
+
lelk.Unlock()
150
+
151
+
if time.Since(let) > time.Second*30 {
152
+
slog.Error("firehose connection timed out")
153
+
con.Close()
154
+
return
155
+
}
156
+
case <-ctx.Done():
157
+
return
158
+
}
159
+
}
160
+
}()
161
+
162
+
var cclk sync.Mutex
163
+
var completeCursor int64
164
+
165
+
rsc := &stream.RepoStreamCallbacks{
166
+
RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error {
167
+
ctx := context.Background()
168
+
169
+
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq))
170
+
171
+
s.seqLk.Lock()
172
+
if evt.Seq > s.lastSeq {
173
+
curs = int(evt.Seq)
174
+
s.lastSeq = evt.Seq
175
+
176
+
if evt.Seq%1000 == 0 {
177
+
if err := storeLastSeq(s.db, host, evt.Seq); err != nil {
178
+
fmt.Println("failed to store seqno: ", err)
179
+
}
180
+
}
181
+
}
182
+
s.seqLk.Unlock()
183
+
184
+
lelk.Lock()
185
+
lastEvent = time.Now()
186
+
lelk.Unlock()
187
+
188
+
if err := s.backend.HandleEvent(ctx, evt); err != nil {
189
+
return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err)
190
+
}
191
+
192
+
cclk.Lock()
193
+
if evt.Seq > completeCursor {
194
+
completeCursor = evt.Seq
195
+
firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq))
196
+
}
197
+
cclk.Unlock()
198
+
199
+
return nil
200
+
},
201
+
RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error {
202
+
return nil
203
+
},
204
+
// TODO: all the other event types
205
+
Error: func(errf *stream.ErrorFrame) error {
206
+
return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message)
207
+
},
208
+
}
209
+
210
+
sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler)
211
+
212
+
return stream.HandleRepoStream(ctx, con, sched, slog.Default())
213
+
}
214
+
215
+
func (s *Server) startJetstreamTail(ctx context.Context, host string, cursor int64, parWorkers int) error {
216
+
ctx, cancel := context.WithCancel(ctx)
217
+
defer cancel()
218
+
219
+
slog.Info("starting jetstream tail", "host", host, "cursor", cursor)
220
+
221
+
// Create a scheduler for parallel processing
222
+
lastStored := int64(0)
223
+
sched := jsparallel.NewScheduler(
224
+
parWorkers,
225
+
host,
226
+
slog.Default(),
227
+
func(ctx context.Context, event *models.Event) error {
228
+
// Update cursor tracking
229
+
s.seqLk.Lock()
230
+
if event.TimeUS > s.lastSeq {
231
+
s.lastSeq = event.TimeUS
232
+
if event.TimeUS-lastStored > 1_000_000 {
233
+
// Store checkpoint periodically
234
+
if err := storeLastSeq(s.db, host, event.TimeUS); err != nil {
235
+
slog.Error("failed to store jetstream cursor", "error", err)
236
+
}
237
+
lastStored = event.TimeUS
238
+
}
239
+
}
240
+
s.seqLk.Unlock()
241
+
242
+
// Update metrics
243
+
firehoseCursorGauge.WithLabelValues("ingest").Set(float64(event.TimeUS))
244
+
245
+
// Convert Jetstream event to ATProto event format
246
+
if event.Commit != nil {
247
+
248
+
if err := s.backend.HandleEventJetstream(ctx, event); err != nil {
249
+
return fmt.Errorf("handle event (%s,%d): %w", event.Did, event.TimeUS, err)
250
+
}
251
+
252
+
firehoseCursorGauge.WithLabelValues("complete").Set(float64(event.TimeUS))
253
+
}
254
+
255
+
return nil
256
+
},
257
+
)
258
+
259
+
// Configure Jetstream client
260
+
config := jsclient.DefaultClientConfig()
261
+
config.WebsocketURL = fmt.Sprintf("wss://%s/subscribe", host)
262
+
263
+
// Prepare cursor pointer
264
+
var cursorPtr *int64
265
+
if cursor > 0 {
266
+
cursorPtr = &cursor
267
+
}
268
+
269
+
// Create and connect client
270
+
client, err := jsclient.NewClient(
271
+
config,
272
+
slog.Default(),
273
+
sched,
274
+
)
275
+
if err != nil {
276
+
return fmt.Errorf("create jetstream client: %w", err)
277
+
}
278
+
279
+
// Start reading from Jetstream
280
+
return client.ConnectAndRead(ctx, cursorPtr)
281
+
}
+102
views/actor.go
+102
views/actor.go
···
1
+
package views
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/api/bsky"
7
+
"github.com/bluesky-social/indigo/lex/util"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
)
10
+
11
+
// ProfileViewBasic builds a basic profile view (app.bsky.actor.defs#profileViewBasic)
12
+
func ProfileViewBasic(actor *hydration.ActorInfo) *bsky.ActorDefs_ProfileViewBasic {
13
+
view := &bsky.ActorDefs_ProfileViewBasic{
14
+
Did: actor.DID,
15
+
Handle: actor.Handle,
16
+
}
17
+
18
+
if actor.Profile != nil {
19
+
if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" {
20
+
view.DisplayName = actor.Profile.DisplayName
21
+
}
22
+
if actor.Profile.Avatar != nil {
23
+
avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar)
24
+
if avatarURL != "" {
25
+
view.Avatar = &avatarURL
26
+
}
27
+
}
28
+
}
29
+
30
+
return view
31
+
}
32
+
33
+
// ProfileView builds a profile view (app.bsky.actor.defs#profileView)
34
+
func ProfileView(actor *hydration.ActorInfo) *bsky.ActorDefs_ProfileView {
35
+
view := &bsky.ActorDefs_ProfileView{
36
+
Did: actor.DID,
37
+
Handle: actor.Handle,
38
+
}
39
+
40
+
if actor.Profile != nil {
41
+
if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" {
42
+
view.DisplayName = actor.Profile.DisplayName
43
+
}
44
+
if actor.Profile.Description != nil && *actor.Profile.Description != "" {
45
+
view.Description = actor.Profile.Description
46
+
}
47
+
if actor.Profile.Avatar != nil {
48
+
avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar)
49
+
if avatarURL != "" {
50
+
view.Avatar = &avatarURL
51
+
}
52
+
}
53
+
// Note: CreatedAt is typically set on the profile record itself
54
+
}
55
+
56
+
return view
57
+
}
58
+
59
+
// ProfileViewDetailed builds a detailed profile view (app.bsky.actor.defs#profileViewDetailed)
60
+
func ProfileViewDetailed(actor *hydration.ActorInfoDetailed) *bsky.ActorDefs_ProfileViewDetailed {
61
+
view := &bsky.ActorDefs_ProfileViewDetailed{
62
+
Did: actor.DID,
63
+
Handle: actor.Handle,
64
+
}
65
+
66
+
if actor.Profile != nil {
67
+
if actor.Profile.DisplayName != nil && *actor.Profile.DisplayName != "" {
68
+
view.DisplayName = actor.Profile.DisplayName
69
+
}
70
+
if actor.Profile.Description != nil && *actor.Profile.Description != "" {
71
+
view.Description = actor.Profile.Description
72
+
}
73
+
if actor.Profile.Avatar != nil {
74
+
avatarURL := formatBlobRef(actor.DID, actor.Profile.Avatar)
75
+
if avatarURL != "" {
76
+
view.Avatar = &avatarURL
77
+
}
78
+
}
79
+
if actor.Profile.Banner != nil {
80
+
bannerURL := formatBlobRef(actor.DID, actor.Profile.Banner)
81
+
if bannerURL != "" {
82
+
view.Banner = &bannerURL
83
+
}
84
+
}
85
+
}
86
+
87
+
// Add counts
88
+
view.FollowersCount = &actor.FollowerCount
89
+
view.FollowsCount = &actor.FollowCount
90
+
view.PostsCount = &actor.PostCount
91
+
92
+
// Add viewer state if available
93
+
if actor.ViewerState != nil {
94
+
view.Viewer = actor.ViewerState
95
+
}
96
+
97
+
return view
98
+
}
99
+
100
+
func formatBlobRef(did string, blob *util.LexBlob) string {
101
+
return fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", did, blob.Ref.String())
102
+
}
+117
views/feed.go
+117
views/feed.go
···
1
+
package views
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/api/bsky"
7
+
"github.com/bluesky-social/indigo/lex/util"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
)
10
+
11
+
// PostView builds a post view (app.bsky.feed.defs#postView)
12
+
func PostView(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_PostView {
13
+
view := &bsky.FeedDefs_PostView{
14
+
LexiconTypeID: "app.bsky.feed.defs#postView",
15
+
Uri: post.URI,
16
+
Cid: post.Cid,
17
+
Author: ProfileViewBasic(author),
18
+
Record: &util.LexiconTypeDecoder{
19
+
Val: post.Post,
20
+
},
21
+
IndexedAt: post.Post.CreatedAt, // Using createdAt as indexedAt for now
22
+
}
23
+
24
+
// Add engagement counts
25
+
if post.LikeCount > 0 {
26
+
lc := int64(post.LikeCount)
27
+
view.LikeCount = &lc
28
+
}
29
+
if post.RepostCount > 0 {
30
+
rc := int64(post.RepostCount)
31
+
view.RepostCount = &rc
32
+
}
33
+
if post.ReplyCount > 0 {
34
+
rpc := int64(post.ReplyCount)
35
+
view.ReplyCount = &rpc
36
+
}
37
+
38
+
// Add viewer state
39
+
if post.ViewerLike != "" {
40
+
view.Viewer = &bsky.FeedDefs_ViewerState{
41
+
Like: &post.ViewerLike,
42
+
}
43
+
}
44
+
45
+
// Add embed if it was hydrated
46
+
if post.EmbedInfo != nil {
47
+
view.Embed = post.EmbedInfo
48
+
}
49
+
50
+
return view
51
+
}
52
+
53
+
// FeedViewPost builds a feed view post (app.bsky.feed.defs#feedViewPost)
54
+
func FeedViewPost(post *hydration.PostInfo, author *hydration.ActorInfo) *bsky.FeedDefs_FeedViewPost {
55
+
return &bsky.FeedDefs_FeedViewPost{
56
+
Post: PostView(post, author),
57
+
}
58
+
}
59
+
60
+
// ThreadViewPost builds a thread view post (app.bsky.feed.defs#threadViewPost)
61
+
func ThreadViewPost(post *hydration.PostInfo, author *hydration.ActorInfo, parent, replies any) *bsky.FeedDefs_ThreadViewPost {
62
+
view := &bsky.FeedDefs_ThreadViewPost{
63
+
LexiconTypeID: "app.bsky.feed.defs#threadViewPost",
64
+
Post: PostView(post, author),
65
+
}
66
+
67
+
// TODO: Type parent and replies properly as union types
68
+
// For now leaving them as interface{} to be handled by handlers
69
+
70
+
return view
71
+
}
72
+
73
+
// GeneratorView builds a feed generator view (app.bsky.feed.defs#generatorView)
74
+
func GeneratorView(uri, cid string, record *bsky.FeedGenerator, creator *hydration.ActorInfo, likeCount int64, viewerLike string, indexedAt string) *bsky.FeedDefs_GeneratorView {
75
+
view := &bsky.FeedDefs_GeneratorView{
76
+
LexiconTypeID: "app.bsky.feed.defs#generatorView",
77
+
Uri: uri,
78
+
Cid: cid,
79
+
Did: record.Did,
80
+
Creator: ProfileView(creator),
81
+
DisplayName: record.DisplayName,
82
+
Description: record.Description,
83
+
IndexedAt: indexedAt,
84
+
}
85
+
86
+
// Add optional fields
87
+
if record.Avatar != nil {
88
+
avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar/plain/%s/%s@jpeg", creator.DID, record.Avatar.Ref.String())
89
+
view.Avatar = &avatarURL
90
+
}
91
+
92
+
if record.DescriptionFacets != nil && len(record.DescriptionFacets) > 0 {
93
+
view.DescriptionFacets = record.DescriptionFacets
94
+
}
95
+
96
+
if record.AcceptsInteractions != nil {
97
+
view.AcceptsInteractions = record.AcceptsInteractions
98
+
}
99
+
100
+
if record.ContentMode != nil {
101
+
view.ContentMode = record.ContentMode
102
+
}
103
+
104
+
// Add like count if present
105
+
if likeCount > 0 {
106
+
view.LikeCount = &likeCount
107
+
}
108
+
109
+
// Add viewer state if viewer has liked
110
+
if viewerLike != "" {
111
+
view.Viewer = &bsky.FeedDefs_GeneratorViewerState{
112
+
Like: &viewerLike,
113
+
}
114
+
}
115
+
116
+
return view
117
+
}
+111
xrpc/actor/getPreferences.go
+111
xrpc/actor/getPreferences.go
···
1
+
package actor
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/bluesky-social/indigo/api/bsky"
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"gorm.io/gorm"
10
+
)
11
+
12
+
// HandleGetPreferences implements app.bsky.actor.getPreferences
13
+
// This is typically a PDS endpoint, not an AppView endpoint.
14
+
// For now, return empty preferences.
15
+
func HandleGetPreferences(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
+
// Get viewer from authentication
17
+
viewer := c.Get("viewer")
18
+
if viewer == nil {
19
+
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
20
+
"error": "AuthenticationRequired",
21
+
"message": "authentication required",
22
+
})
23
+
}
24
+
25
+
out := bsky.ActorGetPreferences_Output{
26
+
Preferences: []bsky.ActorDefs_Preferences_Elem{
27
+
{
28
+
ActorDefs_AdultContentPref: &bsky.ActorDefs_AdultContentPref{
29
+
Enabled: true,
30
+
},
31
+
},
32
+
{
33
+
ActorDefs_ContentLabelPref: &bsky.ActorDefs_ContentLabelPref{
34
+
Label: "nsfw",
35
+
Visibility: "warn",
36
+
},
37
+
},
38
+
/*
39
+
{
40
+
ActorDefs_LabelersPref: &bsky.ActorDefs_LabelersPref{
41
+
Labelers: []*bsky.ActorDefs_LabelerPrefItem{},
42
+
},
43
+
},
44
+
*/
45
+
{
46
+
ActorDefs_BskyAppStatePref: &bsky.ActorDefs_BskyAppStatePref{
47
+
Nuxs: []*bsky.ActorDefs_Nux{
48
+
{
49
+
Id: "NeueTypography",
50
+
Completed: true,
51
+
},
52
+
{
53
+
Id: "PolicyUpdate202508",
54
+
Completed: true,
55
+
},
56
+
},
57
+
},
58
+
},
59
+
{
60
+
ActorDefs_SavedFeedsPrefV2: &bsky.ActorDefs_SavedFeedsPrefV2{
61
+
Items: []*bsky.ActorDefs_SavedFeed{
62
+
{
63
+
Id: "3m2k6cbfsq22n",
64
+
Pinned: true,
65
+
Type: "timeline",
66
+
Value: "following",
67
+
},
68
+
},
69
+
},
70
+
},
71
+
},
72
+
}
73
+
74
+
return c.JSON(http.StatusOK, out)
75
+
}
76
+
77
+
/*
78
+
{
79
+
"nuxs": [
80
+
{
81
+
"id": "TenMillionDialog",
82
+
"completed": true
83
+
},
84
+
{
85
+
"id": "NeueTypography",
86
+
"completed": true
87
+
},
88
+
{
89
+
"id": "NeueChar",
90
+
"completed": true
91
+
},
92
+
{
93
+
"id": "InitialVerificationAnnouncement",
94
+
"completed": true
95
+
},
96
+
{
97
+
"id": "ActivitySubscriptions",
98
+
"completed": true
99
+
},
100
+
{
101
+
"id": "BookmarksAnnouncement",
102
+
"completed": true
103
+
},
104
+
{
105
+
"id": "PolicyUpdate202508",
106
+
"completed": true
107
+
}
108
+
],
109
+
"$type": "app.bsky.actor.defs#bskyAppStatePref"
110
+
}
111
+
*/
+47
xrpc/actor/getProfile.go
+47
xrpc/actor/getProfile.go
···
1
+
package actor
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
"github.com/whyrusleeping/konbini/hydration"
8
+
"github.com/whyrusleeping/konbini/views"
9
+
)
10
+
11
+
// HandleGetProfile implements app.bsky.actor.getProfile
12
+
func HandleGetProfile(c echo.Context, hydrator *hydration.Hydrator) error {
13
+
actorParam := c.QueryParam("actor")
14
+
if actorParam == "" {
15
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
16
+
"error": "InvalidRequest",
17
+
"message": "actor parameter is required",
18
+
})
19
+
}
20
+
21
+
ctx := c.Request().Context()
22
+
23
+
viewer, _ := c.Get("viewer").(string)
24
+
25
+
// Resolve actor to DID
26
+
did, err := hydrator.ResolveDID(ctx, actorParam)
27
+
if err != nil {
28
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
29
+
"error": "ActorNotFound",
30
+
"message": "actor not found",
31
+
})
32
+
}
33
+
34
+
// Hydrate actor info
35
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
36
+
if err != nil {
37
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
38
+
"error": "ActorNotFound",
39
+
"message": "failed to load actor",
40
+
})
41
+
}
42
+
43
+
// Build response
44
+
profile := views.ProfileViewDetailed(actorInfo)
45
+
46
+
return c.JSON(http.StatusOK, profile)
47
+
}
+55
xrpc/actor/getProfiles.go
+55
xrpc/actor/getProfiles.go
···
1
+
package actor
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/bluesky-social/indigo/api/bsky"
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetProfiles implements app.bsky.actor.getProfiles
14
+
func HandleGetProfiles(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
// Parse actors parameter (can be multiple)
16
+
actors := c.QueryParams()["actors"]
17
+
if len(actors) == 0 {
18
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
19
+
"error": "InvalidRequest",
20
+
"message": "actors parameter is required",
21
+
})
22
+
}
23
+
24
+
// Limit to reasonable batch size
25
+
if len(actors) > 25 {
26
+
actors = actors[:25]
27
+
}
28
+
29
+
ctx := c.Request().Context()
30
+
viewer, _ := c.Get("viewer").(string)
31
+
32
+
// Resolve all actors to DIDs and hydrate profiles
33
+
profiles := make([]*bsky.ActorDefs_ProfileViewDetailed, 0, len(actors))
34
+
for _, actor := range actors {
35
+
// Resolve actor to DID
36
+
did, err := hydrator.ResolveDID(ctx, actor)
37
+
if err != nil {
38
+
// Skip actors that can't be resolved
39
+
continue
40
+
}
41
+
42
+
// Hydrate actor info
43
+
actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer)
44
+
if err != nil {
45
+
// Skip actors that can't be hydrated
46
+
continue
47
+
}
48
+
49
+
profiles = append(profiles, views.ProfileViewDetailed(actorInfo))
50
+
}
51
+
52
+
return c.JSON(http.StatusOK, map[string]interface{}{
53
+
"profiles": profiles,
54
+
})
55
+
}
+25
xrpc/actor/putPreferences.go
+25
xrpc/actor/putPreferences.go
···
1
+
package actor
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
"github.com/whyrusleeping/konbini/hydration"
8
+
"gorm.io/gorm"
9
+
)
10
+
11
+
// HandlePutPreferences implements app.bsky.actor.putPreferences
12
+
// Stubbed out for now - just returns success without doing anything
13
+
func HandlePutPreferences(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
14
+
// Get viewer from authentication
15
+
viewer := c.Get("viewer")
16
+
if viewer == nil {
17
+
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
18
+
"error": "AuthenticationRequired",
19
+
"message": "authentication required",
20
+
})
21
+
}
22
+
23
+
// For now, just return success without storing anything
24
+
return c.JSON(http.StatusOK, map[string]interface{}{})
25
+
}
+106
xrpc/auth.go
+106
xrpc/auth.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net/http"
7
+
"strings"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"github.com/labstack/echo/v4"
11
+
"github.com/lestrrat-go/jwx/v2/jwt"
12
+
)
13
+
14
+
// requireAuth is middleware that requires authentication
15
+
func (s *Server) requireAuth(next echo.HandlerFunc) echo.HandlerFunc {
16
+
return func(c echo.Context) error {
17
+
viewer, err := s.authenticate(c)
18
+
if err != nil {
19
+
return XRPCError(c, http.StatusUnauthorized, "AuthenticationRequired", err.Error())
20
+
}
21
+
c.Set("viewer", viewer)
22
+
return next(c)
23
+
}
24
+
}
25
+
26
+
// optionalAuth is middleware that optionally authenticates
27
+
func (s *Server) optionalAuth(next echo.HandlerFunc) echo.HandlerFunc {
28
+
return func(c echo.Context) error {
29
+
viewer, _ := s.authenticate(c)
30
+
if viewer != "" {
31
+
c.Set("viewer", viewer)
32
+
}
33
+
return next(c)
34
+
}
35
+
}
36
+
37
+
// authenticate extracts and validates the JWT from the Authorization header
38
+
// Returns the viewer DID if valid, empty string otherwise
39
+
func (s *Server) authenticate(c echo.Context) (string, error) {
40
+
authHeader := c.Request().Header.Get("Authorization")
41
+
if authHeader == "" {
42
+
return "", fmt.Errorf("missing authorization header")
43
+
}
44
+
45
+
// Extract Bearer token
46
+
parts := strings.Split(authHeader, " ")
47
+
if len(parts) != 2 || parts[0] != "Bearer" {
48
+
return "", fmt.Errorf("invalid authorization header format")
49
+
}
50
+
51
+
tokenString := parts[1]
52
+
53
+
// Parse JWT without signature validation (for development)
54
+
// In production, you'd want to validate the signature using the issuer's public key
55
+
token, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false))
56
+
if err != nil {
57
+
return "", fmt.Errorf("failed to parse token: %w", err)
58
+
}
59
+
60
+
// Extract the user's DID - try both "sub" (PDS tokens) and "iss" (service tokens)
61
+
var userDID string
62
+
63
+
// First try "sub" claim (used by PDS tokens and entryway tokens)
64
+
sub := token.Subject()
65
+
if sub != "" && strings.HasPrefix(sub, "did:") {
66
+
userDID = sub
67
+
} else {
68
+
// Fall back to "iss" claim (used by some service tokens)
69
+
iss := token.Issuer()
70
+
if iss != "" && strings.HasPrefix(iss, "did:") {
71
+
userDID = iss
72
+
}
73
+
}
74
+
75
+
if userDID == "" {
76
+
return "", fmt.Errorf("missing 'sub' or 'iss' claim with DID in token")
77
+
}
78
+
79
+
// Optional: check scope if present
80
+
scope, ok := token.Get("scope")
81
+
if ok {
82
+
scopeStr, _ := scope.(string)
83
+
// Valid scopes are: com.atproto.access, com.atproto.appPass, com.atproto.appPassPrivileged
84
+
if scopeStr != "com.atproto.access" && scopeStr != "com.atproto.appPass" && scopeStr != "com.atproto.appPassPrivileged" {
85
+
return "", fmt.Errorf("invalid token scope: %s", scopeStr)
86
+
}
87
+
}
88
+
89
+
return userDID, nil
90
+
}
91
+
92
+
// resolveActor resolves an actor identifier (handle or DID) to a DID
93
+
func (s *Server) resolveActor(ctx context.Context, actor string) (string, error) {
94
+
// If it's already a DID, return it
95
+
if strings.HasPrefix(actor, "did:") {
96
+
return actor, nil
97
+
}
98
+
99
+
// Otherwise, resolve the handle
100
+
resp, err := s.dir.LookupHandle(ctx, syntax.Handle(actor))
101
+
if err != nil {
102
+
return "", fmt.Errorf("failed to resolve handle: %w", err)
103
+
}
104
+
105
+
return resp.DID.String(), nil
106
+
}
+119
xrpc/feed/getActorLikes.go
+119
xrpc/feed/getActorLikes.go
···
1
+
package feed
2
+
3
+
import (
4
+
"net/http"
5
+
"strconv"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetActorLikes implements app.bsky.feed.getActorLikes
14
+
func HandleGetActorLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
actorParam := c.QueryParam("actor")
16
+
if actorParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "actor parameter is required",
20
+
})
21
+
}
22
+
23
+
ctx := c.Request().Context()
24
+
25
+
// Resolve actor to DID
26
+
actorDID, err := hydrator.ResolveDID(ctx, actorParam)
27
+
if err != nil {
28
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
29
+
"error": "ActorNotFound",
30
+
"message": "actor not found",
31
+
})
32
+
}
33
+
34
+
// Check authentication - user can only view their own likes
35
+
viewer := c.Get("viewer")
36
+
if viewer == nil || viewer.(string) != actorDID {
37
+
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
38
+
"error": "AuthenticationRequired",
39
+
"message": "you can only view your own likes",
40
+
})
41
+
}
42
+
43
+
// Parse limit
44
+
limit := 50
45
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
46
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
47
+
limit = l
48
+
}
49
+
}
50
+
51
+
// Parse cursor (like ID)
52
+
var cursor uint
53
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
54
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
55
+
cursor = uint(c)
56
+
}
57
+
}
58
+
59
+
// Query likes
60
+
type likeRow struct {
61
+
ID uint
62
+
Subject string // post URI
63
+
}
64
+
var rows []likeRow
65
+
66
+
query := `
67
+
SELECT l.id, 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as subject
68
+
FROM likes l
69
+
JOIN posts p ON p.id = l.subject
70
+
JOIN repos r ON r.id = p.author
71
+
WHERE l.author = (SELECT id FROM repos WHERE did = ?)
72
+
`
73
+
if cursor > 0 {
74
+
query += ` AND l.id < ?`
75
+
}
76
+
query += ` ORDER BY l.id DESC LIMIT ?`
77
+
78
+
var queryArgs []interface{}
79
+
queryArgs = append(queryArgs, actorDID)
80
+
if cursor > 0 {
81
+
queryArgs = append(queryArgs, cursor)
82
+
}
83
+
queryArgs = append(queryArgs, limit)
84
+
85
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
86
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
87
+
"error": "InternalError",
88
+
"message": "failed to query likes",
89
+
})
90
+
}
91
+
92
+
// Hydrate posts
93
+
feed := make([]interface{}, 0)
94
+
for _, row := range rows {
95
+
postInfo, err := hydrator.HydratePost(ctx, row.Subject, actorDID)
96
+
if err != nil {
97
+
continue
98
+
}
99
+
100
+
// Hydrate the post author
101
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
102
+
if err != nil {
103
+
continue
104
+
}
105
+
106
+
feed = append(feed, views.FeedViewPost(postInfo, authorInfo))
107
+
}
108
+
109
+
// Generate next cursor
110
+
var nextCursor string
111
+
if len(rows) > 0 {
112
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
113
+
}
114
+
115
+
return c.JSON(http.StatusOK, map[string]interface{}{
116
+
"feed": feed,
117
+
"cursor": nextCursor,
118
+
})
119
+
}
+207
xrpc/feed/getAuthorFeed.go
+207
xrpc/feed/getAuthorFeed.go
···
1
+
package feed
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"net/http"
7
+
"strconv"
8
+
"strings"
9
+
"sync"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"github.com/labstack/echo/v4"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
"github.com/whyrusleeping/konbini/views"
17
+
"gorm.io/gorm"
18
+
)
19
+
20
+
type postRow struct {
21
+
URI string
22
+
AuthorID uint
23
+
}
24
+
25
+
// HandleGetAuthorFeed implements app.bsky.feed.getAuthorFeed
26
+
func HandleGetAuthorFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
27
+
actorParam := c.QueryParam("actor")
28
+
if actorParam == "" {
29
+
return c.JSON(http.StatusBadRequest, map[string]any{
30
+
"error": "InvalidRequest",
31
+
"message": "actor parameter is required",
32
+
})
33
+
}
34
+
35
+
// Parse limit
36
+
limit := 50
37
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
38
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
39
+
limit = l
40
+
}
41
+
}
42
+
43
+
// Parse cursor (timestamp)
44
+
cursor := time.Now()
45
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
46
+
if t, err := time.Parse(time.RFC3339, cursorParam); err == nil {
47
+
cursor = t
48
+
}
49
+
}
50
+
51
+
// Parse filter (posts_with_replies, posts_no_replies, posts_with_media, etc.)
52
+
filter := c.QueryParam("filter")
53
+
if filter == "" {
54
+
filter = "posts_with_replies" // default
55
+
}
56
+
57
+
ctx := c.Request().Context()
58
+
viewer := getUserDID(c)
59
+
60
+
// Resolve actor to DID
61
+
did, err := hydrator.ResolveDID(ctx, actorParam)
62
+
if err != nil {
63
+
return c.JSON(http.StatusBadRequest, map[string]any{
64
+
"error": "ActorNotFound",
65
+
"message": "actor not found",
66
+
})
67
+
}
68
+
69
+
// Build query based on filter
70
+
var query string
71
+
switch filter {
72
+
case "posts_no_replies", "posts_and_author_threads":
73
+
query = `
74
+
SELECT
75
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
76
+
p.author as author_id
77
+
FROM posts p
78
+
JOIN repos r ON r.id = p.author
79
+
WHERE p.author = (SELECT id FROM repos WHERE did = ?)
80
+
AND p.reply_to = 0
81
+
AND p.created < ?
82
+
AND p.not_found = false
83
+
ORDER BY p.created DESC
84
+
LIMIT ?
85
+
`
86
+
default: // posts_with_replies
87
+
query = `
88
+
SELECT
89
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
90
+
p.author as author_id
91
+
FROM posts p
92
+
JOIN repos r ON r.id = p.author
93
+
WHERE p.author = (SELECT id FROM repos WHERE did = ?)
94
+
AND p.created < ?
95
+
AND p.not_found = false
96
+
ORDER BY p.created DESC
97
+
LIMIT ?
98
+
`
99
+
}
100
+
101
+
var rows []postRow
102
+
if err := db.Raw(query, did, cursor, limit).Scan(&rows).Error; err != nil {
103
+
return c.JSON(http.StatusInternalServerError, map[string]any{
104
+
"error": "InternalError",
105
+
"message": "failed to query author feed",
106
+
})
107
+
}
108
+
109
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
110
+
111
+
// Generate next cursor
112
+
var nextCursor string
113
+
if len(rows) > 0 {
114
+
lastURI := rows[len(rows)-1].URI
115
+
postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer)
116
+
if err == nil && postInfo.Post != nil {
117
+
t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt)
118
+
if err == nil {
119
+
nextCursor = t.Format(time.RFC3339)
120
+
}
121
+
}
122
+
}
123
+
124
+
return c.JSON(http.StatusOK, map[string]any{
125
+
"feed": feed,
126
+
"cursor": nextCursor,
127
+
})
128
+
}
129
+
130
+
func hydratePostRows(ctx context.Context, hydrator *hydration.Hydrator, viewer string, rows []postRow) []*bsky.FeedDefs_FeedViewPost {
131
+
ctx, span := tracer.Start(ctx, "hydratePostRows")
132
+
defer span.End()
133
+
134
+
// Hydrate posts
135
+
var wg sync.WaitGroup
136
+
137
+
var outLk sync.Mutex
138
+
feed := make([]*bsky.FeedDefs_FeedViewPost, len(rows))
139
+
for i, row := range rows {
140
+
wg.Add(1)
141
+
go func(i int, row postRow) {
142
+
defer wg.Done()
143
+
144
+
puri, err := syntax.ParseATURI(row.URI)
145
+
if err != nil {
146
+
slog.Error("row had invalid uri", "uri", row.URI, "error", err)
147
+
return
148
+
}
149
+
150
+
var subwg sync.WaitGroup
151
+
152
+
var postInfo *hydration.PostInfo
153
+
subwg.Go(func() {
154
+
pi, err := hydrator.HydratePost(ctx, row.URI, viewer)
155
+
if err != nil {
156
+
if strings.Contains(err.Error(), "post not found") {
157
+
hydrator.AddMissingRecord(row.URI, true)
158
+
pi, err = hydrator.HydratePost(ctx, row.URI, viewer)
159
+
if err != nil {
160
+
slog.Error("failed to hydrate post after fetch missing", "uri", row.URI, "error", err)
161
+
return
162
+
}
163
+
} else {
164
+
slog.Warn("failed to hydrate post", "uri", row.URI, "error", err)
165
+
return
166
+
}
167
+
}
168
+
postInfo = pi
169
+
})
170
+
171
+
var authorInfo *hydration.ActorInfo
172
+
subwg.Go(func() {
173
+
ai, err := hydrator.HydrateActor(ctx, puri.Authority().String())
174
+
if err != nil {
175
+
hydrator.AddMissingRecord(postInfo.Author, false)
176
+
slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err)
177
+
return
178
+
}
179
+
authorInfo = ai
180
+
})
181
+
182
+
subwg.Wait()
183
+
184
+
if postInfo == nil || authorInfo == nil {
185
+
return
186
+
}
187
+
188
+
feedItem := views.FeedViewPost(postInfo, authorInfo)
189
+
outLk.Lock()
190
+
feed[i] = feedItem
191
+
outLk.Unlock()
192
+
}(i, row)
193
+
}
194
+
wg.Wait()
195
+
196
+
x := 0
197
+
for i := 0; i < len(feed); i++ {
198
+
if feed[i] != nil {
199
+
feed[x] = feed[i]
200
+
x++
201
+
continue
202
+
}
203
+
}
204
+
feed = feed[:x]
205
+
206
+
return feed
207
+
}
+200
xrpc/feed/getFeed.go
+200
xrpc/feed/getFeed.go
···
1
+
package feed
2
+
3
+
import (
4
+
"bytes"
5
+
"log/slog"
6
+
"net/http"
7
+
"strconv"
8
+
"strings"
9
+
"sync"
10
+
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/atproto/identity"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
"github.com/bluesky-social/indigo/xrpc"
15
+
"github.com/labstack/echo/v4"
16
+
"github.com/whyrusleeping/konbini/hydration"
17
+
"github.com/whyrusleeping/konbini/views"
18
+
"github.com/whyrusleeping/market/models"
19
+
"gorm.io/gorm"
20
+
)
21
+
22
+
// HandleGetFeed implements app.bsky.feed.getFeed
23
+
// Gets posts from a custom feed generator
24
+
func HandleGetFeed(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator, dir identity.Directory) error {
25
+
// Parse parameters
26
+
feedURI := c.QueryParam("feed")
27
+
if feedURI == "" {
28
+
return c.JSON(http.StatusBadRequest, map[string]any{
29
+
"error": "InvalidRequest",
30
+
"message": "feed parameter is required",
31
+
})
32
+
}
33
+
34
+
// Parse limit
35
+
limit := int64(50)
36
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
37
+
if l, err := strconv.ParseInt(limitParam, 10, 64); err == nil && l > 0 && l <= 100 {
38
+
limit = l
39
+
}
40
+
}
41
+
42
+
// Parse cursor
43
+
cursor := c.QueryParam("cursor")
44
+
45
+
ctx := c.Request().Context()
46
+
viewer := getUserDID(c)
47
+
48
+
// Extract feed generator DID and rkey from URI
49
+
// URI format: at://did:plc:xxx/app.bsky.feed.generator/rkey
50
+
did := extractDIDFromURI(feedURI)
51
+
rkey := extractRkeyFromURI(feedURI)
52
+
53
+
if did == "" || rkey == "" {
54
+
return c.JSON(http.StatusBadRequest, map[string]any{
55
+
"error": "InvalidRequest",
56
+
"message": "invalid feed URI format",
57
+
})
58
+
}
59
+
60
+
// Check if feed generator exists in database
61
+
var feedGen models.FeedGenerator
62
+
if err := db.Raw(`
63
+
SELECT * FROM feed_generators fg WHERE fg.author = (select id from repos where did = ?) AND fg.rkey = ?
64
+
`, did, rkey).Scan(&feedGen).Error; err != nil {
65
+
return err
66
+
}
67
+
68
+
if feedGen.ID == 0 {
69
+
hydrator.AddMissingRecord(feedURI, true)
70
+
return c.JSON(http.StatusNotFound, map[string]any{
71
+
"error": "NotFound",
72
+
"message": "feed generator not found",
73
+
})
74
+
}
75
+
76
+
// Decode the feed generator record to get the service DID
77
+
var feedGenRecord bsky.FeedGenerator
78
+
if err := feedGenRecord.UnmarshalCBOR(bytes.NewReader(feedGen.Raw)); err != nil {
79
+
slog.Error("failed to decode feed generator record", "error", err)
80
+
return c.JSON(http.StatusInternalServerError, map[string]any{
81
+
"error": "InternalError",
82
+
"message": "failed to decode feed generator record",
83
+
})
84
+
}
85
+
86
+
// Parse the service DID
87
+
serviceDID, err := syntax.ParseDID(feedGenRecord.Did)
88
+
if err != nil {
89
+
slog.Error("invalid service DID in feed generator", "error", err, "did", feedGenRecord.Did)
90
+
return c.JSON(http.StatusInternalServerError, map[string]any{
91
+
"error": "InternalError",
92
+
"message": "invalid service DID",
93
+
})
94
+
}
95
+
96
+
// Resolve the service DID to get its endpoint
97
+
serviceIdent, err := dir.LookupDID(ctx, serviceDID)
98
+
if err != nil {
99
+
slog.Error("failed to resolve service DID", "error", err, "did", serviceDID)
100
+
return c.JSON(http.StatusInternalServerError, map[string]any{
101
+
"error": "InternalError",
102
+
"message": "failed to resolve service endpoint",
103
+
})
104
+
}
105
+
106
+
serviceEndpoint := serviceIdent.GetServiceEndpoint("bsky_fg")
107
+
if serviceEndpoint == "" {
108
+
slog.Error("service has no bsky_fg endpoint", "did", serviceDID)
109
+
return c.JSON(http.StatusInternalServerError, map[string]any{
110
+
"error": "InternalError",
111
+
"message": "service has no endpoint",
112
+
})
113
+
}
114
+
115
+
// Create XRPC client for the feed generator service
116
+
// Pass through headers from the original request so feed generators can
117
+
// customize feeds based on the viewer
118
+
headers := make(map[string]string)
119
+
120
+
// Set User-Agent to identify konbini
121
+
headers["User-Agent"] = "konbini/0.0.1"
122
+
123
+
// Pass through Authorization header if present (for authenticated feed requests)
124
+
if authHeader := c.Request().Header.Get("Authorization"); authHeader != "" {
125
+
headers["Authorization"] = authHeader
126
+
}
127
+
128
+
// Pass through Accept-Language header if present
129
+
if langHeader := c.Request().Header.Get("Accept-Language"); langHeader != "" {
130
+
headers["Accept-Language"] = langHeader
131
+
}
132
+
133
+
// Pass through X-Bsky-Topics header if present
134
+
if topicsHeader := c.Request().Header.Get("X-Bsky-Topics"); topicsHeader != "" {
135
+
headers["X-Bsky-Topics"] = topicsHeader
136
+
}
137
+
138
+
client := &xrpc.Client{
139
+
Host: serviceEndpoint,
140
+
Headers: headers,
141
+
}
142
+
143
+
// Call getFeedSkeleton on the service
144
+
skeleton, err := bsky.FeedGetFeedSkeleton(ctx, client, cursor, feedURI, limit)
145
+
if err != nil {
146
+
slog.Error("failed to call getFeedSkeleton", "error", err, "service", serviceEndpoint)
147
+
// Return empty feed on error rather than failing completely
148
+
return c.JSON(http.StatusOK, &bsky.FeedGetFeed_Output{
149
+
Feed: make([]*bsky.FeedDefs_FeedViewPost, 0),
150
+
})
151
+
}
152
+
153
+
// Hydrate the posts from the skeleton
154
+
posts := make([]*bsky.FeedDefs_FeedViewPost, len(skeleton.Feed))
155
+
var wg sync.WaitGroup
156
+
for i := range skeleton.Feed {
157
+
wg.Add(1)
158
+
go func(ix int) {
159
+
defer wg.Done()
160
+
skeletonPost := skeleton.Feed[ix]
161
+
postURI, err := syntax.ParseATURI(skeletonPost.Post)
162
+
if err != nil {
163
+
slog.Warn("invalid post URI in skeleton", "uri", skeletonPost.Post, "error", err)
164
+
return
165
+
}
166
+
167
+
postInfo, err := hydrator.HydratePost(ctx, postURI.String(), viewer)
168
+
if err != nil {
169
+
if strings.Contains(err.Error(), "post not found") {
170
+
hydrator.AddMissingRecord(postURI.String(), true)
171
+
postInfo, err = hydrator.HydratePost(ctx, postURI.String(), viewer)
172
+
if err != nil {
173
+
slog.Error("failed to hydrate post after fetch missing", "uri", postURI, "error", err)
174
+
return
175
+
}
176
+
} else {
177
+
slog.Warn("failed to hydrate post", "uri", postURI, "error", err)
178
+
return
179
+
}
180
+
}
181
+
182
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
183
+
if err != nil {
184
+
hydrator.AddMissingRecord(postInfo.Author, false)
185
+
slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err)
186
+
return
187
+
}
188
+
189
+
posts[ix] = views.FeedViewPost(postInfo, authorInfo)
190
+
}(i)
191
+
}
192
+
wg.Wait()
193
+
194
+
output := &bsky.FeedGetFeed_Output{
195
+
Feed: posts,
196
+
Cursor: skeleton.Cursor,
197
+
}
198
+
199
+
return c.JSON(http.StatusOK, output)
200
+
}
+161
xrpc/feed/getFeedGenerator.go
+161
xrpc/feed/getFeedGenerator.go
···
1
+
package feed
2
+
3
+
import (
4
+
"bytes"
5
+
"log/slog"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/api/bsky"
10
+
"github.com/bluesky-social/indigo/atproto/identity"
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
cid "github.com/ipfs/go-cid"
13
+
"github.com/labstack/echo/v4"
14
+
mh "github.com/multiformats/go-multihash"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
"github.com/whyrusleeping/konbini/views"
17
+
"gorm.io/gorm"
18
+
)
19
+
20
+
// HandleGetFeedGenerator implements app.bsky.feed.getFeedGenerator
21
+
func HandleGetFeedGenerator(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator, dir identity.Directory) error {
22
+
ctx := c.Request().Context()
23
+
24
+
// Parse parameters
25
+
feedURI := c.QueryParam("feed")
26
+
if feedURI == "" {
27
+
return c.JSON(http.StatusBadRequest, map[string]any{
28
+
"error": "InvalidRequest",
29
+
"message": "feed parameter is required",
30
+
})
31
+
}
32
+
33
+
nu, err := hydrator.NormalizeUri(ctx, feedURI)
34
+
if err != nil {
35
+
return err
36
+
}
37
+
feedURI = nu
38
+
39
+
viewer := getUserDID(c)
40
+
_ = viewer
41
+
42
+
// Extract feed generator DID and rkey from URI
43
+
did := extractDIDFromURI(feedURI)
44
+
rkey := extractRkeyFromURI(feedURI)
45
+
46
+
if did == "" || rkey == "" {
47
+
return c.JSON(http.StatusBadRequest, map[string]any{
48
+
"error": "InvalidRequest",
49
+
"message": "invalid feed URI format",
50
+
})
51
+
}
52
+
53
+
// Query feed generator from database
54
+
type feedGenRow struct {
55
+
ID uint
56
+
Did string
57
+
Raw []byte
58
+
AuthorDid string
59
+
Indexed time.Time
60
+
}
61
+
var feedGen feedGenRow
62
+
err = db.Raw(`
63
+
SELECT fg.id, fg.did, fg.raw, r.did as author_did, indexed
64
+
FROM feed_generators fg
65
+
JOIN repos r ON r.id = fg.author
66
+
WHERE r.did = ? AND fg.rkey = ?
67
+
`, did, rkey).Scan(&feedGen).Error
68
+
69
+
if err != nil || feedGen.ID == 0 {
70
+
// Track this missing feed generator for fetching
71
+
hydrator.AddMissingRecord(feedURI, true)
72
+
73
+
return c.JSON(http.StatusNotFound, map[string]any{
74
+
"error": "NotFound",
75
+
"message": "feed generator not found",
76
+
})
77
+
}
78
+
79
+
// Decode the feed generator record
80
+
var feedGenRecord bsky.FeedGenerator
81
+
if err := feedGenRecord.UnmarshalCBOR(bytes.NewReader(feedGen.Raw)); err != nil {
82
+
slog.Error("failed to decode feed generator record", "error", err)
83
+
return c.JSON(http.StatusInternalServerError, map[string]any{
84
+
"error": "InternalError",
85
+
"message": "failed to decode feed generator record",
86
+
})
87
+
}
88
+
89
+
// Compute CID from raw bytes
90
+
hash, err := mh.Sum(feedGen.Raw, mh.SHA2_256, -1)
91
+
if err != nil {
92
+
slog.Error("failed to hash record", "error", err)
93
+
return c.JSON(http.StatusInternalServerError, map[string]any{
94
+
"error": "InternalError",
95
+
"message": "failed to compute CID",
96
+
})
97
+
}
98
+
recordCid := cid.NewCidV1(cid.DagCBOR, hash).String()
99
+
100
+
// Hydrate the creator
101
+
creatorInfo, err := hydrator.HydrateActor(ctx, feedGen.AuthorDid)
102
+
if err != nil {
103
+
slog.Error("failed to hydrate creator", "error", err, "did", feedGen.AuthorDid)
104
+
return c.JSON(http.StatusInternalServerError, map[string]any{
105
+
"error": "InternalError",
106
+
"message": "failed to hydrate creator",
107
+
})
108
+
}
109
+
110
+
// Count likes for this feed generator
111
+
var likeCount int64
112
+
113
+
// Check if viewer has liked this feed generator
114
+
viewerLike := ""
115
+
116
+
// Validate the service DID (check if it's resolvable)
117
+
serviceDID, err := syntax.ParseDID(feedGenRecord.Did)
118
+
if err != nil {
119
+
slog.Error("invalid service DID in feed generator", "error", err, "did", feedGenRecord.Did)
120
+
return c.JSON(http.StatusInternalServerError, map[string]any{
121
+
"error": "InternalError",
122
+
"message": "invalid service DID",
123
+
})
124
+
}
125
+
126
+
// Try to resolve the service DID to check if it's online/valid
127
+
isOnline := true
128
+
isValid := true
129
+
serviceIdent, err := dir.LookupDID(ctx, serviceDID)
130
+
if err != nil {
131
+
slog.Warn("failed to resolve service DID", "error", err, "did", serviceDID)
132
+
isOnline = false
133
+
isValid = false
134
+
} else {
135
+
// Check if service has an endpoint
136
+
serviceEndpoint := serviceIdent.PDSEndpoint()
137
+
if serviceEndpoint == "" {
138
+
slog.Warn("service has no PDS endpoint", "did", serviceDID)
139
+
isValid = false
140
+
}
141
+
}
142
+
143
+
// Build the generator view
144
+
generatorView := views.GeneratorView(
145
+
feedURI,
146
+
recordCid,
147
+
&feedGenRecord,
148
+
creatorInfo,
149
+
likeCount,
150
+
viewerLike,
151
+
feedGen.Indexed.Format(time.RFC3339),
152
+
)
153
+
154
+
output := &bsky.FeedGetFeedGenerator_Output{
155
+
View: generatorView,
156
+
IsOnline: isOnline,
157
+
IsValid: isValid,
158
+
}
159
+
160
+
return c.JSON(http.StatusOK, output)
161
+
}
+117
xrpc/feed/getLikes.go
+117
xrpc/feed/getLikes.go
···
1
+
package feed
2
+
3
+
import (
4
+
"net/http"
5
+
"strconv"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetLikes implements app.bsky.feed.getLikes
14
+
func HandleGetLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
uriParam := c.QueryParam("uri")
16
+
if uriParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "uri parameter is required",
20
+
})
21
+
}
22
+
23
+
// Parse limit
24
+
limit := 50
25
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
26
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
27
+
limit = l
28
+
}
29
+
}
30
+
31
+
// Parse cursor (like ID)
32
+
var cursor uint
33
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
34
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
35
+
cursor = uint(c)
36
+
}
37
+
}
38
+
39
+
ctx := c.Request().Context()
40
+
41
+
// Get post ID from URI
42
+
var postID uint
43
+
db.Raw(`
44
+
SELECT id FROM posts
45
+
WHERE author = (SELECT id FROM repos WHERE did = ?)
46
+
AND rkey = ?
47
+
`, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID)
48
+
49
+
if postID == 0 {
50
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
51
+
"error": "NotFound",
52
+
"message": "post not found",
53
+
})
54
+
}
55
+
56
+
// Query likes
57
+
type likeRow struct {
58
+
ID uint
59
+
AuthorDid string
60
+
Rkey string
61
+
Created string
62
+
}
63
+
var rows []likeRow
64
+
65
+
query := `
66
+
SELECT l.id, r.did as author_did, l.rkey, l.created
67
+
FROM likes l
68
+
JOIN repos r ON r.id = l.author
69
+
WHERE l.subject = ?
70
+
`
71
+
if cursor > 0 {
72
+
query += ` AND l.id < ?`
73
+
}
74
+
query += ` ORDER BY l.id DESC LIMIT ?`
75
+
76
+
var queryArgs []interface{}
77
+
queryArgs = append(queryArgs, postID)
78
+
if cursor > 0 {
79
+
queryArgs = append(queryArgs, cursor)
80
+
}
81
+
queryArgs = append(queryArgs, limit)
82
+
83
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
84
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
85
+
"error": "InternalError",
86
+
"message": "failed to query likes",
87
+
})
88
+
}
89
+
90
+
// Hydrate actors
91
+
likes := make([]interface{}, 0)
92
+
for _, row := range rows {
93
+
actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
94
+
if err != nil {
95
+
continue
96
+
}
97
+
98
+
like := map[string]interface{}{
99
+
"actor": views.ProfileView(actorInfo),
100
+
"createdAt": row.Created,
101
+
"indexedAt": row.Created,
102
+
}
103
+
likes = append(likes, like)
104
+
}
105
+
106
+
// Generate next cursor
107
+
var nextCursor string
108
+
if len(rows) > 0 {
109
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
110
+
}
111
+
112
+
return c.JSON(http.StatusOK, map[string]interface{}{
113
+
"uri": uriParam,
114
+
"likes": likes,
115
+
"cursor": nextCursor,
116
+
})
117
+
}
+187
xrpc/feed/getPostThread.go
+187
xrpc/feed/getPostThread.go
···
1
+
package feed
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net/http"
7
+
8
+
"github.com/labstack/echo/v4"
9
+
"github.com/whyrusleeping/konbini/hydration"
10
+
"github.com/whyrusleeping/konbini/views"
11
+
"gorm.io/gorm"
12
+
)
13
+
14
+
// HandleGetPostThread implements app.bsky.feed.getPostThread
15
+
func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
+
uriParam := c.QueryParam("uri")
17
+
if uriParam == "" {
18
+
return c.JSON(http.StatusBadRequest, map[string]any{
19
+
"error": "InvalidRequest",
20
+
"message": "uri parameter is required",
21
+
})
22
+
}
23
+
24
+
ctx := c.Request().Context()
25
+
viewer := getUserDID(c)
26
+
27
+
// Hydrate the requested post
28
+
postInfo, err := hydrator.HydratePost(ctx, uriParam, viewer)
29
+
if err != nil {
30
+
return c.JSON(http.StatusNotFound, map[string]any{
31
+
"error": "NotFound",
32
+
"message": "post not found",
33
+
})
34
+
}
35
+
36
+
// Determine the root post ID for the thread
37
+
rootPostID := postInfo.InThread
38
+
if rootPostID == 0 {
39
+
// This post is the root
40
+
// Query to find what the post's internal ID is
41
+
var postID uint
42
+
db.Raw(`
43
+
SELECT id FROM posts
44
+
WHERE author = (SELECT id FROM repos WHERE did = ?)
45
+
AND rkey = ?
46
+
`, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID)
47
+
rootPostID = postID
48
+
}
49
+
50
+
// Query all posts in this thread
51
+
type threadPost struct {
52
+
ID uint
53
+
Rkey string
54
+
ReplyTo uint
55
+
InThread uint
56
+
AuthorDID string
57
+
}
58
+
var threadPosts []threadPost
59
+
db.Raw(`
60
+
SELECT p.id, p.rkey, p.reply_to, p.in_thread, r.did as author_did
61
+
FROM posts p
62
+
JOIN repos r ON r.id = p.author
63
+
WHERE (p.id = ? OR p.in_thread = ?)
64
+
AND p.not_found = false
65
+
ORDER BY p.created ASC
66
+
`, rootPostID, rootPostID).Scan(&threadPosts)
67
+
68
+
// Build a map of posts by ID for easy lookup
69
+
postsByID := make(map[uint]*threadPostNode)
70
+
for _, tp := range threadPosts {
71
+
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", tp.AuthorDID, tp.Rkey)
72
+
postsByID[tp.ID] = &threadPostNode{
73
+
id: tp.ID,
74
+
uri: uri,
75
+
replyTo: tp.ReplyTo,
76
+
inThread: tp.InThread,
77
+
replies: []any{},
78
+
}
79
+
}
80
+
81
+
// Build the thread tree structure
82
+
for _, node := range postsByID {
83
+
if node.replyTo != 0 {
84
+
parent := postsByID[node.replyTo]
85
+
if parent != nil {
86
+
parent.replies = append(parent.replies, node)
87
+
}
88
+
}
89
+
}
90
+
91
+
// Find the root node
92
+
var rootNode *threadPostNode
93
+
for _, node := range postsByID {
94
+
if node.inThread == 0 || node.id == rootPostID {
95
+
rootNode = node
96
+
break
97
+
}
98
+
}
99
+
100
+
if rootNode == nil {
101
+
return c.JSON(http.StatusNotFound, map[string]any{
102
+
"error": "NotFound",
103
+
"message": "thread root not found",
104
+
})
105
+
}
106
+
107
+
// Build the response by traversing the tree
108
+
thread := buildThreadView(ctx, db, rootNode, postsByID, hydrator, viewer, nil)
109
+
110
+
return c.JSON(http.StatusOK, map[string]any{
111
+
"thread": thread,
112
+
})
113
+
}
114
+
115
+
type threadPostNode struct {
116
+
id uint
117
+
uri string
118
+
replyTo uint
119
+
inThread uint
120
+
replies []any
121
+
}
122
+
123
+
func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent any) any {
124
+
// Hydrate this post
125
+
postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer)
126
+
if err != nil {
127
+
// Return a notFound post
128
+
return map[string]any{
129
+
"$type": "app.bsky.feed.defs#notFoundPost",
130
+
"uri": node.uri,
131
+
}
132
+
}
133
+
134
+
// Hydrate author
135
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
136
+
if err != nil {
137
+
return map[string]any{
138
+
"$type": "app.bsky.feed.defs#notFoundPost",
139
+
"uri": node.uri,
140
+
}
141
+
}
142
+
143
+
// Build replies
144
+
var replies []any
145
+
for _, replyNode := range node.replies {
146
+
if rn, ok := replyNode.(*threadPostNode); ok {
147
+
replyView := buildThreadView(ctx, db, rn, allNodes, hydrator, viewer, nil)
148
+
replies = append(replies, replyView)
149
+
}
150
+
}
151
+
152
+
// Build the thread view post
153
+
var repliesForView any
154
+
if len(replies) > 0 {
155
+
repliesForView = replies
156
+
}
157
+
158
+
return views.ThreadViewPost(postInfo, authorInfo, parent, repliesForView)
159
+
}
160
+
161
+
func extractDIDFromURI(uri string) string {
162
+
// URI format: at://did:plc:xxx/collection/rkey
163
+
if len(uri) < 5 || uri[:5] != "at://" {
164
+
return ""
165
+
}
166
+
parts := []rune(uri[5:])
167
+
for i, r := range parts {
168
+
if r == '/' {
169
+
return string(parts[:i])
170
+
}
171
+
}
172
+
return string(parts)
173
+
}
174
+
175
+
func extractRkeyFromURI(uri string) string {
176
+
// URI format: at://did:plc:xxx/collection/rkey
177
+
if len(uri) < 5 || uri[:5] != "at://" {
178
+
return ""
179
+
}
180
+
// Find last slash
181
+
for i := len(uri) - 1; i >= 5; i-- {
182
+
if uri[i] == '/' {
183
+
return uri[i+1:]
184
+
}
185
+
}
186
+
return ""
187
+
}
+85
xrpc/feed/getPosts.go
+85
xrpc/feed/getPosts.go
···
1
+
package feed
2
+
3
+
import (
4
+
"net/http"
5
+
"strings"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
)
11
+
12
+
// HandleGetPosts implements app.bsky.feed.getPosts
13
+
func HandleGetPosts(c echo.Context, hydrator *hydration.Hydrator) error {
14
+
// Get URIs from query params (can be multiple)
15
+
urisParam := c.QueryParam("uris")
16
+
if urisParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "uris parameter is required",
20
+
})
21
+
}
22
+
23
+
// Parse URIs (they come as a comma-separated list or as multiple query params)
24
+
var uris []string
25
+
if strings.Contains(urisParam, ",") {
26
+
uris = strings.Split(urisParam, ",")
27
+
} else {
28
+
// Check for multiple uri query params
29
+
uris = c.QueryParams()["uris"]
30
+
if len(uris) == 0 {
31
+
uris = []string{urisParam}
32
+
}
33
+
}
34
+
35
+
// Limit to reasonable number
36
+
if len(uris) > 25 {
37
+
uris = uris[:25]
38
+
}
39
+
40
+
ctx := c.Request().Context()
41
+
viewer := getUserDID(c)
42
+
43
+
// Hydrate posts
44
+
postsMap, err := hydrator.HydratePosts(ctx, uris, viewer)
45
+
if err != nil {
46
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
47
+
"error": "InternalError",
48
+
"message": "failed to load posts",
49
+
})
50
+
}
51
+
52
+
// Build response - need to maintain order of requested URIs
53
+
posts := make([]interface{}, 0)
54
+
for _, uri := range uris {
55
+
postInfo, ok := postsMap[uri]
56
+
if !ok {
57
+
// Post not found, skip it
58
+
continue
59
+
}
60
+
61
+
// Hydrate author
62
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
63
+
if err != nil {
64
+
continue
65
+
}
66
+
67
+
postView := views.PostView(postInfo, authorInfo)
68
+
posts = append(posts, postView)
69
+
}
70
+
71
+
return c.JSON(http.StatusOK, map[string]interface{}{
72
+
"posts": posts,
73
+
})
74
+
}
75
+
76
+
func getUserDID(c echo.Context) string {
77
+
did := c.Get("viewer")
78
+
if did == nil {
79
+
return ""
80
+
}
81
+
if s, ok := did.(string); ok {
82
+
return s
83
+
}
84
+
return ""
85
+
}
+111
xrpc/feed/getRepostedBy.go
+111
xrpc/feed/getRepostedBy.go
···
1
+
package feed
2
+
3
+
import (
4
+
"net/http"
5
+
"strconv"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetRepostedBy implements app.bsky.feed.getRepostedBy
14
+
func HandleGetRepostedBy(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
uriParam := c.QueryParam("uri")
16
+
if uriParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "uri parameter is required",
20
+
})
21
+
}
22
+
23
+
// Parse limit
24
+
limit := 50
25
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
26
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
27
+
limit = l
28
+
}
29
+
}
30
+
31
+
// Parse cursor (repost ID)
32
+
var cursor uint
33
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
34
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
35
+
cursor = uint(c)
36
+
}
37
+
}
38
+
39
+
ctx := c.Request().Context()
40
+
41
+
// Get post ID from URI
42
+
var postID uint
43
+
db.Raw(`
44
+
SELECT id FROM posts
45
+
WHERE author = (SELECT id FROM repos WHERE did = ?)
46
+
AND rkey = ?
47
+
`, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID)
48
+
49
+
if postID == 0 {
50
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
51
+
"error": "NotFound",
52
+
"message": "post not found",
53
+
})
54
+
}
55
+
56
+
// Query reposts
57
+
type repostRow struct {
58
+
ID uint
59
+
AuthorDid string
60
+
Rkey string
61
+
Created string
62
+
}
63
+
var rows []repostRow
64
+
65
+
query := `
66
+
SELECT rp.id, r.did as author_did, rp.rkey, rp.created
67
+
FROM reposts rp
68
+
JOIN repos r ON r.id = rp.author
69
+
WHERE rp.subject = ?
70
+
`
71
+
if cursor > 0 {
72
+
query += ` AND rp.id < ?`
73
+
}
74
+
query += ` ORDER BY rp.id DESC LIMIT ?`
75
+
76
+
var queryArgs []interface{}
77
+
queryArgs = append(queryArgs, postID)
78
+
if cursor > 0 {
79
+
queryArgs = append(queryArgs, cursor)
80
+
}
81
+
queryArgs = append(queryArgs, limit)
82
+
83
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
84
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
85
+
"error": "InternalError",
86
+
"message": "failed to query reposts",
87
+
})
88
+
}
89
+
90
+
// Hydrate actors
91
+
repostedBy := make([]interface{}, 0)
92
+
for _, row := range rows {
93
+
actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
94
+
if err != nil {
95
+
continue
96
+
}
97
+
repostedBy = append(repostedBy, views.ProfileView(actorInfo))
98
+
}
99
+
100
+
// Generate next cursor
101
+
var nextCursor string
102
+
if len(rows) > 0 {
103
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
104
+
}
105
+
106
+
return c.JSON(http.StatusOK, map[string]interface{}{
107
+
"uri": uriParam,
108
+
"repostedBy": repostedBy,
109
+
"cursor": nextCursor,
110
+
})
111
+
}
+114
xrpc/feed/getTimeline.go
+114
xrpc/feed/getTimeline.go
···
1
+
package feed
2
+
3
+
import (
4
+
"context"
5
+
"net/http"
6
+
"strconv"
7
+
"time"
8
+
9
+
"github.com/labstack/echo/v4"
10
+
"github.com/whyrusleeping/konbini/hydration"
11
+
"go.opentelemetry.io/otel"
12
+
"gorm.io/gorm"
13
+
)
14
+
15
+
var tracer = otel.Tracer("xrpc/feed")
16
+
17
+
// HandleGetTimeline implements app.bsky.feed.getTimeline
18
+
func HandleGetTimeline(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
19
+
ctx := c.Request().Context()
20
+
ctx, span := tracer.Start(ctx, "getTimeline")
21
+
defer span.End()
22
+
23
+
viewer := getUserDID(c)
24
+
if viewer == "" {
25
+
return c.JSON(http.StatusUnauthorized, map[string]any{
26
+
"error": "AuthenticationRequired",
27
+
"message": "authentication required",
28
+
})
29
+
}
30
+
31
+
// Parse limit
32
+
limit := 50
33
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
34
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
35
+
limit = l
36
+
}
37
+
}
38
+
39
+
// Parse cursor (timestamp)
40
+
cursor := time.Now()
41
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
42
+
if t, err := time.Parse(time.RFC3339, cursorParam); err == nil {
43
+
cursor = t
44
+
}
45
+
}
46
+
47
+
// Get viewer's repo ID
48
+
var viewerRepoID uint
49
+
if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil {
50
+
return c.JSON(http.StatusInternalServerError, map[string]any{
51
+
"error": "InternalError",
52
+
"message": "failed to load viewer",
53
+
})
54
+
}
55
+
56
+
// Query posts from followed users
57
+
58
+
rows, err := getTimelinePosts(ctx, db, viewerRepoID, cursor, limit)
59
+
if err != nil {
60
+
return c.JSON(http.StatusInternalServerError, map[string]any{
61
+
"error": "InternalError",
62
+
"message": "failed to query timeline",
63
+
})
64
+
}
65
+
66
+
// Hydrate posts
67
+
feed := hydratePostRows(ctx, hydrator, viewer, rows)
68
+
69
+
// Generate next cursor
70
+
var nextCursor string
71
+
if len(rows) > 0 {
72
+
// Get the created time of the last post
73
+
var lastCreated time.Time
74
+
lastURI := rows[len(rows)-1].URI
75
+
postInfo, err := hydrator.HydratePost(ctx, lastURI, viewer)
76
+
if err == nil && postInfo.Post != nil {
77
+
t, err := time.Parse(time.RFC3339, postInfo.Post.CreatedAt)
78
+
if err == nil {
79
+
lastCreated = t
80
+
nextCursor = lastCreated.Format(time.RFC3339)
81
+
}
82
+
}
83
+
}
84
+
85
+
return c.JSON(http.StatusOK, map[string]any{
86
+
"feed": feed,
87
+
"cursor": nextCursor,
88
+
})
89
+
}
90
+
91
+
func getTimelinePosts(ctx context.Context, db *gorm.DB, uid uint, cursor time.Time, limit int) ([]postRow, error) {
92
+
ctx, span := tracer.Start(ctx, "getTimelineQuery")
93
+
defer span.End()
94
+
95
+
var rows []postRow
96
+
err := db.Raw(`
97
+
SELECT
98
+
'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri,
99
+
p.author as author_id
100
+
FROM posts p
101
+
JOIN repos r ON r.id = p.author
102
+
WHERE p.reply_to = 0
103
+
AND p.author IN (SELECT subject FROM follows WHERE author = ?)
104
+
AND p.created < ?
105
+
AND p.not_found = false
106
+
ORDER BY p.created DESC
107
+
LIMIT ?
108
+
`, uid, cursor, limit).Scan(&rows).Error
109
+
110
+
if err != nil {
111
+
return nil, err
112
+
}
113
+
return rows, nil
114
+
}
+97
xrpc/graph/getBlocks.go
+97
xrpc/graph/getBlocks.go
···
1
+
package graph
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"github.com/labstack/echo/v4"
9
+
"github.com/whyrusleeping/konbini/hydration"
10
+
"github.com/whyrusleeping/konbini/views"
11
+
"gorm.io/gorm"
12
+
)
13
+
14
+
// HandleGetBlocks implements app.bsky.graph.getBlocks
15
+
func HandleGetBlocks(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
+
// Get viewer from authentication
17
+
viewer := c.Get("viewer")
18
+
if viewer == nil {
19
+
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
20
+
"error": "AuthenticationRequired",
21
+
"message": "authentication required",
22
+
})
23
+
}
24
+
viewerDID := viewer.(string)
25
+
26
+
// Parse limit
27
+
limit := 50
28
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
29
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
30
+
limit = l
31
+
}
32
+
}
33
+
34
+
// Parse cursor (block ID)
35
+
var cursor uint
36
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
37
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
38
+
cursor = uint(c)
39
+
}
40
+
}
41
+
42
+
ctx := c.Request().Context()
43
+
44
+
// Query blocks
45
+
type blockRow struct {
46
+
ID uint
47
+
SubjectDid string
48
+
}
49
+
var rows []blockRow
50
+
51
+
query := `
52
+
SELECT b.id, r.did as subject_did
53
+
FROM blocks b
54
+
LEFT JOIN repos r ON r.id = b.subject
55
+
WHERE b.author = (SELECT id FROM repos WHERE did = ?)
56
+
`
57
+
if cursor > 0 {
58
+
query += ` AND b.id < ?`
59
+
}
60
+
query += ` ORDER BY b.id DESC LIMIT ?`
61
+
62
+
var queryArgs []interface{}
63
+
queryArgs = append(queryArgs, viewerDID)
64
+
if cursor > 0 {
65
+
queryArgs = append(queryArgs, cursor)
66
+
}
67
+
queryArgs = append(queryArgs, limit)
68
+
69
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
70
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
71
+
"error": "InternalError",
72
+
"message": "failed to query blocks",
73
+
})
74
+
}
75
+
76
+
// Hydrate blocked actors
77
+
blocks := make([]interface{}, 0)
78
+
for _, row := range rows {
79
+
actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid)
80
+
if err != nil {
81
+
fmt.Println("Hydrating actor failed: ", err)
82
+
continue
83
+
}
84
+
blocks = append(blocks, views.ProfileView(actorInfo))
85
+
}
86
+
87
+
// Generate next cursor
88
+
var nextCursor string
89
+
if len(rows) > 0 {
90
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
91
+
}
92
+
93
+
return c.JSON(http.StatusOK, map[string]interface{}{
94
+
"blocks": blocks,
95
+
"cursor": nextCursor,
96
+
})
97
+
}
+112
xrpc/graph/getFollowers.go
+112
xrpc/graph/getFollowers.go
···
1
+
package graph
2
+
3
+
import (
4
+
"net/http"
5
+
"strconv"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetFollowers implements app.bsky.graph.getFollowers
14
+
func HandleGetFollowers(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
actorParam := c.QueryParam("actor")
16
+
if actorParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "actor parameter is required",
20
+
})
21
+
}
22
+
23
+
// Parse limit
24
+
limit := 50
25
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
26
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
27
+
limit = l
28
+
}
29
+
}
30
+
31
+
// Parse cursor (follow ID)
32
+
var cursor uint
33
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
34
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
35
+
cursor = uint(c)
36
+
}
37
+
}
38
+
39
+
ctx := c.Request().Context()
40
+
41
+
// Resolve actor to DID
42
+
did, err := hydrator.ResolveDID(ctx, actorParam)
43
+
if err != nil {
44
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
45
+
"error": "ActorNotFound",
46
+
"message": "actor not found",
47
+
})
48
+
}
49
+
50
+
// Get the subject actor info
51
+
subjectInfo, err := hydrator.HydrateActor(ctx, did)
52
+
if err != nil {
53
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
54
+
"error": "ActorNotFound",
55
+
"message": "failed to load actor",
56
+
})
57
+
}
58
+
59
+
// Query followers
60
+
type followerRow struct {
61
+
ID uint
62
+
AuthorDid string
63
+
}
64
+
var rows []followerRow
65
+
66
+
query := `
67
+
SELECT f.id, r.did as author_did
68
+
FROM follows f
69
+
JOIN repos r ON r.id = f.author
70
+
WHERE f.subject = (SELECT id FROM repos WHERE did = ?)
71
+
`
72
+
if cursor > 0 {
73
+
query += ` AND f.id < ?`
74
+
}
75
+
query += ` ORDER BY f.id DESC LIMIT ?`
76
+
77
+
var queryArgs []interface{}
78
+
queryArgs = append(queryArgs, did)
79
+
if cursor > 0 {
80
+
queryArgs = append(queryArgs, cursor)
81
+
}
82
+
queryArgs = append(queryArgs, limit)
83
+
84
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
85
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
86
+
"error": "InternalError",
87
+
"message": "failed to query followers",
88
+
})
89
+
}
90
+
91
+
// Hydrate follower actors
92
+
followers := make([]interface{}, 0)
93
+
for _, row := range rows {
94
+
actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
95
+
if err != nil {
96
+
continue
97
+
}
98
+
followers = append(followers, views.ProfileView(actorInfo))
99
+
}
100
+
101
+
// Generate next cursor
102
+
var nextCursor string
103
+
if len(rows) > 0 {
104
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
105
+
}
106
+
107
+
return c.JSON(http.StatusOK, map[string]interface{}{
108
+
"subject": views.ProfileView(subjectInfo),
109
+
"followers": followers,
110
+
"cursor": nextCursor,
111
+
})
112
+
}
+112
xrpc/graph/getFollows.go
+112
xrpc/graph/getFollows.go
···
1
+
package graph
2
+
3
+
import (
4
+
"net/http"
5
+
"strconv"
6
+
7
+
"github.com/labstack/echo/v4"
8
+
"github.com/whyrusleeping/konbini/hydration"
9
+
"github.com/whyrusleeping/konbini/views"
10
+
"gorm.io/gorm"
11
+
)
12
+
13
+
// HandleGetFollows implements app.bsky.graph.getFollows
14
+
func HandleGetFollows(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
15
+
actorParam := c.QueryParam("actor")
16
+
if actorParam == "" {
17
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
18
+
"error": "InvalidRequest",
19
+
"message": "actor parameter is required",
20
+
})
21
+
}
22
+
23
+
// Parse limit
24
+
limit := 50
25
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
26
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
27
+
limit = l
28
+
}
29
+
}
30
+
31
+
// Parse cursor (follow ID)
32
+
var cursor uint
33
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
34
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
35
+
cursor = uint(c)
36
+
}
37
+
}
38
+
39
+
ctx := c.Request().Context()
40
+
41
+
// Resolve actor to DID
42
+
did, err := hydrator.ResolveDID(ctx, actorParam)
43
+
if err != nil {
44
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
45
+
"error": "ActorNotFound",
46
+
"message": "actor not found",
47
+
})
48
+
}
49
+
50
+
// Get the subject actor info (the person whose follows we're listing)
51
+
subjectInfo, err := hydrator.HydrateActor(ctx, did)
52
+
if err != nil {
53
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
54
+
"error": "ActorNotFound",
55
+
"message": "failed to load actor",
56
+
})
57
+
}
58
+
59
+
// Query follows
60
+
type followRow struct {
61
+
ID uint
62
+
SubjectDid string
63
+
}
64
+
var rows []followRow
65
+
66
+
query := `
67
+
SELECT f.id, r.did as subject_did
68
+
FROM follows f
69
+
JOIN repos r ON r.id = f.subject
70
+
WHERE f.author = (SELECT id FROM repos WHERE did = ?)
71
+
`
72
+
if cursor > 0 {
73
+
query += ` AND f.id < ?`
74
+
}
75
+
query += ` ORDER BY f.id DESC LIMIT ?`
76
+
77
+
var queryArgs []interface{}
78
+
queryArgs = append(queryArgs, did)
79
+
if cursor > 0 {
80
+
queryArgs = append(queryArgs, cursor)
81
+
}
82
+
queryArgs = append(queryArgs, limit)
83
+
84
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
85
+
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
86
+
"error": "InternalError",
87
+
"message": "failed to query follows",
88
+
})
89
+
}
90
+
91
+
// Hydrate followed actors
92
+
follows := make([]interface{}, 0)
93
+
for _, row := range rows {
94
+
actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid)
95
+
if err != nil {
96
+
continue
97
+
}
98
+
follows = append(follows, views.ProfileView(actorInfo))
99
+
}
100
+
101
+
// Generate next cursor
102
+
var nextCursor string
103
+
if len(rows) > 0 {
104
+
nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
105
+
}
106
+
107
+
return c.JSON(http.StatusOK, map[string]interface{}{
108
+
"subject": views.ProfileView(subjectInfo),
109
+
"follows": follows,
110
+
"cursor": nextCursor,
111
+
})
112
+
}
+41
xrpc/graph/getMutes.go
+41
xrpc/graph/getMutes.go
···
1
+
package graph
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
"github.com/whyrusleeping/konbini/hydration"
8
+
"gorm.io/gorm"
9
+
)
10
+
11
+
// HandleGetMutes implements app.bsky.graph.getMutes
12
+
// NOTE: Mutes are typically stored as user preferences/settings, not as repo records.
13
+
// This implementation returns an empty list as mute tracking is not yet implemented
14
+
// in the database schema.
15
+
func HandleGetMutes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
16
+
// Get viewer from authentication
17
+
viewer := c.Get("viewer")
18
+
if viewer == nil {
19
+
return c.JSON(http.StatusUnauthorized, map[string]interface{}{
20
+
"error": "AuthenticationRequired",
21
+
"message": "authentication required",
22
+
})
23
+
}
24
+
25
+
// TODO: Implement mute tracking in the database
26
+
// Mutes are different from blocks - they're typically stored as preferences
27
+
// rather than as repo records. Would need a new table like:
28
+
// CREATE TABLE user_mutes (
29
+
// id SERIAL PRIMARY KEY,
30
+
// actor_did TEXT NOT NULL,
31
+
// muted_did TEXT NOT NULL,
32
+
// created_at TIMESTAMP NOT NULL,
33
+
// UNIQUE(actor_did, muted_did)
34
+
// );
35
+
36
+
// For now, return empty list
37
+
return c.JSON(http.StatusOK, map[string]interface{}{
38
+
"mutes": []interface{}{},
39
+
"cursor": "",
40
+
})
41
+
}
+102
xrpc/graph/getRelationships.go
+102
xrpc/graph/getRelationships.go
···
1
+
package graph
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
"github.com/whyrusleeping/konbini/hydration"
8
+
"gorm.io/gorm"
9
+
)
10
+
11
+
// HandleGetRelationships implements app.bsky.graph.getRelationships
12
+
func HandleGetRelationships(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
13
+
actorParam := c.QueryParam("actor")
14
+
if actorParam == "" {
15
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
16
+
"error": "InvalidRequest",
17
+
"message": "actor parameter is required",
18
+
})
19
+
}
20
+
21
+
// Parse others parameter (can be multiple)
22
+
others := c.QueryParams()["others"]
23
+
if len(others) == 0 {
24
+
return c.JSON(http.StatusOK, map[string]interface{}{
25
+
"actor": actorParam,
26
+
"relationships": []interface{}{},
27
+
})
28
+
}
29
+
30
+
// Limit to reasonable batch size
31
+
if len(others) > 30 {
32
+
others = others[:30]
33
+
}
34
+
35
+
ctx := c.Request().Context()
36
+
37
+
// Resolve actor to DID
38
+
actorDID, err := hydrator.ResolveDID(ctx, actorParam)
39
+
if err != nil {
40
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
41
+
"error": "ActorNotFound",
42
+
"message": "actor not found",
43
+
})
44
+
}
45
+
46
+
// Build relationships for each "other" actor
47
+
relationships := make([]interface{}, 0, len(others))
48
+
49
+
for _, other := range others {
50
+
// Resolve other to DID
51
+
otherDID, err := hydrator.ResolveDID(ctx, other)
52
+
if err != nil {
53
+
// Actor not found
54
+
relationships = append(relationships, map[string]interface{}{
55
+
"$type": "app.bsky.graph.defs#notFoundActor",
56
+
"actor": other,
57
+
"notFound": true,
58
+
})
59
+
continue
60
+
}
61
+
62
+
// Check if actor follows other
63
+
var following string
64
+
err = db.Raw(`
65
+
SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri
66
+
FROM follows f
67
+
JOIN repos r1 ON r1.id = f.author
68
+
JOIN repos r2 ON r2.id = f.subject
69
+
WHERE r1.did = ? AND r2.did = ?
70
+
LIMIT 1
71
+
`, actorDID, otherDID).Scan(&following).Error
72
+
if err != nil {
73
+
following = ""
74
+
}
75
+
76
+
// Check if other follows actor
77
+
var followedBy string
78
+
err = db.Raw(`
79
+
SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri
80
+
FROM follows f
81
+
JOIN repos r1 ON r1.id = f.author
82
+
JOIN repos r2 ON r2.id = f.subject
83
+
WHERE r1.did = ? AND r2.did = ?
84
+
LIMIT 1
85
+
`, otherDID, actorDID).Scan(&followedBy).Error
86
+
if err != nil {
87
+
followedBy = ""
88
+
}
89
+
90
+
relationships = append(relationships, map[string]interface{}{
91
+
"$type": "app.bsky.graph.defs#relationship",
92
+
"did": otherDID,
93
+
"following": following,
94
+
"followedBy": followedBy,
95
+
})
96
+
}
97
+
98
+
return c.JSON(http.StatusOK, map[string]interface{}{
99
+
"actor": actorDID,
100
+
"relationships": relationships,
101
+
})
102
+
}
+30
xrpc/identity.go
+30
xrpc/identity.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"net/http"
5
+
"strings"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/labstack/echo/v4"
9
+
)
10
+
11
+
// handleResolveHandle implements com.atproto.identity.resolveHandle
12
+
func (s *Server) handleResolveHandle(c echo.Context) error {
13
+
handle := c.QueryParam("handle")
14
+
if handle == "" {
15
+
return XRPCError(c, http.StatusBadRequest, "InvalidRequest", "handle parameter is required")
16
+
}
17
+
18
+
// Clean up handle (remove @ prefix if present)
19
+
handle = strings.TrimPrefix(handle, "@")
20
+
21
+
// Resolve handle to DID
22
+
resp, err := s.dir.LookupHandle(c.Request().Context(), syntax.Handle(handle))
23
+
if err != nil {
24
+
return XRPCError(c, http.StatusBadRequest, "HandleNotFound", "handle not found")
25
+
}
26
+
27
+
return c.JSON(http.StatusOK, map[string]interface{}{
28
+
"did": resp.DID.String(),
29
+
})
30
+
}
+17
xrpc/labeler/getServices.go
+17
xrpc/labeler/getServices.go
···
1
+
package labeler
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
)
8
+
9
+
// HandleGetServices implements app.bsky.labeler.getServices
10
+
// Returns information about labeler services
11
+
func HandleGetServices(c echo.Context) error {
12
+
// For now, return empty views since we don't have labeler support
13
+
// A full implementation would parse the "dids" query parameter
14
+
return c.JSON(http.StatusOK, map[string]interface{}{
15
+
"views": []interface{}{},
16
+
})
17
+
}
+392
xrpc/notification/listNotifications.go
+392
xrpc/notification/listNotifications.go
···
1
+
package notification
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"net/http"
7
+
"strconv"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/api/bsky"
12
+
"github.com/bluesky-social/indigo/lex/util"
13
+
lexutil "github.com/bluesky-social/indigo/lex/util"
14
+
"github.com/labstack/echo/v4"
15
+
"github.com/whyrusleeping/konbini/hydration"
16
+
models "github.com/whyrusleeping/konbini/models"
17
+
"github.com/whyrusleeping/konbini/views"
18
+
"gorm.io/gorm"
19
+
"gorm.io/gorm/clause"
20
+
)
21
+
22
+
// HandleListNotifications implements app.bsky.notification.listNotifications
23
+
func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
24
+
viewer := getUserDID(c)
25
+
if viewer == "" {
26
+
return c.JSON(http.StatusUnauthorized, map[string]any{
27
+
"error": "AuthenticationRequired",
28
+
"message": "authentication required",
29
+
})
30
+
}
31
+
32
+
// Parse limit
33
+
limit := 50
34
+
if limitParam := c.QueryParam("limit"); limitParam != "" {
35
+
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 {
36
+
limit = l
37
+
}
38
+
}
39
+
40
+
// Parse cursor (notification ID)
41
+
var cursor uint
42
+
if cursorParam := c.QueryParam("cursor"); cursorParam != "" {
43
+
if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil {
44
+
cursor = uint(c)
45
+
}
46
+
}
47
+
48
+
ctx := c.Request().Context()
49
+
50
+
// Query notifications for viewer with CIDs from source records
51
+
type notifRow struct {
52
+
ID uint
53
+
Kind string
54
+
AuthorDid string
55
+
Source string
56
+
SourceCid string
57
+
CreatedAt string
58
+
}
59
+
var rows []notifRow
60
+
61
+
// This query tries to fetch the CID from the source record
62
+
// depending on the notification kind (like, repost, reply, etc.)
63
+
query := `
64
+
SELECT
65
+
n.id,
66
+
n.kind,
67
+
r.did as author_did,
68
+
n.source,
69
+
n.source_cid,
70
+
n.created_at
71
+
FROM notifications n
72
+
JOIN repos r ON r.id = n.author
73
+
LEFT JOIN repos r2 ON r2.id = n.author
74
+
WHERE n.for = (SELECT id FROM repos WHERE did = ?)
75
+
`
76
+
if cursor > 0 {
77
+
query += ` AND n.id < ?`
78
+
}
79
+
query += ` ORDER BY n.created_at DESC LIMIT ?`
80
+
81
+
var queryArgs []any
82
+
queryArgs = append(queryArgs, viewer)
83
+
if cursor > 0 {
84
+
queryArgs = append(queryArgs, cursor)
85
+
}
86
+
queryArgs = append(queryArgs, limit)
87
+
88
+
if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil {
89
+
return c.JSON(http.StatusInternalServerError, map[string]any{
90
+
"error": "InternalError",
91
+
"message": "failed to query notifications",
92
+
})
93
+
}
94
+
95
+
// Hydrate notifications
96
+
notifications := make([]*bsky.NotificationListNotifications_Notification, 0)
97
+
for _, row := range rows {
98
+
authorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid)
99
+
if err != nil {
100
+
continue
101
+
}
102
+
103
+
// Skip notifications without CIDs as they're invalid
104
+
if row.SourceCid == "" {
105
+
continue
106
+
}
107
+
108
+
// Fetch and decode the raw record
109
+
recordDecoder, err := fetchNotificationRecord(db, row.Source, row.Kind)
110
+
if err != nil {
111
+
continue
112
+
}
113
+
114
+
notif := &bsky.NotificationListNotifications_Notification{
115
+
Uri: row.Source,
116
+
Cid: row.SourceCid,
117
+
Author: views.ProfileView(authorInfo),
118
+
Reason: mapNotifKind(row.Kind),
119
+
Record: recordDecoder,
120
+
IsRead: false,
121
+
IndexedAt: row.CreatedAt,
122
+
}
123
+
124
+
notifications = append(notifications, notif)
125
+
}
126
+
127
+
// Generate next cursor
128
+
var cursorPtr *string
129
+
if len(rows) > 0 {
130
+
cursor := strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10)
131
+
cursorPtr = &cursor
132
+
}
133
+
134
+
var lastSeen time.Time
135
+
if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = (select id from repos where did = ?)", viewer).Scan(&lastSeen).Error; err != nil {
136
+
return err
137
+
}
138
+
139
+
var lastSeenStr *string
140
+
if !lastSeen.IsZero() {
141
+
s := lastSeen.Format(time.RFC3339)
142
+
lastSeenStr = &s
143
+
}
144
+
145
+
output := &bsky.NotificationListNotifications_Output{
146
+
Notifications: notifications,
147
+
Cursor: cursorPtr,
148
+
SeenAt: lastSeenStr,
149
+
}
150
+
151
+
return c.JSON(http.StatusOK, output)
152
+
}
153
+
154
+
// HandleGetUnreadCount implements app.bsky.notification.getUnreadCount
155
+
func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
156
+
viewer := getUserDID(c)
157
+
if viewer == "" {
158
+
return c.JSON(http.StatusUnauthorized, map[string]any{
159
+
"error": "AuthenticationRequired",
160
+
"message": "authentication required",
161
+
})
162
+
}
163
+
164
+
var repo models.Repo
165
+
if err := db.Find(&repo, "did = ?", viewer).Error; err != nil {
166
+
return err
167
+
}
168
+
169
+
var lastSeen time.Time
170
+
if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = ?", repo.ID).Scan(&lastSeen).Error; err != nil {
171
+
return err
172
+
}
173
+
174
+
var count int
175
+
query := `SELECT count(*) FROM notifications WHERE created_at > ? AND for = ?`
176
+
if err := db.Raw(query, lastSeen, repo.ID).Scan(&count).Error; err != nil {
177
+
return c.JSON(http.StatusInternalServerError, map[string]any{
178
+
"error": "InternalError",
179
+
"message": "failed to count unread notifications",
180
+
})
181
+
}
182
+
183
+
return c.JSON(http.StatusOK, map[string]any{
184
+
"count": count,
185
+
})
186
+
}
187
+
188
+
// HandleUpdateSeen implements app.bsky.notification.updateSeen
189
+
func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
190
+
viewer := getUserDID(c)
191
+
if viewer == "" {
192
+
return c.JSON(http.StatusUnauthorized, map[string]any{
193
+
"error": "AuthenticationRequired",
194
+
"message": "authentication required",
195
+
})
196
+
}
197
+
198
+
var body bsky.NotificationUpdateSeen_Input
199
+
if err := c.Bind(&body); err != nil {
200
+
return c.JSON(http.StatusBadRequest, map[string]any{
201
+
"error": "InvalidRequest",
202
+
"message": "invalid request body",
203
+
})
204
+
}
205
+
206
+
// Parse the seenAt timestamp
207
+
seenAt, err := time.Parse(time.RFC3339, body.SeenAt)
208
+
if err != nil {
209
+
return c.JSON(http.StatusBadRequest, map[string]any{
210
+
"error": "InvalidRequest",
211
+
"message": "invalid seenAt timestamp",
212
+
})
213
+
}
214
+
215
+
// Get the viewer's repo ID
216
+
var repoID uint
217
+
if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&repoID).Error; err != nil {
218
+
return c.JSON(http.StatusInternalServerError, map[string]any{
219
+
"error": "InternalError",
220
+
"message": "failed to find viewer repo",
221
+
})
222
+
}
223
+
224
+
if repoID == 0 {
225
+
return c.JSON(http.StatusInternalServerError, map[string]any{
226
+
"error": "InternalError",
227
+
"message": "viewer repo not found",
228
+
})
229
+
}
230
+
231
+
// Upsert the NotificationSeen record
232
+
notifSeen := models.NotificationSeen{
233
+
Repo: repoID,
234
+
SeenAt: seenAt,
235
+
}
236
+
237
+
err = db.Clauses(clause.OnConflict{
238
+
Columns: []clause.Column{{Name: "repo"}},
239
+
DoUpdates: clause.AssignmentColumns([]string{"seen_at"}),
240
+
}).Create(¬ifSeen).Error
241
+
242
+
if err != nil {
243
+
return c.JSON(http.StatusInternalServerError, map[string]any{
244
+
"error": "InternalError",
245
+
"message": "failed to update seen timestamp",
246
+
})
247
+
}
248
+
249
+
return c.JSON(http.StatusOK, map[string]any{})
250
+
}
251
+
252
+
func getUserDID(c echo.Context) string {
253
+
did := c.Get("viewer")
254
+
if did == nil {
255
+
return ""
256
+
}
257
+
if s, ok := did.(string); ok {
258
+
return s
259
+
}
260
+
return ""
261
+
}
262
+
263
+
func mapNotifKind(kind string) string {
264
+
switch kind {
265
+
case "reply":
266
+
return "reply"
267
+
case "like":
268
+
return "like"
269
+
case "repost":
270
+
return "repost"
271
+
case "mention":
272
+
return "mention"
273
+
case "follow":
274
+
return "follow"
275
+
default:
276
+
return kind
277
+
}
278
+
}
279
+
280
+
// fetchNotificationRecord fetches and decodes the raw record for a notification
281
+
func fetchNotificationRecord(db *gorm.DB, sourceURI string, kind string) (*util.LexiconTypeDecoder, error) {
282
+
// Parse the source URI to extract DID and rkey
283
+
// URI format: at://did:plc:xxx/collection/rkey
284
+
did := extractDIDFromURI(sourceURI)
285
+
rkey := extractRkeyFromURI(sourceURI)
286
+
287
+
if did == "" || rkey == "" {
288
+
return nil, fmt.Errorf("invalid source URI")
289
+
}
290
+
291
+
var raw []byte
292
+
var err error
293
+
294
+
// Fetch raw data based on notification kind
295
+
switch kind {
296
+
case "reply", "mention", "quote":
297
+
// These reference posts
298
+
err = db.Raw(`
299
+
SELECT p.raw
300
+
FROM posts p
301
+
JOIN repos r ON r.id = p.author
302
+
WHERE r.did = ? AND p.rkey = ?
303
+
`, did, rkey).Scan(&raw).Error
304
+
305
+
case "like":
306
+
// we don't store the raw like objects, so we just reconstruct it here...
307
+
// These reference like records
308
+
var like models.Like
309
+
err = db.Raw(`
310
+
SELECT *
311
+
FROM likes l
312
+
JOIN repos r ON r.id = l.author
313
+
WHERE r.did = ? AND l.rkey = ?
314
+
`, did, rkey).Scan(&like).Error
315
+
316
+
lk := bsky.FeedLike{
317
+
CreatedAt: like.Created.Format(time.RFC3339),
318
+
Subject: &atproto.RepoStrongRef{
319
+
Cid: "",
320
+
Uri: "",
321
+
},
322
+
}
323
+
buf := new(bytes.Buffer)
324
+
if err := lk.MarshalCBOR(buf); err != nil {
325
+
return nil, fmt.Errorf("failed to marshal reconstructed like: %w", err)
326
+
}
327
+
raw = buf.Bytes()
328
+
329
+
case "repost":
330
+
// These reference repost records
331
+
err = db.Raw(`
332
+
SELECT r.raw
333
+
FROM reposts r
334
+
JOIN repos repo ON repo.id = r.author
335
+
WHERE repo.did = ? AND r.rkey = ?
336
+
`, did, rkey).Scan(&raw).Error
337
+
338
+
case "follow":
339
+
// These reference follow records
340
+
err = db.Raw(`
341
+
SELECT f.raw
342
+
FROM follows f
343
+
JOIN repos r ON r.id = f.author
344
+
WHERE r.did = ? AND f.rkey = ?
345
+
`, did, rkey).Scan(&raw).Error
346
+
347
+
default:
348
+
return nil, fmt.Errorf("unknown notification kind: %s", kind)
349
+
}
350
+
351
+
if err != nil || len(raw) == 0 {
352
+
return nil, fmt.Errorf("failed to fetch record: %w", err)
353
+
}
354
+
355
+
// Decode the CBOR data
356
+
decoded, err := lexutil.CborDecodeValue(raw)
357
+
if err != nil {
358
+
return nil, fmt.Errorf("failed to decode CBOR: %w", err)
359
+
}
360
+
361
+
return &util.LexiconTypeDecoder{
362
+
Val: decoded,
363
+
}, nil
364
+
}
365
+
366
+
func extractDIDFromURI(uri string) string {
367
+
// URI format: at://did:plc:xxx/collection/rkey
368
+
if len(uri) < 5 || uri[:5] != "at://" {
369
+
return ""
370
+
}
371
+
parts := []rune(uri[5:])
372
+
for i, r := range parts {
373
+
if r == '/' {
374
+
return string(parts[:i])
375
+
}
376
+
}
377
+
return string(parts)
378
+
}
379
+
380
+
func extractRkeyFromURI(uri string) string {
381
+
// URI format: at://did:plc:xxx/collection/rkey
382
+
if len(uri) < 5 || uri[:5] != "at://" {
383
+
return ""
384
+
}
385
+
// Find last slash
386
+
for i := len(uri) - 1; i >= 5; i-- {
387
+
if uri[i] == '/' {
388
+
return uri[i+1:]
389
+
}
390
+
}
391
+
return ""
392
+
}
+190
xrpc/repo/getRecord.go
+190
xrpc/repo/getRecord.go
···
1
+
package repo
2
+
3
+
import (
4
+
"fmt"
5
+
"net/http"
6
+
7
+
cbg "github.com/whyrusleeping/cbor-gen"
8
+
9
+
lexutil "github.com/bluesky-social/indigo/lex/util"
10
+
"github.com/labstack/echo/v4"
11
+
"github.com/whyrusleeping/konbini/hydration"
12
+
"gorm.io/gorm"
13
+
)
14
+
15
+
// HandleGetRecord implements com.atproto.repo.getRecord
16
+
func HandleGetRecord(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
17
+
repoParam := c.QueryParam("repo")
18
+
collection := c.QueryParam("collection")
19
+
rkey := c.QueryParam("rkey")
20
+
cidParam := c.QueryParam("cid")
21
+
22
+
if repoParam == "" || collection == "" || rkey == "" {
23
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
24
+
"error": "InvalidRequest",
25
+
"message": "repo, collection, and rkey parameters are required",
26
+
})
27
+
}
28
+
29
+
ctx := c.Request().Context()
30
+
31
+
// Resolve repo to DID
32
+
repoDID, err := hydrator.ResolveDID(ctx, repoParam)
33
+
if err != nil {
34
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
35
+
"error": "InvalidRequest",
36
+
"message": fmt.Sprintf("could not find repo: %s", repoParam),
37
+
})
38
+
}
39
+
40
+
// Build URI
41
+
uri := fmt.Sprintf("at://%s/%s/%s", repoDID, collection, rkey)
42
+
43
+
// Query the record based on collection type
44
+
var recordCID string
45
+
var recordRaw []byte
46
+
47
+
switch collection {
48
+
case "app.bsky.feed.post":
49
+
type postRecord struct {
50
+
CID string
51
+
Raw []byte
52
+
}
53
+
var post postRecord
54
+
err = db.Raw(`
55
+
SELECT COALESCE(p.cid, '') as cid, p.raw
56
+
FROM posts p
57
+
JOIN repos r ON r.id = p.author
58
+
WHERE r.did = ? AND p.rkey = ?
59
+
LIMIT 1
60
+
`, repoDID, rkey).Scan(&post).Error
61
+
if err != nil || len(post.Raw) == 0 {
62
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
63
+
"error": "RecordNotFound",
64
+
"message": fmt.Sprintf("could not locate record: %s", uri),
65
+
})
66
+
}
67
+
recordCID = post.CID // May be empty
68
+
recordRaw = post.Raw
69
+
70
+
case "app.bsky.actor.profile":
71
+
type profileRecord struct {
72
+
CID string
73
+
Raw []byte
74
+
}
75
+
var profile profileRecord
76
+
err = db.Raw(`
77
+
SELECT p.cid, p.raw
78
+
FROM profiles p
79
+
JOIN repos r ON r.id = p.repo
80
+
WHERE r.did = ? AND p.rkey = ?
81
+
`, repoDID, rkey).Scan(&profile).Error
82
+
if err != nil || profile.CID == "" {
83
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
84
+
"error": "RecordNotFound",
85
+
"message": fmt.Sprintf("could not locate record: %s", uri),
86
+
})
87
+
}
88
+
recordCID = profile.CID
89
+
recordRaw = profile.Raw
90
+
91
+
case "app.bsky.graph.follow":
92
+
type followRecord struct {
93
+
CID string
94
+
Raw []byte
95
+
}
96
+
var follow followRecord
97
+
err = db.Raw(`
98
+
SELECT f.cid, f.raw
99
+
FROM follows f
100
+
JOIN repos r ON r.id = f.author
101
+
WHERE r.did = ? AND f.rkey = ?
102
+
`, repoDID, rkey).Scan(&follow).Error
103
+
if err != nil || follow.CID == "" {
104
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
105
+
"error": "RecordNotFound",
106
+
"message": fmt.Sprintf("could not locate record: %s", uri),
107
+
})
108
+
}
109
+
recordCID = follow.CID
110
+
recordRaw = follow.Raw
111
+
112
+
case "app.bsky.feed.like":
113
+
type likeRecord struct {
114
+
CID string
115
+
Raw []byte
116
+
}
117
+
var like likeRecord
118
+
err = db.Raw(`
119
+
SELECT l.cid, l.raw
120
+
FROM likes l
121
+
JOIN repos r ON r.id = l.author
122
+
WHERE r.did = ? AND l.rkey = ?
123
+
`, repoDID, rkey).Scan(&like).Error
124
+
if err != nil || like.CID == "" {
125
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
126
+
"error": "RecordNotFound",
127
+
"message": fmt.Sprintf("could not locate record: %s", uri),
128
+
})
129
+
}
130
+
recordCID = like.CID
131
+
recordRaw = like.Raw
132
+
133
+
case "app.bsky.feed.repost":
134
+
type repostRecord struct {
135
+
CID string
136
+
Raw []byte
137
+
}
138
+
var repost repostRecord
139
+
err = db.Raw(`
140
+
SELECT rp.cid, rp.raw
141
+
FROM reposts rp
142
+
JOIN repos r ON r.id = rp.author
143
+
WHERE r.did = ? AND rp.rkey = ?
144
+
`, repoDID, rkey).Scan(&repost).Error
145
+
if err != nil || repost.CID == "" {
146
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
147
+
"error": "RecordNotFound",
148
+
"message": fmt.Sprintf("could not locate record: %s", uri),
149
+
})
150
+
}
151
+
recordCID = repost.CID
152
+
recordRaw = repost.Raw
153
+
154
+
default:
155
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
156
+
"error": "InvalidRequest",
157
+
"message": fmt.Sprintf("unsupported collection: %s", collection),
158
+
})
159
+
}
160
+
161
+
// Check CID if provided
162
+
if cidParam != "" && recordCID != cidParam {
163
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
164
+
"error": "RecordNotFound",
165
+
"message": fmt.Sprintf("could not locate record: %s", uri),
166
+
})
167
+
}
168
+
169
+
// Decode the CBOR record
170
+
// For now, return a placeholder - full CBOR decoding would require
171
+
// type-specific unmarshalers for each collection type
172
+
var value interface{}
173
+
if len(recordRaw) > 0 {
174
+
rec, err := lexutil.CborDecodeValue(recordRaw)
175
+
if err != nil {
176
+
return err
177
+
}
178
+
179
+
value = rec
180
+
}
181
+
182
+
// Suppress unused import warning
183
+
_ = cbg.CborNull
184
+
185
+
return c.JSON(http.StatusOK, map[string]interface{}{
186
+
"uri": uri,
187
+
"cid": recordCID,
188
+
"value": value,
189
+
})
190
+
}
+223
xrpc/server.go
+223
xrpc/server.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/identity"
9
+
"github.com/labstack/echo/v4"
10
+
"github.com/labstack/echo/v4/middleware"
11
+
"github.com/whyrusleeping/konbini/backend"
12
+
"github.com/whyrusleeping/konbini/hydration"
13
+
"github.com/whyrusleeping/konbini/models"
14
+
"github.com/whyrusleeping/konbini/xrpc/actor"
15
+
"github.com/whyrusleeping/konbini/xrpc/feed"
16
+
"github.com/whyrusleeping/konbini/xrpc/graph"
17
+
"github.com/whyrusleeping/konbini/xrpc/labeler"
18
+
"github.com/whyrusleeping/konbini/xrpc/notification"
19
+
"github.com/whyrusleeping/konbini/xrpc/repo"
20
+
"github.com/whyrusleeping/konbini/xrpc/unspecced"
21
+
"gorm.io/gorm"
22
+
)
23
+
24
+
// Server represents the XRPC API server
25
+
type Server struct {
26
+
e *echo.Echo
27
+
db *gorm.DB
28
+
dir identity.Directory
29
+
backend Backend
30
+
hydrator *hydration.Hydrator
31
+
}
32
+
33
+
// Backend interface for data access
34
+
type Backend interface {
35
+
// Add methods as needed for data access
36
+
37
+
TrackMissingRecord(identifier string, wait bool)
38
+
GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error)
39
+
}
40
+
41
+
// NewServer creates a new XRPC server
42
+
func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server {
43
+
e := echo.New()
44
+
e.HidePort = true
45
+
e.HideBanner = true
46
+
47
+
// CORS middleware
48
+
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
49
+
AllowOrigins: []string{"*"},
50
+
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
51
+
AllowHeaders: []string{"*"},
52
+
}))
53
+
54
+
// Logging middleware
55
+
e.Use(middleware.Logger())
56
+
e.Use(middleware.Recover())
57
+
58
+
s := &Server{
59
+
e: e,
60
+
db: db,
61
+
dir: dir,
62
+
backend: backend,
63
+
hydrator: hydration.NewHydrator(db, dir, backend),
64
+
}
65
+
66
+
// Register XRPC endpoints
67
+
s.registerEndpoints()
68
+
69
+
return s
70
+
}
71
+
72
+
// Start starts the XRPC server
73
+
func (s *Server) Start(addr string) error {
74
+
slog.Info("starting XRPC server", "addr", addr)
75
+
return s.e.Start(addr)
76
+
}
77
+
78
+
// registerEndpoints registers all XRPC endpoints
79
+
func (s *Server) registerEndpoints() {
80
+
// XRPC endpoints follow the pattern: /xrpc/<namespace>.<method>
81
+
82
+
s.e.GET("/.well-known/did.json", func(c echo.Context) error {
83
+
return c.File("did.json")
84
+
})
85
+
86
+
xrpcGroup := s.e.Group("/xrpc")
87
+
88
+
// com.atproto.identity.*
89
+
xrpcGroup.GET("/com.atproto.identity.resolveHandle", s.handleResolveHandle)
90
+
91
+
// com.atproto.repo.*
92
+
xrpcGroup.GET("/com.atproto.repo.getRecord", func(c echo.Context) error {
93
+
return repo.HandleGetRecord(c, s.db, s.hydrator)
94
+
})
95
+
96
+
// app.bsky.actor.*
97
+
xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error {
98
+
return actor.HandleGetProfile(c, s.hydrator)
99
+
}, s.optionalAuth)
100
+
xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error {
101
+
return actor.HandleGetProfiles(c, s.db, s.hydrator)
102
+
}, s.optionalAuth)
103
+
xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error {
104
+
return actor.HandleGetPreferences(c, s.db, s.hydrator)
105
+
}, s.requireAuth)
106
+
xrpcGroup.POST("/app.bsky.actor.putPreferences", func(c echo.Context) error {
107
+
return actor.HandlePutPreferences(c, s.db, s.hydrator)
108
+
}, s.requireAuth)
109
+
xrpcGroup.GET("/app.bsky.actor.searchActors", s.handleSearchActors)
110
+
xrpcGroup.GET("/app.bsky.actor.searchActorsTypeahead", s.handleSearchActorsTypeahead)
111
+
112
+
// app.bsky.feed.*
113
+
xrpcGroup.GET("/app.bsky.feed.getTimeline", func(c echo.Context) error {
114
+
return feed.HandleGetTimeline(c, s.db, s.hydrator)
115
+
}, s.requireAuth)
116
+
xrpcGroup.GET("/app.bsky.feed.getAuthorFeed", func(c echo.Context) error {
117
+
return feed.HandleGetAuthorFeed(c, s.db, s.hydrator)
118
+
})
119
+
xrpcGroup.GET("/app.bsky.feed.getPostThread", func(c echo.Context) error {
120
+
return feed.HandleGetPostThread(c, s.db, s.hydrator)
121
+
})
122
+
xrpcGroup.GET("/app.bsky.feed.getPosts", func(c echo.Context) error {
123
+
return feed.HandleGetPosts(c, s.hydrator)
124
+
})
125
+
xrpcGroup.GET("/app.bsky.feed.getLikes", func(c echo.Context) error {
126
+
return feed.HandleGetLikes(c, s.db, s.hydrator)
127
+
})
128
+
xrpcGroup.GET("/app.bsky.feed.getRepostedBy", func(c echo.Context) error {
129
+
return feed.HandleGetRepostedBy(c, s.db, s.hydrator)
130
+
})
131
+
xrpcGroup.GET("/app.bsky.feed.getActorLikes", func(c echo.Context) error {
132
+
return feed.HandleGetActorLikes(c, s.db, s.hydrator)
133
+
}, s.requireAuth)
134
+
xrpcGroup.GET("/app.bsky.feed.getFeed", func(c echo.Context) error {
135
+
return feed.HandleGetFeed(c, s.db, s.hydrator, s.dir)
136
+
}, s.optionalAuth)
137
+
xrpcGroup.GET("/app.bsky.feed.getFeedGenerator", func(c echo.Context) error {
138
+
return feed.HandleGetFeedGenerator(c, s.db, s.hydrator, s.dir)
139
+
})
140
+
141
+
// app.bsky.graph.*
142
+
xrpcGroup.GET("/app.bsky.graph.getFollows", func(c echo.Context) error {
143
+
return graph.HandleGetFollows(c, s.db, s.hydrator)
144
+
})
145
+
xrpcGroup.GET("/app.bsky.graph.getFollowers", func(c echo.Context) error {
146
+
return graph.HandleGetFollowers(c, s.db, s.hydrator)
147
+
})
148
+
xrpcGroup.GET("/app.bsky.graph.getBlocks", func(c echo.Context) error {
149
+
return graph.HandleGetBlocks(c, s.db, s.hydrator)
150
+
}, s.requireAuth)
151
+
xrpcGroup.GET("/app.bsky.graph.getMutes", func(c echo.Context) error {
152
+
return graph.HandleGetMutes(c, s.db, s.hydrator)
153
+
}, s.requireAuth)
154
+
xrpcGroup.GET("/app.bsky.graph.getRelationships", func(c echo.Context) error {
155
+
return graph.HandleGetRelationships(c, s.db, s.hydrator)
156
+
})
157
+
xrpcGroup.GET("/app.bsky.graph.getLists", s.handleGetLists)
158
+
xrpcGroup.GET("/app.bsky.graph.getList", s.handleGetList)
159
+
160
+
// app.bsky.notification.*
161
+
xrpcGroup.GET("/app.bsky.notification.listNotifications", func(c echo.Context) error {
162
+
return notification.HandleListNotifications(c, s.db, s.hydrator)
163
+
}, s.requireAuth)
164
+
xrpcGroup.GET("/app.bsky.notification.getUnreadCount", func(c echo.Context) error {
165
+
return notification.HandleGetUnreadCount(c, s.db, s.hydrator)
166
+
}, s.requireAuth)
167
+
xrpcGroup.POST("/app.bsky.notification.updateSeen", func(c echo.Context) error {
168
+
return notification.HandleUpdateSeen(c, s.db, s.hydrator)
169
+
}, s.requireAuth)
170
+
171
+
// app.bsky.labeler.*
172
+
xrpcGroup.GET("/app.bsky.labeler.getServices", func(c echo.Context) error {
173
+
return labeler.HandleGetServices(c)
174
+
})
175
+
176
+
// app.bsky.unspecced.*
177
+
xrpcGroup.GET("/app.bsky.unspecced.getConfig", func(c echo.Context) error {
178
+
return unspecced.HandleGetConfig(c)
179
+
})
180
+
xrpcGroup.GET("/app.bsky.unspecced.getTrendingTopics", func(c echo.Context) error {
181
+
return unspecced.HandleGetTrendingTopics(c)
182
+
})
183
+
xrpcGroup.GET("/app.bsky.unspecced.getPostThreadV2", func(c echo.Context) error {
184
+
return unspecced.HandleGetPostThreadV2(c, s.db, s.hydrator)
185
+
})
186
+
}
187
+
188
+
// XRPCError creates a properly formatted XRPC error response
189
+
func XRPCError(c echo.Context, statusCode int, errType, message string) error {
190
+
return c.JSON(statusCode, map[string]interface{}{
191
+
"error": errType,
192
+
"message": message,
193
+
})
194
+
}
195
+
196
+
// getUserDID extracts the viewer DID from the request context
197
+
// Returns empty string if not authenticated
198
+
func getUserDID(c echo.Context) string {
199
+
did := c.Get("viewer")
200
+
if did == nil {
201
+
return ""
202
+
}
203
+
if s, ok := did.(string); ok {
204
+
return s
205
+
}
206
+
return ""
207
+
}
208
+
209
+
func (s *Server) handleSearchActors(c echo.Context) error {
210
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
211
+
}
212
+
213
+
func (s *Server) handleSearchActorsTypeahead(c echo.Context) error {
214
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
215
+
}
216
+
217
+
func (s *Server) handleGetLists(c echo.Context) error {
218
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
219
+
}
220
+
221
+
func (s *Server) handleGetList(c echo.Context) error {
222
+
return XRPCError(c, http.StatusNotImplemented, "NotImplemented", "Not yet implemented")
223
+
}
+16
xrpc/unspecced/getConfig.go
+16
xrpc/unspecced/getConfig.go
···
1
+
package unspecced
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
)
8
+
9
+
// HandleGetConfig implements app.bsky.unspecced.getConfig
10
+
// Returns basic configuration for the app
11
+
func HandleGetConfig(c echo.Context) error {
12
+
return c.JSON(http.StatusOK, map[string]interface{}{
13
+
"checkEmailConfirmed": false,
14
+
"liveNow": []any{},
15
+
})
16
+
}
+362
xrpc/unspecced/getPostThreadV2.go
+362
xrpc/unspecced/getPostThreadV2.go
···
1
+
package unspecced
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"fmt"
7
+
"log/slog"
8
+
"net/http"
9
+
"strconv"
10
+
"sync"
11
+
12
+
"github.com/bluesky-social/indigo/api/bsky"
13
+
"github.com/labstack/echo/v4"
14
+
"github.com/whyrusleeping/konbini/hydration"
15
+
"github.com/whyrusleeping/konbini/views"
16
+
"github.com/whyrusleeping/market/models"
17
+
"go.opentelemetry.io/otel"
18
+
"gorm.io/gorm"
19
+
)
20
+
21
+
var tracer = otel.Tracer("xrpc/unspecced")
22
+
23
+
// HandleGetPostThreadV2 implements app.bsky.unspecced.getPostThreadV2
24
+
func HandleGetPostThreadV2(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error {
25
+
ctx, span := tracer.Start(c.Request().Context(), "getPostThreadV2")
26
+
defer span.End()
27
+
ctx = context.WithValue(ctx, "auto-fetch", true)
28
+
29
+
// Parse parameters
30
+
anchorRaw := c.QueryParam("anchor")
31
+
if anchorRaw == "" {
32
+
return c.JSON(http.StatusBadRequest, map[string]interface{}{
33
+
"error": "InvalidRequest",
34
+
"message": "anchor parameter is required",
35
+
})
36
+
}
37
+
38
+
anchorUri, err := hydrator.NormalizeUri(ctx, anchorRaw)
39
+
if err != nil {
40
+
return err
41
+
}
42
+
43
+
// Parse optional parameters with defaults
44
+
above := c.QueryParam("above") != "false" // default true
45
+
46
+
below := int64(6) // default
47
+
if belowParam := c.QueryParam("below"); belowParam != "" {
48
+
if b, err := strconv.ParseInt(belowParam, 10, 64); err == nil && b >= 0 && b <= 20 {
49
+
below = b
50
+
}
51
+
}
52
+
53
+
branchingFactor := int64(10) // default
54
+
if bfParam := c.QueryParam("branchingFactor"); bfParam != "" {
55
+
if bf, err := strconv.ParseInt(bfParam, 10, 64); err == nil && bf > 0 {
56
+
branchingFactor = bf
57
+
}
58
+
}
59
+
60
+
_ = c.QueryParam("prioritizeFollowedUsers") == "true" // TODO: implement prioritization
61
+
62
+
sort := c.QueryParam("sort")
63
+
if sort == "" {
64
+
sort = "newest"
65
+
}
66
+
67
+
viewer := getUserDID(c)
68
+
69
+
// Hydrate the anchor post
70
+
anchorPostInfo, err := hydrator.HydratePost(ctx, anchorUri, viewer)
71
+
if err != nil {
72
+
slog.Error("failed to hydrate post", "error", err, "anchor", anchorUri)
73
+
return c.JSON(http.StatusNotFound, map[string]interface{}{
74
+
"error": "NotFound",
75
+
"message": "anchor post not found",
76
+
})
77
+
}
78
+
79
+
threadID := anchorPostInfo.InThread
80
+
if threadID == 0 {
81
+
threadID = anchorPostInfo.ID
82
+
}
83
+
84
+
var threadPosts []*models.Post
85
+
if err := db.Raw("SELECT * FROM posts WHERE in_thread = ? OR id = ?", threadID, anchorPostInfo.ID).Scan(&threadPosts).Error; err != nil {
86
+
return err
87
+
}
88
+
89
+
fmt.Println("GOT THREAD POSTS: ", len(threadPosts))
90
+
91
+
treeNodes, err := buildThreadTree(ctx, hydrator, db, threadPosts)
92
+
if err != nil {
93
+
return fmt.Errorf("failed to construct tree: %w", err)
94
+
}
95
+
96
+
anchor := treeNodes[anchorPostInfo.ID]
97
+
98
+
// Build flat thread items list
99
+
var threadItems []*bsky.UnspeccedGetPostThreadV2_ThreadItem
100
+
hasOtherReplies := false
101
+
102
+
// Add parents if requested
103
+
if above {
104
+
parent := anchor.parent
105
+
depth := int64(-1)
106
+
for parent != nil {
107
+
if parent.missing {
108
+
fmt.Println("Parent missing: ", depth)
109
+
item := &bsky.UnspeccedGetPostThreadV2_ThreadItem{
110
+
Depth: depth,
111
+
Uri: parent.uri,
112
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
113
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
114
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
115
+
},
116
+
},
117
+
}
118
+
119
+
threadItems = append(threadItems, item)
120
+
break
121
+
}
122
+
123
+
item := buildThreadItem(ctx, hydrator, parent, depth, viewer)
124
+
if item != nil {
125
+
threadItems = append(threadItems, item)
126
+
}
127
+
128
+
parent = parent.parent
129
+
depth--
130
+
}
131
+
}
132
+
133
+
// Add anchor post (depth 0)
134
+
anchorItem := buildThreadItem(ctx, hydrator, anchor, 0, viewer)
135
+
if anchorItem != nil {
136
+
threadItems = append(threadItems, anchorItem)
137
+
}
138
+
139
+
// Add replies below anchor
140
+
if below > 0 {
141
+
replies, err := collectReplies(ctx, hydrator, anchor, 0, below, branchingFactor, sort, viewer)
142
+
if err != nil {
143
+
return err
144
+
}
145
+
threadItems = append(threadItems, replies...)
146
+
//hasOtherReplies = hasMore
147
+
}
148
+
149
+
return c.JSON(http.StatusOK, &bsky.UnspeccedGetPostThreadV2_Output{
150
+
Thread: threadItems,
151
+
HasOtherReplies: hasOtherReplies,
152
+
})
153
+
}
154
+
155
+
func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, curnode *threadTree, depth int64, below int64, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, error) {
156
+
if below == 0 {
157
+
return nil, nil
158
+
}
159
+
160
+
type parThreadResults struct {
161
+
node *bsky.UnspeccedGetPostThreadV2_ThreadItem
162
+
children []*bsky.UnspeccedGetPostThreadV2_ThreadItem
163
+
}
164
+
165
+
results := make([]parThreadResults, len(curnode.children))
166
+
167
+
var wg sync.WaitGroup
168
+
for i := range curnode.children {
169
+
ix := i
170
+
wg.Go(func() {
171
+
child := curnode.children[ix]
172
+
173
+
results[ix].node = buildThreadItem(ctx, hydrator, child, depth+1, viewer)
174
+
if child.missing {
175
+
return
176
+
}
177
+
178
+
sub, err := collectReplies(ctx, hydrator, child, depth+1, below-1, branchingFactor, sort, viewer)
179
+
if err != nil {
180
+
slog.Error("failed to collect replies", "node", child.uri, "error", err)
181
+
return
182
+
}
183
+
184
+
results[ix].children = sub
185
+
})
186
+
}
187
+
188
+
wg.Wait()
189
+
190
+
var out []*bsky.UnspeccedGetPostThreadV2_ThreadItem
191
+
for _, res := range results {
192
+
out = append(out, res.node)
193
+
out = append(out, res.children...)
194
+
}
195
+
196
+
return out, nil
197
+
}
198
+
199
+
func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadTree, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem {
200
+
if node.missing {
201
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
202
+
Depth: depth,
203
+
Uri: node.uri,
204
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
205
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
206
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
207
+
},
208
+
},
209
+
}
210
+
}
211
+
212
+
// Hydrate the post
213
+
postInfo, err := hydrator.HydratePostDB(ctx, node.uri, node.val, viewer)
214
+
if err != nil {
215
+
slog.Error("failed to hydrate post in thread item", "uri", node.uri, "error", err)
216
+
// Return not found item
217
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
218
+
Depth: depth,
219
+
Uri: node.uri,
220
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
221
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
222
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
223
+
},
224
+
},
225
+
}
226
+
}
227
+
228
+
// Hydrate author
229
+
authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author)
230
+
if err != nil {
231
+
slog.Error("failed to hydrate actor in thread item", "author", postInfo.Author, "error", err)
232
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
233
+
Depth: depth,
234
+
Uri: node.uri,
235
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
236
+
UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{
237
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound",
238
+
},
239
+
},
240
+
}
241
+
}
242
+
243
+
// Build post view
244
+
postView := views.PostView(postInfo, authorInfo)
245
+
246
+
// Calculate moreReplies count
247
+
moreReplies := int64(0)
248
+
if len(node.children) > 0 {
249
+
// This is a simplified calculation - actual count would need more complex logic
250
+
moreReplies = int64(len(node.children))
251
+
}
252
+
253
+
return &bsky.UnspeccedGetPostThreadV2_ThreadItem{
254
+
Depth: depth,
255
+
Uri: node.uri,
256
+
Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{
257
+
UnspeccedDefs_ThreadItemPost: &bsky.UnspeccedDefs_ThreadItemPost{
258
+
LexiconTypeID: "app.bsky.unspecced.defs#threadItemPost",
259
+
Post: postView,
260
+
HiddenByThreadgate: false,
261
+
MoreParents: false,
262
+
MoreReplies: moreReplies,
263
+
MutedByViewer: false,
264
+
OpThread: false, // TODO: Calculate this properly
265
+
},
266
+
},
267
+
}
268
+
}
269
+
270
+
func getUserDID(c echo.Context) string {
271
+
did := c.Get("viewer")
272
+
if did == nil {
273
+
return ""
274
+
}
275
+
if s, ok := did.(string); ok {
276
+
return s
277
+
}
278
+
return ""
279
+
}
280
+
281
+
func extractDIDFromURI(uri string) string {
282
+
// URI format: at://did:plc:xxx/collection/rkey
283
+
if len(uri) < 5 || uri[:5] != "at://" {
284
+
return ""
285
+
}
286
+
parts := []rune(uri[5:])
287
+
for i, r := range parts {
288
+
if r == '/' {
289
+
return string(parts[:i])
290
+
}
291
+
}
292
+
return string(parts)
293
+
}
294
+
295
+
type threadTree struct {
296
+
parent *threadTree
297
+
children []*threadTree
298
+
299
+
val *models.Post
300
+
301
+
missing bool
302
+
303
+
uri string
304
+
cid string
305
+
}
306
+
307
+
func buildThreadTree(ctx context.Context, hydrator *hydration.Hydrator, db *gorm.DB, posts []*models.Post) (map[uint]*threadTree, error) {
308
+
nodes := make(map[uint]*threadTree)
309
+
for _, p := range posts {
310
+
puri, err := hydrator.UriForPost(ctx, p)
311
+
if err != nil {
312
+
return nil, err
313
+
}
314
+
315
+
t := &threadTree{
316
+
val: p,
317
+
uri: puri,
318
+
}
319
+
320
+
nodes[p.ID] = t
321
+
}
322
+
323
+
missing := make(map[uint]*threadTree)
324
+
for _, node := range nodes {
325
+
if node.val.ReplyTo == 0 {
326
+
continue
327
+
}
328
+
329
+
pnode, ok := nodes[node.val.ReplyTo]
330
+
if !ok {
331
+
pnode = &threadTree{
332
+
missing: true,
333
+
}
334
+
missing[node.val.ReplyTo] = pnode
335
+
336
+
var bspost bsky.FeedPost
337
+
if err := bspost.UnmarshalCBOR(bytes.NewReader(node.val.Raw)); err != nil {
338
+
return nil, err
339
+
}
340
+
341
+
if bspost.Reply == nil || bspost.Reply.Parent == nil {
342
+
return nil, fmt.Errorf("node with parent had no parent in object")
343
+
}
344
+
345
+
pnode.uri = bspost.Reply.Parent.Uri
346
+
pnode.cid = bspost.Reply.Parent.Cid
347
+
348
+
/* Maybe we could force hydrate these?
349
+
hydrator.AddMissingRecord(puri, true)
350
+
*/
351
+
}
352
+
353
+
pnode.children = append(pnode.children, node)
354
+
node.parent = pnode
355
+
}
356
+
357
+
for k, v := range missing {
358
+
nodes[k] = v
359
+
}
360
+
361
+
return nodes, nil
362
+
}
+16
xrpc/unspecced/getTrendingTopics.go
+16
xrpc/unspecced/getTrendingTopics.go
···
1
+
package unspecced
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/labstack/echo/v4"
7
+
)
8
+
9
+
// HandleGetTrendingTopics implements app.bsky.unspecced.getTrendingTopics
10
+
// Returns trending topics (empty for now)
11
+
func HandleGetTrendingTopics(c echo.Context) error {
12
+
return c.JSON(http.StatusOK, map[string]interface{}{
13
+
"topics": []interface{}{},
14
+
"suggested": []interface{}{},
15
+
})
16
+
}