A locally focused bluesky appview

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 2 2 frontend/node_modules 3 3 konbini 4 4 sequence.txt 5 + atproto
+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
··· 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
··· 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
··· 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, &notfound); 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
··· 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
··· 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
··· 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
··· 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 # Build stage 2 - FROM node:18-alpine AS builder 2 + FROM node:24-alpine AS builder 3 3 4 4 WORKDIR /app 5 5
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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: &quotedFP, 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: &quotedFP, 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(&notifications).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(&notifications).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
··· 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
··· 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
··· 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
··· 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
··· 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, &notfound); 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
··· 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
··· 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
··· 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
··· 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
··· 1 + { 2 + "backends": [ 3 + { 4 + "type": "jetstream", 5 + "host": "jetstream1.us-west.bsky.network" 6 + } 7 + ] 8 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package actor 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandlePutPreferences implements app.bsky.actor.putPreferences 12 + // Stubbed out for now - just returns success without doing anything 13 + func HandlePutPreferences(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 14 + // Get viewer from authentication 15 + viewer := c.Get("viewer") 16 + if viewer == nil { 17 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 18 + "error": "AuthenticationRequired", 19 + "message": "authentication required", 20 + }) 21 + } 22 + 23 + // For now, just return success without storing anything 24 + return c.JSON(http.StatusOK, map[string]interface{}{}) 25 + }
+106
xrpc/auth.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/labstack/echo/v4" 11 + "github.com/lestrrat-go/jwx/v2/jwt" 12 + ) 13 + 14 + // requireAuth is middleware that requires authentication 15 + func (s *Server) requireAuth(next echo.HandlerFunc) echo.HandlerFunc { 16 + return func(c echo.Context) error { 17 + viewer, err := s.authenticate(c) 18 + if err != nil { 19 + return XRPCError(c, http.StatusUnauthorized, "AuthenticationRequired", err.Error()) 20 + } 21 + c.Set("viewer", viewer) 22 + return next(c) 23 + } 24 + } 25 + 26 + // optionalAuth is middleware that optionally authenticates 27 + func (s *Server) optionalAuth(next echo.HandlerFunc) echo.HandlerFunc { 28 + return func(c echo.Context) error { 29 + viewer, _ := s.authenticate(c) 30 + if viewer != "" { 31 + c.Set("viewer", viewer) 32 + } 33 + return next(c) 34 + } 35 + } 36 + 37 + // authenticate extracts and validates the JWT from the Authorization header 38 + // Returns the viewer DID if valid, empty string otherwise 39 + func (s *Server) authenticate(c echo.Context) (string, error) { 40 + authHeader := c.Request().Header.Get("Authorization") 41 + if authHeader == "" { 42 + return "", fmt.Errorf("missing authorization header") 43 + } 44 + 45 + // Extract Bearer token 46 + parts := strings.Split(authHeader, " ") 47 + if len(parts) != 2 || parts[0] != "Bearer" { 48 + return "", fmt.Errorf("invalid authorization header format") 49 + } 50 + 51 + tokenString := parts[1] 52 + 53 + // Parse JWT without signature validation (for development) 54 + // In production, you'd want to validate the signature using the issuer's public key 55 + token, err := jwt.Parse([]byte(tokenString), jwt.WithVerify(false), jwt.WithValidate(false)) 56 + if err != nil { 57 + return "", fmt.Errorf("failed to parse token: %w", err) 58 + } 59 + 60 + // Extract the user's DID - try both "sub" (PDS tokens) and "iss" (service tokens) 61 + var userDID string 62 + 63 + // First try "sub" claim (used by PDS tokens and entryway tokens) 64 + sub := token.Subject() 65 + if sub != "" && strings.HasPrefix(sub, "did:") { 66 + userDID = sub 67 + } else { 68 + // Fall back to "iss" claim (used by some service tokens) 69 + iss := token.Issuer() 70 + if iss != "" && strings.HasPrefix(iss, "did:") { 71 + userDID = iss 72 + } 73 + } 74 + 75 + if userDID == "" { 76 + return "", fmt.Errorf("missing 'sub' or 'iss' claim with DID in token") 77 + } 78 + 79 + // Optional: check scope if present 80 + scope, ok := token.Get("scope") 81 + if ok { 82 + scopeStr, _ := scope.(string) 83 + // Valid scopes are: com.atproto.access, com.atproto.appPass, com.atproto.appPassPrivileged 84 + if scopeStr != "com.atproto.access" && scopeStr != "com.atproto.appPass" && scopeStr != "com.atproto.appPassPrivileged" { 85 + return "", fmt.Errorf("invalid token scope: %s", scopeStr) 86 + } 87 + } 88 + 89 + return userDID, nil 90 + } 91 + 92 + // resolveActor resolves an actor identifier (handle or DID) to a DID 93 + func (s *Server) resolveActor(ctx context.Context, actor string) (string, error) { 94 + // If it's already a DID, return it 95 + if strings.HasPrefix(actor, "did:") { 96 + return actor, nil 97 + } 98 + 99 + // Otherwise, resolve the handle 100 + resp, err := s.dir.LookupHandle(ctx, syntax.Handle(actor)) 101 + if err != nil { 102 + return "", fmt.Errorf("failed to resolve handle: %w", err) 103 + } 104 + 105 + return resp.DID.String(), nil 106 + }
+119
xrpc/feed/getActorLikes.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetActorLikes implements app.bsky.feed.getActorLikes 14 + func HandleGetActorLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + ctx := c.Request().Context() 24 + 25 + // Resolve actor to DID 26 + actorDID, err := hydrator.ResolveDID(ctx, actorParam) 27 + if err != nil { 28 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 29 + "error": "ActorNotFound", 30 + "message": "actor not found", 31 + }) 32 + } 33 + 34 + // Check authentication - user can only view their own likes 35 + viewer := c.Get("viewer") 36 + if viewer == nil || viewer.(string) != actorDID { 37 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 38 + "error": "AuthenticationRequired", 39 + "message": "you can only view your own likes", 40 + }) 41 + } 42 + 43 + // Parse limit 44 + limit := 50 45 + if limitParam := c.QueryParam("limit"); limitParam != "" { 46 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 47 + limit = l 48 + } 49 + } 50 + 51 + // Parse cursor (like ID) 52 + var cursor uint 53 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 54 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 55 + cursor = uint(c) 56 + } 57 + } 58 + 59 + // Query likes 60 + type likeRow struct { 61 + ID uint 62 + Subject string // post URI 63 + } 64 + var rows []likeRow 65 + 66 + query := ` 67 + SELECT l.id, 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as subject 68 + FROM likes l 69 + JOIN posts p ON p.id = l.subject 70 + JOIN repos r ON r.id = p.author 71 + WHERE l.author = (SELECT id FROM repos WHERE did = ?) 72 + ` 73 + if cursor > 0 { 74 + query += ` AND l.id < ?` 75 + } 76 + query += ` ORDER BY l.id DESC LIMIT ?` 77 + 78 + var queryArgs []interface{} 79 + queryArgs = append(queryArgs, actorDID) 80 + if cursor > 0 { 81 + queryArgs = append(queryArgs, cursor) 82 + } 83 + queryArgs = append(queryArgs, limit) 84 + 85 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 86 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 87 + "error": "InternalError", 88 + "message": "failed to query likes", 89 + }) 90 + } 91 + 92 + // Hydrate posts 93 + feed := make([]interface{}, 0) 94 + for _, row := range rows { 95 + postInfo, err := hydrator.HydratePost(ctx, row.Subject, actorDID) 96 + if err != nil { 97 + continue 98 + } 99 + 100 + // Hydrate the post author 101 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 102 + if err != nil { 103 + continue 104 + } 105 + 106 + feed = append(feed, views.FeedViewPost(postInfo, authorInfo)) 107 + } 108 + 109 + // Generate next cursor 110 + var nextCursor string 111 + if len(rows) > 0 { 112 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 113 + } 114 + 115 + return c.JSON(http.StatusOK, map[string]interface{}{ 116 + "feed": feed, 117 + "cursor": nextCursor, 118 + }) 119 + }
+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
··· 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
··· 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
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetLikes implements app.bsky.feed.getLikes 14 + func HandleGetLikes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + uriParam := c.QueryParam("uri") 16 + if uriParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uri parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (like ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Get post ID from URI 42 + var postID uint 43 + db.Raw(` 44 + SELECT id FROM posts 45 + WHERE author = (SELECT id FROM repos WHERE did = ?) 46 + AND rkey = ? 47 + `, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID) 48 + 49 + if postID == 0 { 50 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 51 + "error": "NotFound", 52 + "message": "post not found", 53 + }) 54 + } 55 + 56 + // Query likes 57 + type likeRow struct { 58 + ID uint 59 + AuthorDid string 60 + Rkey string 61 + Created string 62 + } 63 + var rows []likeRow 64 + 65 + query := ` 66 + SELECT l.id, r.did as author_did, l.rkey, l.created 67 + FROM likes l 68 + JOIN repos r ON r.id = l.author 69 + WHERE l.subject = ? 70 + ` 71 + if cursor > 0 { 72 + query += ` AND l.id < ?` 73 + } 74 + query += ` ORDER BY l.id DESC LIMIT ?` 75 + 76 + var queryArgs []interface{} 77 + queryArgs = append(queryArgs, postID) 78 + if cursor > 0 { 79 + queryArgs = append(queryArgs, cursor) 80 + } 81 + queryArgs = append(queryArgs, limit) 82 + 83 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 84 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 85 + "error": "InternalError", 86 + "message": "failed to query likes", 87 + }) 88 + } 89 + 90 + // Hydrate actors 91 + likes := make([]interface{}, 0) 92 + for _, row := range rows { 93 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 94 + if err != nil { 95 + continue 96 + } 97 + 98 + like := map[string]interface{}{ 99 + "actor": views.ProfileView(actorInfo), 100 + "createdAt": row.Created, 101 + "indexedAt": row.Created, 102 + } 103 + likes = append(likes, like) 104 + } 105 + 106 + // Generate next cursor 107 + var nextCursor string 108 + if len(rows) > 0 { 109 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 110 + } 111 + 112 + return c.JSON(http.StatusOK, map[string]interface{}{ 113 + "uri": uriParam, 114 + "likes": likes, 115 + "cursor": nextCursor, 116 + }) 117 + }
+187
xrpc/feed/getPostThread.go
··· 1 + package feed 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/whyrusleeping/konbini/hydration" 10 + "github.com/whyrusleeping/konbini/views" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // HandleGetPostThread implements app.bsky.feed.getPostThread 15 + func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + uriParam := c.QueryParam("uri") 17 + if uriParam == "" { 18 + return c.JSON(http.StatusBadRequest, map[string]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
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + ) 11 + 12 + // HandleGetPosts implements app.bsky.feed.getPosts 13 + func HandleGetPosts(c echo.Context, hydrator *hydration.Hydrator) error { 14 + // Get URIs from query params (can be multiple) 15 + urisParam := c.QueryParam("uris") 16 + if urisParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uris parameter is required", 20 + }) 21 + } 22 + 23 + // Parse URIs (they come as a comma-separated list or as multiple query params) 24 + var uris []string 25 + if strings.Contains(urisParam, ",") { 26 + uris = strings.Split(urisParam, ",") 27 + } else { 28 + // Check for multiple uri query params 29 + uris = c.QueryParams()["uris"] 30 + if len(uris) == 0 { 31 + uris = []string{urisParam} 32 + } 33 + } 34 + 35 + // Limit to reasonable number 36 + if len(uris) > 25 { 37 + uris = uris[:25] 38 + } 39 + 40 + ctx := c.Request().Context() 41 + viewer := getUserDID(c) 42 + 43 + // Hydrate posts 44 + postsMap, err := hydrator.HydratePosts(ctx, uris, viewer) 45 + if err != nil { 46 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 47 + "error": "InternalError", 48 + "message": "failed to load posts", 49 + }) 50 + } 51 + 52 + // Build response - need to maintain order of requested URIs 53 + posts := make([]interface{}, 0) 54 + for _, uri := range uris { 55 + postInfo, ok := postsMap[uri] 56 + if !ok { 57 + // Post not found, skip it 58 + continue 59 + } 60 + 61 + // Hydrate author 62 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 63 + if err != nil { 64 + continue 65 + } 66 + 67 + postView := views.PostView(postInfo, authorInfo) 68 + posts = append(posts, postView) 69 + } 70 + 71 + return c.JSON(http.StatusOK, map[string]interface{}{ 72 + "posts": posts, 73 + }) 74 + } 75 + 76 + func getUserDID(c echo.Context) string { 77 + did := c.Get("viewer") 78 + if did == nil { 79 + return "" 80 + } 81 + if s, ok := did.(string); ok { 82 + return s 83 + } 84 + return "" 85 + }
+111
xrpc/feed/getRepostedBy.go
··· 1 + package feed 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetRepostedBy implements app.bsky.feed.getRepostedBy 14 + func HandleGetRepostedBy(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + uriParam := c.QueryParam("uri") 16 + if uriParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "uri parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (repost ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Get post ID from URI 42 + var postID uint 43 + db.Raw(` 44 + SELECT id FROM posts 45 + WHERE author = (SELECT id FROM repos WHERE did = ?) 46 + AND rkey = ? 47 + `, extractDIDFromURI(uriParam), extractRkeyFromURI(uriParam)).Scan(&postID) 48 + 49 + if postID == 0 { 50 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 51 + "error": "NotFound", 52 + "message": "post not found", 53 + }) 54 + } 55 + 56 + // Query reposts 57 + type repostRow struct { 58 + ID uint 59 + AuthorDid string 60 + Rkey string 61 + Created string 62 + } 63 + var rows []repostRow 64 + 65 + query := ` 66 + SELECT rp.id, r.did as author_did, rp.rkey, rp.created 67 + FROM reposts rp 68 + JOIN repos r ON r.id = rp.author 69 + WHERE rp.subject = ? 70 + ` 71 + if cursor > 0 { 72 + query += ` AND rp.id < ?` 73 + } 74 + query += ` ORDER BY rp.id DESC LIMIT ?` 75 + 76 + var queryArgs []interface{} 77 + queryArgs = append(queryArgs, postID) 78 + if cursor > 0 { 79 + queryArgs = append(queryArgs, cursor) 80 + } 81 + queryArgs = append(queryArgs, limit) 82 + 83 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 84 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 85 + "error": "InternalError", 86 + "message": "failed to query reposts", 87 + }) 88 + } 89 + 90 + // Hydrate actors 91 + repostedBy := make([]interface{}, 0) 92 + for _, row := range rows { 93 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 94 + if err != nil { 95 + continue 96 + } 97 + repostedBy = append(repostedBy, views.ProfileView(actorInfo)) 98 + } 99 + 100 + // Generate next cursor 101 + var nextCursor string 102 + if len(rows) > 0 { 103 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 104 + } 105 + 106 + return c.JSON(http.StatusOK, map[string]interface{}{ 107 + "uri": uriParam, 108 + "repostedBy": repostedBy, 109 + "cursor": nextCursor, 110 + }) 111 + }
+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
··· 1 + package graph 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/whyrusleeping/konbini/hydration" 10 + "github.com/whyrusleeping/konbini/views" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + // HandleGetBlocks implements app.bsky.graph.getBlocks 15 + func HandleGetBlocks(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + // Get viewer from authentication 17 + viewer := c.Get("viewer") 18 + if viewer == nil { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + viewerDID := viewer.(string) 25 + 26 + // Parse limit 27 + limit := 50 28 + if limitParam := c.QueryParam("limit"); limitParam != "" { 29 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 30 + limit = l 31 + } 32 + } 33 + 34 + // Parse cursor (block ID) 35 + var cursor uint 36 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 37 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 38 + cursor = uint(c) 39 + } 40 + } 41 + 42 + ctx := c.Request().Context() 43 + 44 + // Query blocks 45 + type blockRow struct { 46 + ID uint 47 + SubjectDid string 48 + } 49 + var rows []blockRow 50 + 51 + query := ` 52 + SELECT b.id, r.did as subject_did 53 + FROM blocks b 54 + LEFT JOIN repos r ON r.id = b.subject 55 + WHERE b.author = (SELECT id FROM repos WHERE did = ?) 56 + ` 57 + if cursor > 0 { 58 + query += ` AND b.id < ?` 59 + } 60 + query += ` ORDER BY b.id DESC LIMIT ?` 61 + 62 + var queryArgs []interface{} 63 + queryArgs = append(queryArgs, viewerDID) 64 + if cursor > 0 { 65 + queryArgs = append(queryArgs, cursor) 66 + } 67 + queryArgs = append(queryArgs, limit) 68 + 69 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 70 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 71 + "error": "InternalError", 72 + "message": "failed to query blocks", 73 + }) 74 + } 75 + 76 + // Hydrate blocked actors 77 + blocks := make([]interface{}, 0) 78 + for _, row := range rows { 79 + actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid) 80 + if err != nil { 81 + fmt.Println("Hydrating actor failed: ", err) 82 + continue 83 + } 84 + blocks = append(blocks, views.ProfileView(actorInfo)) 85 + } 86 + 87 + // Generate next cursor 88 + var nextCursor string 89 + if len(rows) > 0 { 90 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 91 + } 92 + 93 + return c.JSON(http.StatusOK, map[string]interface{}{ 94 + "blocks": blocks, 95 + "cursor": nextCursor, 96 + }) 97 + }
+112
xrpc/graph/getFollowers.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetFollowers implements app.bsky.graph.getFollowers 14 + func HandleGetFollowers(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (follow ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Resolve actor to DID 42 + did, err := hydrator.ResolveDID(ctx, actorParam) 43 + if err != nil { 44 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 45 + "error": "ActorNotFound", 46 + "message": "actor not found", 47 + }) 48 + } 49 + 50 + // Get the subject actor info 51 + subjectInfo, err := hydrator.HydrateActor(ctx, did) 52 + if err != nil { 53 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 54 + "error": "ActorNotFound", 55 + "message": "failed to load actor", 56 + }) 57 + } 58 + 59 + // Query followers 60 + type followerRow struct { 61 + ID uint 62 + AuthorDid string 63 + } 64 + var rows []followerRow 65 + 66 + query := ` 67 + SELECT f.id, r.did as author_did 68 + FROM follows f 69 + JOIN repos r ON r.id = f.author 70 + WHERE f.subject = (SELECT id FROM repos WHERE did = ?) 71 + ` 72 + if cursor > 0 { 73 + query += ` AND f.id < ?` 74 + } 75 + query += ` ORDER BY f.id DESC LIMIT ?` 76 + 77 + var queryArgs []interface{} 78 + queryArgs = append(queryArgs, did) 79 + if cursor > 0 { 80 + queryArgs = append(queryArgs, cursor) 81 + } 82 + queryArgs = append(queryArgs, limit) 83 + 84 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 85 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 86 + "error": "InternalError", 87 + "message": "failed to query followers", 88 + }) 89 + } 90 + 91 + // Hydrate follower actors 92 + followers := make([]interface{}, 0) 93 + for _, row := range rows { 94 + actorInfo, err := hydrator.HydrateActor(ctx, row.AuthorDid) 95 + if err != nil { 96 + continue 97 + } 98 + followers = append(followers, views.ProfileView(actorInfo)) 99 + } 100 + 101 + // Generate next cursor 102 + var nextCursor string 103 + if len(rows) > 0 { 104 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 105 + } 106 + 107 + return c.JSON(http.StatusOK, map[string]interface{}{ 108 + "subject": views.ProfileView(subjectInfo), 109 + "followers": followers, 110 + "cursor": nextCursor, 111 + }) 112 + }
+112
xrpc/graph/getFollows.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/labstack/echo/v4" 8 + "github.com/whyrusleeping/konbini/hydration" 9 + "github.com/whyrusleeping/konbini/views" 10 + "gorm.io/gorm" 11 + ) 12 + 13 + // HandleGetFollows implements app.bsky.graph.getFollows 14 + func HandleGetFollows(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 15 + actorParam := c.QueryParam("actor") 16 + if actorParam == "" { 17 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + "error": "InvalidRequest", 19 + "message": "actor parameter is required", 20 + }) 21 + } 22 + 23 + // Parse limit 24 + limit := 50 25 + if limitParam := c.QueryParam("limit"); limitParam != "" { 26 + if l, err := strconv.Atoi(limitParam); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + // Parse cursor (follow ID) 32 + var cursor uint 33 + if cursorParam := c.QueryParam("cursor"); cursorParam != "" { 34 + if c, err := strconv.ParseUint(cursorParam, 10, 64); err == nil { 35 + cursor = uint(c) 36 + } 37 + } 38 + 39 + ctx := c.Request().Context() 40 + 41 + // Resolve actor to DID 42 + did, err := hydrator.ResolveDID(ctx, actorParam) 43 + if err != nil { 44 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 45 + "error": "ActorNotFound", 46 + "message": "actor not found", 47 + }) 48 + } 49 + 50 + // Get the subject actor info (the person whose follows we're listing) 51 + subjectInfo, err := hydrator.HydrateActor(ctx, did) 52 + if err != nil { 53 + return c.JSON(http.StatusNotFound, map[string]interface{}{ 54 + "error": "ActorNotFound", 55 + "message": "failed to load actor", 56 + }) 57 + } 58 + 59 + // Query follows 60 + type followRow struct { 61 + ID uint 62 + SubjectDid string 63 + } 64 + var rows []followRow 65 + 66 + query := ` 67 + SELECT f.id, r.did as subject_did 68 + FROM follows f 69 + JOIN repos r ON r.id = f.subject 70 + WHERE f.author = (SELECT id FROM repos WHERE did = ?) 71 + ` 72 + if cursor > 0 { 73 + query += ` AND f.id < ?` 74 + } 75 + query += ` ORDER BY f.id DESC LIMIT ?` 76 + 77 + var queryArgs []interface{} 78 + queryArgs = append(queryArgs, did) 79 + if cursor > 0 { 80 + queryArgs = append(queryArgs, cursor) 81 + } 82 + queryArgs = append(queryArgs, limit) 83 + 84 + if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 85 + return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 86 + "error": "InternalError", 87 + "message": "failed to query follows", 88 + }) 89 + } 90 + 91 + // Hydrate followed actors 92 + follows := make([]interface{}, 0) 93 + for _, row := range rows { 94 + actorInfo, err := hydrator.HydrateActor(ctx, row.SubjectDid) 95 + if err != nil { 96 + continue 97 + } 98 + follows = append(follows, views.ProfileView(actorInfo)) 99 + } 100 + 101 + // Generate next cursor 102 + var nextCursor string 103 + if len(rows) > 0 { 104 + nextCursor = strconv.FormatUint(uint64(rows[len(rows)-1].ID), 10) 105 + } 106 + 107 + return c.JSON(http.StatusOK, map[string]interface{}{ 108 + "subject": views.ProfileView(subjectInfo), 109 + "follows": follows, 110 + "cursor": nextCursor, 111 + }) 112 + }
+41
xrpc/graph/getMutes.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandleGetMutes implements app.bsky.graph.getMutes 12 + // NOTE: Mutes are typically stored as user preferences/settings, not as repo records. 13 + // This implementation returns an empty list as mute tracking is not yet implemented 14 + // in the database schema. 15 + func HandleGetMutes(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 + // Get viewer from authentication 17 + viewer := c.Get("viewer") 18 + if viewer == nil { 19 + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 20 + "error": "AuthenticationRequired", 21 + "message": "authentication required", 22 + }) 23 + } 24 + 25 + // TODO: Implement mute tracking in the database 26 + // Mutes are different from blocks - they're typically stored as preferences 27 + // rather than as repo records. Would need a new table like: 28 + // CREATE TABLE user_mutes ( 29 + // id SERIAL PRIMARY KEY, 30 + // actor_did TEXT NOT NULL, 31 + // muted_did TEXT NOT NULL, 32 + // created_at TIMESTAMP NOT NULL, 33 + // UNIQUE(actor_did, muted_did) 34 + // ); 35 + 36 + // For now, return empty list 37 + return c.JSON(http.StatusOK, map[string]interface{}{ 38 + "mutes": []interface{}{}, 39 + "cursor": "", 40 + }) 41 + }
+102
xrpc/graph/getRelationships.go
··· 1 + package graph 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + "github.com/whyrusleeping/konbini/hydration" 8 + "gorm.io/gorm" 9 + ) 10 + 11 + // HandleGetRelationships implements app.bsky.graph.getRelationships 12 + func HandleGetRelationships(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 13 + actorParam := c.QueryParam("actor") 14 + if actorParam == "" { 15 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 16 + "error": "InvalidRequest", 17 + "message": "actor parameter is required", 18 + }) 19 + } 20 + 21 + // Parse others parameter (can be multiple) 22 + others := c.QueryParams()["others"] 23 + if len(others) == 0 { 24 + return c.JSON(http.StatusOK, map[string]interface{}{ 25 + "actor": actorParam, 26 + "relationships": []interface{}{}, 27 + }) 28 + } 29 + 30 + // Limit to reasonable batch size 31 + if len(others) > 30 { 32 + others = others[:30] 33 + } 34 + 35 + ctx := c.Request().Context() 36 + 37 + // Resolve actor to DID 38 + actorDID, err := hydrator.ResolveDID(ctx, actorParam) 39 + if err != nil { 40 + return c.JSON(http.StatusBadRequest, map[string]interface{}{ 41 + "error": "ActorNotFound", 42 + "message": "actor not found", 43 + }) 44 + } 45 + 46 + // Build relationships for each "other" actor 47 + relationships := make([]interface{}, 0, len(others)) 48 + 49 + for _, other := range others { 50 + // Resolve other to DID 51 + otherDID, err := hydrator.ResolveDID(ctx, other) 52 + if err != nil { 53 + // Actor not found 54 + relationships = append(relationships, map[string]interface{}{ 55 + "$type": "app.bsky.graph.defs#notFoundActor", 56 + "actor": other, 57 + "notFound": true, 58 + }) 59 + continue 60 + } 61 + 62 + // Check if actor follows other 63 + var following string 64 + err = db.Raw(` 65 + SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri 66 + FROM follows f 67 + JOIN repos r1 ON r1.id = f.author 68 + JOIN repos r2 ON r2.id = f.subject 69 + WHERE r1.did = ? AND r2.did = ? 70 + LIMIT 1 71 + `, actorDID, otherDID).Scan(&following).Error 72 + if err != nil { 73 + following = "" 74 + } 75 + 76 + // Check if other follows actor 77 + var followedBy string 78 + err = db.Raw(` 79 + SELECT 'at://' || r1.did || '/app.bsky.graph.follow/' || f.rkey as uri 80 + FROM follows f 81 + JOIN repos r1 ON r1.id = f.author 82 + JOIN repos r2 ON r2.id = f.subject 83 + WHERE r1.did = ? AND r2.did = ? 84 + LIMIT 1 85 + `, otherDID, actorDID).Scan(&followedBy).Error 86 + if err != nil { 87 + followedBy = "" 88 + } 89 + 90 + relationships = append(relationships, map[string]interface{}{ 91 + "$type": "app.bsky.graph.defs#relationship", 92 + "did": otherDID, 93 + "following": following, 94 + "followedBy": followedBy, 95 + }) 96 + } 97 + 98 + return c.JSON(http.StatusOK, map[string]interface{}{ 99 + "actor": actorDID, 100 + "relationships": relationships, 101 + }) 102 + }
+30
xrpc/identity.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/labstack/echo/v4" 9 + ) 10 + 11 + // handleResolveHandle implements com.atproto.identity.resolveHandle 12 + func (s *Server) handleResolveHandle(c echo.Context) error { 13 + handle := c.QueryParam("handle") 14 + if handle == "" { 15 + return XRPCError(c, http.StatusBadRequest, "InvalidRequest", "handle parameter is required") 16 + } 17 + 18 + // Clean up handle (remove @ prefix if present) 19 + handle = strings.TrimPrefix(handle, "@") 20 + 21 + // Resolve handle to DID 22 + resp, err := s.dir.LookupHandle(c.Request().Context(), syntax.Handle(handle)) 23 + if err != nil { 24 + return XRPCError(c, http.StatusBadRequest, "HandleNotFound", "handle not found") 25 + } 26 + 27 + return c.JSON(http.StatusOK, map[string]interface{}{ 28 + "did": resp.DID.String(), 29 + }) 30 + }
+17
xrpc/labeler/getServices.go
··· 1 + package labeler 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + // HandleGetServices implements app.bsky.labeler.getServices 10 + // Returns information about labeler services 11 + func HandleGetServices(c echo.Context) error { 12 + // For now, return empty views since we don't have labeler support 13 + // A full implementation would parse the "dids" query parameter 14 + return c.JSON(http.StatusOK, map[string]interface{}{ 15 + "views": []interface{}{}, 16 + }) 17 + }
+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(&notifSeen).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
··· 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
··· 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
··· 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
··· 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
··· 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 + }