Commit 25d14e49ab68
tarball18 files changed+5952-0
@@ -0,0 +1,6 @@
1
+target/2
+/result3
+*.sqlite4
+*.sqlite-shm5
+*.sqlite-wal6
+*.sqlite.lock@@ -0,0 +1,2047 @@
1
+# This file is automatically @generated by Cargo.2
+# It is not intended for manual editing.3
+version = 44
+5
+[[package]]6
+name = "aead"7
+version = "0.5.2"8
+source = "registry+https://github.com/rust-lang/crates.io-index"9
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"10
+dependencies = [11
+ "crypto-common",12
+ "generic-array",13
+]14
+15
+[[package]]16
+name = "aho-corasick"17
+version = "1.1.5"18
+source = "registry+https://github.com/rust-lang/crates.io-index"19
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"20
+dependencies = [21
+ "memchr",22
+]23
+24
+[[package]]25
+name = "anstream"26
+version = "1.0.0"27
+source = "registry+https://github.com/rust-lang/crates.io-index"28
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"29
+dependencies = [30
+ "anstyle",31
+ "anstyle-parse",32
+ "anstyle-query",33
+ "anstyle-wincon",34
+ "colorchoice",35
+ "is_terminal_polyfill",36
+ "utf8parse",37
+]38
+39
+[[package]]40
+name = "anstyle"41
+version = "1.0.14"42
+source = "registry+https://github.com/rust-lang/crates.io-index"43
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"44
+45
+[[package]]46
+name = "anstyle-parse"47
+version = "1.0.0"48
+source = "registry+https://github.com/rust-lang/crates.io-index"49
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"50
+dependencies = [51
+ "utf8parse",52
+]53
+54
+[[package]]55
+name = "anstyle-query"56
+version = "1.1.5"57
+source = "registry+https://github.com/rust-lang/crates.io-index"58
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"59
+dependencies = [60
+ "windows-sys 0.61.2",61
+]62
+63
+[[package]]64
+name = "anstyle-wincon"65
+version = "3.0.11"66
+source = "registry+https://github.com/rust-lang/crates.io-index"67
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"68
+dependencies = [69
+ "anstyle",70
+ "once_cell_polyfill",71
+ "windows-sys 0.61.2",72
+]73
+74
+[[package]]75
+name = "anyhow"76
+version = "1.0.104"77
+source = "registry+https://github.com/rust-lang/crates.io-index"78
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"79
+80
+[[package]]81
+name = "arrayvec"82
+version = "0.7.8"83
+source = "registry+https://github.com/rust-lang/crates.io-index"84
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"85
+86
+[[package]]87
+name = "atomic-waker"88
+version = "1.1.2"89
+source = "registry+https://github.com/rust-lang/crates.io-index"90
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"91
+92
+[[package]]93
+name = "base64"94
+version = "0.22.1"95
+source = "registry+https://github.com/rust-lang/crates.io-index"96
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"97
+98
+[[package]]99
+name = "bech32"100
+version = "0.12.0"101
+source = "registry+https://github.com/rust-lang/crates.io-index"102
+checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8"103
+104
+[[package]]105
+name = "bip39"106
+version = "2.2.2"107
+source = "registry+https://github.com/rust-lang/crates.io-index"108
+checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"109
+dependencies = [110
+ "bitcoin_hashes 0.14.101",111
+ "serde",112
+ "unicode-normalization",113
+]114
+115
+[[package]]116
+name = "bitcoin-consensus-encoding"117
+version = "1.2.0"118
+source = "registry+https://github.com/rust-lang/crates.io-index"119
+checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"120
+dependencies = [121
+ "bitcoin-internals",122
+ "hex-conservative 1.3.0",123
+ "serde",124
+]125
+126
+[[package]]127
+name = "bitcoin-internals"128
+version = "0.6.0"129
+source = "registry+https://github.com/rust-lang/crates.io-index"130
+checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5"131
+132
+[[package]]133
+name = "bitcoin-io"134
+version = "0.1.101"135
+source = "registry+https://github.com/rust-lang/crates.io-index"136
+checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78"137
+dependencies = [138
+ "bitcoin-consensus-encoding",139
+]140
+141
+[[package]]142
+name = "bitcoin_hashes"143
+version = "0.14.101"144
+source = "registry+https://github.com/rust-lang/crates.io-index"145
+checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"146
+dependencies = [147
+ "bitcoin-io",148
+ "hex-conservative 0.2.3",149
+]150
+151
+[[package]]152
+name = "bitcoin_hashes"153
+version = "1.2.0"154
+source = "registry+https://github.com/rust-lang/crates.io-index"155
+checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e"156
+dependencies = [157
+ "bitcoin-consensus-encoding",158
+ "bitcoin-internals",159
+ "hex-conservative 1.3.0",160
+ "serde",161
+]162
+163
+[[package]]164
+name = "bitflags"165
+version = "2.13.2"166
+source = "registry+https://github.com/rust-lang/crates.io-index"167
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"168
+169
+[[package]]170
+name = "block-buffer"171
+version = "0.10.4"172
+source = "registry+https://github.com/rust-lang/crates.io-index"173
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"174
+dependencies = [175
+ "generic-array",176
+]177
+178
+[[package]]179
+name = "block-padding"180
+version = "0.3.3"181
+source = "registry+https://github.com/rust-lang/crates.io-index"182
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"183
+dependencies = [184
+ "generic-array",185
+]186
+187
+[[package]]188
+name = "bumpalo"189
+version = "3.20.3"190
+source = "registry+https://github.com/rust-lang/crates.io-index"191
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"192
+193
+[[package]]194
+name = "byteorder"195
+version = "1.5.0"196
+source = "registry+https://github.com/rust-lang/crates.io-index"197
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"198
+199
+[[package]]200
+name = "bytes"201
+version = "1.12.1"202
+source = "registry+https://github.com/rust-lang/crates.io-index"203
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"204
+205
+[[package]]206
+name = "cbc"207
+version = "0.1.2"208
+source = "registry+https://github.com/rust-lang/crates.io-index"209
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"210
+dependencies = [211
+ "cipher",212
+]213
+214
+[[package]]215
+name = "cc"216
+version = "1.4.6"217
+source = "registry+https://github.com/rust-lang/crates.io-index"218
+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"219
+dependencies = [220
+ "find-msvc-tools",221
+ "shlex",222
+]223
+224
+[[package]]225
+name = "cfg-if"226
+version = "1.0.5"227
+source = "registry+https://github.com/rust-lang/crates.io-index"228
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"229
+230
+[[package]]231
+name = "chacha20"232
+version = "0.9.1"233
+source = "registry+https://github.com/rust-lang/crates.io-index"234
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"235
+dependencies = [236
+ "cfg-if",237
+ "cipher",238
+ "cpufeatures",239
+]240
+241
+[[package]]242
+name = "chacha20poly1305"243
+version = "0.10.1"244
+source = "registry+https://github.com/rust-lang/crates.io-index"245
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"246
+dependencies = [247
+ "aead",248
+ "chacha20",249
+ "cipher",250
+ "poly1305",251
+ "zeroize",252
+]253
+254
+[[package]]255
+name = "cipher"256
+version = "0.4.4"257
+source = "registry+https://github.com/rust-lang/crates.io-index"258
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"259
+dependencies = [260
+ "crypto-common",261
+ "inout",262
+ "zeroize",263
+]264
+265
+[[package]]266
+name = "clap"267
+version = "4.6.7"268
+source = "registry+https://github.com/rust-lang/crates.io-index"269
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"270
+dependencies = [271
+ "clap_builder",272
+ "clap_derive",273
+]274
+275
+[[package]]276
+name = "clap_builder"277
+version = "4.6.7"278
+source = "registry+https://github.com/rust-lang/crates.io-index"279
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"280
+dependencies = [281
+ "anstream",282
+ "anstyle",283
+ "clap_lex",284
+ "strsim",285
+]286
+287
+[[package]]288
+name = "clap_derive"289
+version = "4.6.7"290
+source = "registry+https://github.com/rust-lang/crates.io-index"291
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"292
+dependencies = [293
+ "heck",294
+ "proc-macro2",295
+ "quote",296
+ "syn 3.0.6",297
+]298
+299
+[[package]]300
+name = "clap_lex"301
+version = "1.1.1"302
+source = "registry+https://github.com/rust-lang/crates.io-index"303
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"304
+305
+[[package]]306
+name = "colorchoice"307
+version = "1.0.5"308
+source = "registry+https://github.com/rust-lang/crates.io-index"309
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"310
+311
+[[package]]312
+name = "cpufeatures"313
+version = "0.2.17"314
+source = "registry+https://github.com/rust-lang/crates.io-index"315
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"316
+dependencies = [317
+ "libc",318
+]319
+320
+[[package]]321
+name = "crypto-common"322
+version = "0.1.7"323
+source = "registry+https://github.com/rust-lang/crates.io-index"324
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"325
+dependencies = [326
+ "generic-array",327
+ "typenum",328
+]329
+330
+[[package]]331
+name = "data-encoding"332
+version = "2.11.1"333
+source = "registry+https://github.com/rust-lang/crates.io-index"334
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"335
+336
+[[package]]337
+name = "digest"338
+version = "0.10.7"339
+source = "registry+https://github.com/rust-lang/crates.io-index"340
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"341
+dependencies = [342
+ "block-buffer",343
+ "crypto-common",344
+]345
+346
+[[package]]347
+name = "displaydoc"348
+version = "0.2.7"349
+source = "registry+https://github.com/rust-lang/crates.io-index"350
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"351
+dependencies = [352
+ "proc-macro2",353
+ "quote",354
+ "syn 3.0.6",355
+]356
+357
+[[package]]358
+name = "equivalent"359
+version = "1.0.2"360
+source = "registry+https://github.com/rust-lang/crates.io-index"361
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"362
+363
+[[package]]364
+name = "errno"365
+version = "0.3.14"366
+source = "registry+https://github.com/rust-lang/crates.io-index"367
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"368
+dependencies = [369
+ "libc",370
+ "windows-sys 0.61.2",371
+]372
+373
+[[package]]374
+name = "fallible-iterator"375
+version = "0.3.0"376
+source = "registry+https://github.com/rust-lang/crates.io-index"377
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"378
+379
+[[package]]380
+name = "fallible-streaming-iterator"381
+version = "0.1.9"382
+source = "registry+https://github.com/rust-lang/crates.io-index"383
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"384
+385
+[[package]]386
+name = "faster-hex"387
+version = "0.10.0"388
+source = "registry+https://github.com/rust-lang/crates.io-index"389
+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"390
+dependencies = [391
+ "heapless",392
+ "serde",393
+]394
+395
+[[package]]396
+name = "find-msvc-tools"397
+version = "0.1.12"398
+source = "registry+https://github.com/rust-lang/crates.io-index"399
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"400
+401
+[[package]]402
+name = "foldhash"403
+version = "0.2.0"404
+source = "registry+https://github.com/rust-lang/crates.io-index"405
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"406
+407
+[[package]]408
+name = "form_urlencoded"409
+version = "1.2.2"410
+source = "registry+https://github.com/rust-lang/crates.io-index"411
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"412
+dependencies = [413
+ "percent-encoding",414
+]415
+416
+[[package]]417
+name = "fs2"418
+version = "0.4.3"419
+source = "registry+https://github.com/rust-lang/crates.io-index"420
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"421
+dependencies = [422
+ "libc",423
+ "winapi",424
+]425
+426
+[[package]]427
+name = "futures-channel"428
+version = "0.3.34"429
+source = "registry+https://github.com/rust-lang/crates.io-index"430
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"431
+dependencies = [432
+ "futures-core",433
+]434
+435
+[[package]]436
+name = "futures-core"437
+version = "0.3.34"438
+source = "registry+https://github.com/rust-lang/crates.io-index"439
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"440
+441
+[[package]]442
+name = "futures-macro"443
+version = "0.3.34"444
+source = "registry+https://github.com/rust-lang/crates.io-index"445
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"446
+dependencies = [447
+ "proc-macro2",448
+ "quote",449
+ "syn 3.0.6",450
+]451
+452
+[[package]]453
+name = "futures-sink"454
+version = "0.3.34"455
+source = "registry+https://github.com/rust-lang/crates.io-index"456
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"457
+458
+[[package]]459
+name = "futures-task"460
+version = "0.3.34"461
+source = "registry+https://github.com/rust-lang/crates.io-index"462
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"463
+464
+[[package]]465
+name = "futures-util"466
+version = "0.3.34"467
+source = "registry+https://github.com/rust-lang/crates.io-index"468
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"469
+dependencies = [470
+ "futures-core",471
+ "futures-macro",472
+ "futures-sink",473
+ "futures-task",474
+ "pin-project-lite",475
+ "slab",476
+]477
+478
+[[package]]479
+name = "generic-array"480
+version = "0.14.7"481
+source = "registry+https://github.com/rust-lang/crates.io-index"482
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"483
+dependencies = [484
+ "typenum",485
+ "version_check",486
+]487
+488
+[[package]]489
+name = "getrandom"490
+version = "0.2.17"491
+source = "registry+https://github.com/rust-lang/crates.io-index"492
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"493
+dependencies = [494
+ "cfg-if",495
+ "libc",496
+ "wasi",497
+]498
+499
+[[package]]500
+name = "getrandom"501
+version = "0.3.4"502
+source = "registry+https://github.com/rust-lang/crates.io-index"503
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"504
+dependencies = [505
+ "cfg-if",506
+ "libc",507
+ "r-efi 5.3.0",508
+ "wasip2",509
+]510
+511
+[[package]]512
+name = "getrandom"513
+version = "0.4.3"514
+source = "registry+https://github.com/rust-lang/crates.io-index"515
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"516
+dependencies = [517
+ "cfg-if",518
+ "libc",519
+ "r-efi 6.0.0",520
+]521
+522
+[[package]]523
+name = "hash32"524
+version = "0.3.1"525
+source = "registry+https://github.com/rust-lang/crates.io-index"526
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"527
+dependencies = [528
+ "byteorder",529
+]530
+531
+[[package]]532
+name = "hashbrown"533
+version = "0.16.1"534
+source = "registry+https://github.com/rust-lang/crates.io-index"535
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"536
+dependencies = [537
+ "foldhash",538
+]539
+540
+[[package]]541
+name = "hashbrown"542
+version = "0.17.1"543
+source = "registry+https://github.com/rust-lang/crates.io-index"544
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"545
+546
+[[package]]547
+name = "hashlink"548
+version = "0.11.1"549
+source = "registry+https://github.com/rust-lang/crates.io-index"550
+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"551
+dependencies = [552
+ "hashbrown 0.16.1",553
+]554
+555
+[[package]]556
+name = "heapless"557
+version = "0.8.0"558
+source = "registry+https://github.com/rust-lang/crates.io-index"559
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"560
+dependencies = [561
+ "hash32",562
+ "stable_deref_trait",563
+]564
+565
+[[package]]566
+name = "heck"567
+version = "0.5.0"568
+source = "registry+https://github.com/rust-lang/crates.io-index"569
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"570
+571
+[[package]]572
+name = "hex-conservative"573
+version = "0.2.3"574
+source = "registry+https://github.com/rust-lang/crates.io-index"575
+checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"576
+dependencies = [577
+ "arrayvec",578
+]579
+580
+[[package]]581
+name = "hex-conservative"582
+version = "1.3.0"583
+source = "registry+https://github.com/rust-lang/crates.io-index"584
+checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8"585
+dependencies = [586
+ "arrayvec",587
+]588
+589
+[[package]]590
+name = "http"591
+version = "1.5.0"592
+source = "registry+https://github.com/rust-lang/crates.io-index"593
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"594
+dependencies = [595
+ "bytes",596
+ "itoa",597
+]598
+599
+[[package]]600
+name = "http-body"601
+version = "1.1.0"602
+source = "registry+https://github.com/rust-lang/crates.io-index"603
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"604
+dependencies = [605
+ "bytes",606
+ "http",607
+]608
+609
+[[package]]610
+name = "http-body-util"611
+version = "0.1.5"612
+source = "registry+https://github.com/rust-lang/crates.io-index"613
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"614
+dependencies = [615
+ "bytes",616
+ "futures-core",617
+ "http",618
+ "http-body",619
+ "pin-project-lite",620
+]621
+622
+[[package]]623
+name = "httparse"624
+version = "1.10.1"625
+source = "registry+https://github.com/rust-lang/crates.io-index"626
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"627
+628
+[[package]]629
+name = "httpdate"630
+version = "1.0.3"631
+source = "registry+https://github.com/rust-lang/crates.io-index"632
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"633
+634
+[[package]]635
+name = "hyper"636
+version = "1.11.1"637
+source = "registry+https://github.com/rust-lang/crates.io-index"638
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"639
+dependencies = [640
+ "atomic-waker",641
+ "bytes",642
+ "futures-channel",643
+ "futures-core",644
+ "http",645
+ "http-body",646
+ "httparse",647
+ "httpdate",648
+ "itoa",649
+ "pin-project-lite",650
+ "smallvec",651
+ "tokio",652
+]653
+654
+[[package]]655
+name = "hyper-util"656
+version = "0.1.20"657
+source = "registry+https://github.com/rust-lang/crates.io-index"658
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"659
+dependencies = [660
+ "bytes",661
+ "http",662
+ "http-body",663
+ "hyper",664
+ "pin-project-lite",665
+ "tokio",666
+]667
+668
+[[package]]669
+name = "icu_collections"670
+version = "2.1.1"671
+source = "registry+https://github.com/rust-lang/crates.io-index"672
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"673
+dependencies = [674
+ "displaydoc",675
+ "potential_utf",676
+ "yoke",677
+ "zerofrom",678
+ "zerovec",679
+]680
+681
+[[package]]682
+name = "icu_locale_core"683
+version = "2.1.1"684
+source = "registry+https://github.com/rust-lang/crates.io-index"685
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"686
+dependencies = [687
+ "displaydoc",688
+ "litemap",689
+ "tinystr",690
+ "writeable",691
+ "zerovec",692
+]693
+694
+[[package]]695
+name = "icu_normalizer"696
+version = "2.1.1"697
+source = "registry+https://github.com/rust-lang/crates.io-index"698
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"699
+dependencies = [700
+ "icu_collections",701
+ "icu_normalizer_data",702
+ "icu_properties",703
+ "icu_provider",704
+ "smallvec",705
+ "zerovec",706
+]707
+708
+[[package]]709
+name = "icu_normalizer_data"710
+version = "2.1.1"711
+source = "registry+https://github.com/rust-lang/crates.io-index"712
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"713
+714
+[[package]]715
+name = "icu_properties"716
+version = "2.1.2"717
+source = "registry+https://github.com/rust-lang/crates.io-index"718
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"719
+dependencies = [720
+ "icu_collections",721
+ "icu_locale_core",722
+ "icu_properties_data",723
+ "icu_provider",724
+ "zerotrie",725
+ "zerovec",726
+]727
+728
+[[package]]729
+name = "icu_properties_data"730
+version = "2.1.2"731
+source = "registry+https://github.com/rust-lang/crates.io-index"732
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"733
+734
+[[package]]735
+name = "icu_provider"736
+version = "2.1.1"737
+source = "registry+https://github.com/rust-lang/crates.io-index"738
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"739
+dependencies = [740
+ "displaydoc",741
+ "icu_locale_core",742
+ "writeable",743
+ "yoke",744
+ "zerofrom",745
+ "zerotrie",746
+ "zerovec",747
+]748
+749
+[[package]]750
+name = "idna"751
+version = "1.1.0"752
+source = "registry+https://github.com/rust-lang/crates.io-index"753
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"754
+dependencies = [755
+ "idna_adapter",756
+ "smallvec",757
+ "utf8_iter",758
+]759
+760
+[[package]]761
+name = "idna_adapter"762
+version = "1.2.1"763
+source = "registry+https://github.com/rust-lang/crates.io-index"764
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"765
+dependencies = [766
+ "icu_normalizer",767
+ "icu_properties",768
+]769
+770
+[[package]]771
+name = "indexmap"772
+version = "2.14.2"773
+source = "registry+https://github.com/rust-lang/crates.io-index"774
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"775
+dependencies = [776
+ "equivalent",777
+ "hashbrown 0.17.1",778
+]779
+780
+[[package]]781
+name = "inout"782
+version = "0.1.4"783
+source = "registry+https://github.com/rust-lang/crates.io-index"784
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"785
+dependencies = [786
+ "block-padding",787
+ "generic-array",788
+]789
+790
+[[package]]791
+name = "is_terminal_polyfill"792
+version = "1.70.2"793
+source = "registry+https://github.com/rust-lang/crates.io-index"794
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"795
+796
+[[package]]797
+name = "itoa"798
+version = "1.0.18"799
+source = "registry+https://github.com/rust-lang/crates.io-index"800
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"801
+802
+[[package]]803
+name = "js-sys"804
+version = "0.3.105"805
+source = "registry+https://github.com/rust-lang/crates.io-index"806
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"807
+dependencies = [808
+ "cfg-if",809
+ "wasm-bindgen",810
+]811
+812
+[[package]]813
+name = "lazy_static"814
+version = "1.5.0"815
+source = "registry+https://github.com/rust-lang/crates.io-index"816
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"817
+818
+[[package]]819
+name = "libc"820
+version = "0.2.189"821
+source = "registry+https://github.com/rust-lang/crates.io-index"822
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"823
+824
+[[package]]825
+name = "libsqlite3-sys"826
+version = "0.36.0"827
+source = "registry+https://github.com/rust-lang/crates.io-index"828
+checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"829
+dependencies = [830
+ "cc",831
+ "pkg-config",832
+ "vcpkg",833
+]834
+835
+[[package]]836
+name = "litemap"837
+version = "0.8.3"838
+source = "registry+https://github.com/rust-lang/crates.io-index"839
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"840
+841
+[[package]]842
+name = "log"843
+version = "0.4.34"844
+source = "registry+https://github.com/rust-lang/crates.io-index"845
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"846
+847
+[[package]]848
+name = "matchers"849
+version = "0.2.0"850
+source = "registry+https://github.com/rust-lang/crates.io-index"851
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"852
+dependencies = [853
+ "regex-automata",854
+]855
+856
+[[package]]857
+name = "memchr"858
+version = "2.8.3"859
+source = "registry+https://github.com/rust-lang/crates.io-index"860
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"861
+862
+[[package]]863
+name = "mio"864
+version = "1.2.3"865
+source = "registry+https://github.com/rust-lang/crates.io-index"866
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"867
+dependencies = [868
+ "libc",869
+ "wasi",870
+ "windows-sys 0.61.2",871
+]872
+873
+[[package]]874
+name = "nostr"875
+version = "0.45.5"876
+source = "registry+https://github.com/rust-lang/crates.io-index"877
+checksum = "38ca5c25a6df8d4d78fdf39b42792438f6ce22b4809ccbfdb27582ed9ca45407"878
+dependencies = [879
+ "base64",880
+ "bech32",881
+ "bip39",882
+ "bitcoin_hashes 1.2.0",883
+ "cbc",884
+ "chacha20",885
+ "chacha20poly1305",886
+ "faster-hex",887
+ "opaquerr",888
+ "rand 0.10.2",889
+ "secp256k1",890
+ "serde",891
+ "serde_json",892
+ "unicode-normalization",893
+ "universal-time",894
+ "url",895
+ "zeroize",896
+]897
+898
+[[package]]899
+name = "nostr-database"900
+version = "0.45.2"901
+source = "registry+https://github.com/rust-lang/crates.io-index"902
+checksum = "309950383c9854ca413d195705400e78b1376aedc48f3256754a86c609c047e8"903
+dependencies = [904
+ "nostr",905
+ "opaquerr",906
+]907
+908
+[[package]]909
+name = "nu-ansi-term"910
+version = "0.50.3"911
+source = "registry+https://github.com/rust-lang/crates.io-index"912
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"913
+dependencies = [914
+ "windows-sys 0.61.2",915
+]916
+917
+[[package]]918
+name = "once_cell"919
+version = "1.21.4"920
+source = "registry+https://github.com/rust-lang/crates.io-index"921
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"922
+923
+[[package]]924
+name = "once_cell_polyfill"925
+version = "1.70.2"926
+source = "registry+https://github.com/rust-lang/crates.io-index"927
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"928
+929
+[[package]]930
+name = "opaque-debug"931
+version = "0.3.1"932
+source = "registry+https://github.com/rust-lang/crates.io-index"933
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"934
+935
+[[package]]936
+name = "opaquerr"937
+version = "0.2.0"938
+source = "registry+https://github.com/rust-lang/crates.io-index"939
+checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"940
+941
+[[package]]942
+name = "percent-encoding"943
+version = "2.3.2"944
+source = "registry+https://github.com/rust-lang/crates.io-index"945
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"946
+947
+[[package]]948
+name = "pin-project-lite"949
+version = "0.2.17"950
+source = "registry+https://github.com/rust-lang/crates.io-index"951
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"952
+953
+[[package]]954
+name = "pkg-config"955
+version = "0.3.34"956
+source = "registry+https://github.com/rust-lang/crates.io-index"957
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"958
+959
+[[package]]960
+name = "poly1305"961
+version = "0.8.0"962
+source = "registry+https://github.com/rust-lang/crates.io-index"963
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"964
+dependencies = [965
+ "cpufeatures",966
+ "opaque-debug",967
+ "universal-hash",968
+]969
+970
+[[package]]971
+name = "potential_utf"972
+version = "0.1.6"973
+source = "registry+https://github.com/rust-lang/crates.io-index"974
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"975
+dependencies = [976
+ "zerovec",977
+]978
+979
+[[package]]980
+name = "ppv-lite86"981
+version = "0.2.21"982
+source = "registry+https://github.com/rust-lang/crates.io-index"983
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"984
+dependencies = [985
+ "zerocopy",986
+]987
+988
+[[package]]989
+name = "proc-macro2"990
+version = "1.0.107"991
+source = "registry+https://github.com/rust-lang/crates.io-index"992
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"993
+dependencies = [994
+ "unicode-ident",995
+]996
+997
+[[package]]998
+name = "quote"999
+version = "1.0.47"1000
+source = "registry+https://github.com/rust-lang/crates.io-index"1001
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"1002
+dependencies = [1003
+ "proc-macro2",1004
+]1005
+1006
+[[package]]1007
+name = "r-efi"1008
+version = "5.3.0"1009
+source = "registry+https://github.com/rust-lang/crates.io-index"1010
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"1011
+1012
+[[package]]1013
+name = "r-efi"1014
+version = "6.0.0"1015
+source = "registry+https://github.com/rust-lang/crates.io-index"1016
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"1017
+1018
+[[package]]1019
+name = "rand"1020
+version = "0.8.8"1021
+source = "registry+https://github.com/rust-lang/crates.io-index"1022
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"1023
+dependencies = [1024
+ "libc",1025
+ "rand_chacha 0.3.1",1026
+ "rand_core 0.6.4",1027
+]1028
+1029
+[[package]]1030
+name = "rand"1031
+version = "0.9.5"1032
+source = "registry+https://github.com/rust-lang/crates.io-index"1033
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"1034
+dependencies = [1035
+ "rand_chacha 0.9.0",1036
+ "rand_core 0.9.5",1037
+]1038
+1039
+[[package]]1040
+name = "rand"1041
+version = "0.10.2"1042
+source = "registry+https://github.com/rust-lang/crates.io-index"1043
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"1044
+dependencies = [1045
+ "getrandom 0.4.3",1046
+ "rand_core 0.10.1",1047
+]1048
+1049
+[[package]]1050
+name = "rand_chacha"1051
+version = "0.3.1"1052
+source = "registry+https://github.com/rust-lang/crates.io-index"1053
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"1054
+dependencies = [1055
+ "ppv-lite86",1056
+ "rand_core 0.6.4",1057
+]1058
+1059
+[[package]]1060
+name = "rand_chacha"1061
+version = "0.9.0"1062
+source = "registry+https://github.com/rust-lang/crates.io-index"1063
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"1064
+dependencies = [1065
+ "ppv-lite86",1066
+ "rand_core 0.9.5",1067
+]1068
+1069
+[[package]]1070
+name = "rand_core"1071
+version = "0.6.4"1072
+source = "registry+https://github.com/rust-lang/crates.io-index"1073
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"1074
+dependencies = [1075
+ "getrandom 0.2.17",1076
+]1077
+1078
+[[package]]1079
+name = "rand_core"1080
+version = "0.9.5"1081
+source = "registry+https://github.com/rust-lang/crates.io-index"1082
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"1083
+dependencies = [1084
+ "getrandom 0.3.4",1085
+]1086
+1087
+[[package]]1088
+name = "rand_core"1089
+version = "0.10.1"1090
+source = "registry+https://github.com/rust-lang/crates.io-index"1091
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"1092
+1093
+[[package]]1094
+name = "regex-automata"1095
+version = "0.4.18"1096
+source = "registry+https://github.com/rust-lang/crates.io-index"1097
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"1098
+dependencies = [1099
+ "aho-corasick",1100
+ "memchr",1101
+ "regex-syntax",1102
+]1103
+1104
+[[package]]1105
+name = "regex-syntax"1106
+version = "0.8.11"1107
+source = "registry+https://github.com/rust-lang/crates.io-index"1108
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"1109
+1110
+[[package]]1111
+name = "ring"1112
+version = "0.17.14"1113
+source = "registry+https://github.com/rust-lang/crates.io-index"1114
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"1115
+dependencies = [1116
+ "cc",1117
+ "cfg-if",1118
+ "getrandom 0.2.17",1119
+ "libc",1120
+ "untrusted",1121
+ "windows-sys 0.52.0",1122
+]1123
+1124
+[[package]]1125
+name = "rsqlite-vfs"1126
+version = "0.1.1"1127
+source = "registry+https://github.com/rust-lang/crates.io-index"1128
+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"1129
+dependencies = [1130
+ "hashbrown 0.16.1",1131
+ "thiserror",1132
+]1133
+1134
+[[package]]1135
+name = "rusqlite"1136
+version = "0.38.0"1137
+source = "registry+https://github.com/rust-lang/crates.io-index"1138
+checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"1139
+dependencies = [1140
+ "bitflags",1141
+ "fallible-iterator",1142
+ "fallible-streaming-iterator",1143
+ "hashlink",1144
+ "libsqlite3-sys",1145
+ "smallvec",1146
+ "sqlite-wasm-rs",1147
+]1148
+1149
+[[package]]1150
+name = "rustls"1151
+version = "0.23.45"1152
+source = "registry+https://github.com/rust-lang/crates.io-index"1153
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"1154
+dependencies = [1155
+ "once_cell",1156
+ "rustls-pki-types",1157
+ "rustls-webpki",1158
+ "subtle",1159
+ "zeroize",1160
+]1161
+1162
+[[package]]1163
+name = "rustls-pki-types"1164
+version = "1.15.1"1165
+source = "registry+https://github.com/rust-lang/crates.io-index"1166
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"1167
+dependencies = [1168
+ "zeroize",1169
+]1170
+1171
+[[package]]1172
+name = "rustls-webpki"1173
+version = "0.103.15"1174
+source = "registry+https://github.com/rust-lang/crates.io-index"1175
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"1176
+dependencies = [1177
+ "ring",1178
+ "rustls-pki-types",1179
+ "untrusted",1180
+]1181
+1182
+[[package]]1183
+name = "rustversion"1184
+version = "1.0.23"1185
+source = "registry+https://github.com/rust-lang/crates.io-index"1186
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"1187
+1188
+[[package]]1189
+name = "secp256k1"1190
+version = "0.30.0"1191
+source = "registry+https://github.com/rust-lang/crates.io-index"1192
+checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252"1193
+dependencies = [1194
+ "bitcoin_hashes 0.14.101",1195
+ "rand 0.8.8",1196
+ "secp256k1-sys",1197
+]1198
+1199
+[[package]]1200
+name = "secp256k1-sys"1201
+version = "0.10.1"1202
+source = "registry+https://github.com/rust-lang/crates.io-index"1203
+checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"1204
+dependencies = [1205
+ "cc",1206
+]1207
+1208
+[[package]]1209
+name = "serde"1210
+version = "1.0.229"1211
+source = "registry+https://github.com/rust-lang/crates.io-index"1212
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"1213
+dependencies = [1214
+ "serde_core",1215
+ "serde_derive",1216
+]1217
+1218
+[[package]]1219
+name = "serde_core"1220
+version = "1.0.229"1221
+source = "registry+https://github.com/rust-lang/crates.io-index"1222
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"1223
+dependencies = [1224
+ "serde_derive",1225
+]1226
+1227
+[[package]]1228
+name = "serde_derive"1229
+version = "1.0.229"1230
+source = "registry+https://github.com/rust-lang/crates.io-index"1231
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"1232
+dependencies = [1233
+ "proc-macro2",1234
+ "quote",1235
+ "syn 3.0.6",1236
+]1237
+1238
+[[package]]1239
+name = "serde_json"1240
+version = "1.0.151"1241
+source = "registry+https://github.com/rust-lang/crates.io-index"1242
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"1243
+dependencies = [1244
+ "itoa",1245
+ "memchr",1246
+ "serde",1247
+ "serde_core",1248
+ "zmij",1249
+]1250
+1251
+[[package]]1252
+name = "serde_spanned"1253
+version = "0.6.9"1254
+source = "registry+https://github.com/rust-lang/crates.io-index"1255
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"1256
+dependencies = [1257
+ "serde",1258
+]1259
+1260
+[[package]]1261
+name = "sha1"1262
+version = "0.10.7"1263
+source = "registry+https://github.com/rust-lang/crates.io-index"1264
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"1265
+dependencies = [1266
+ "cfg-if",1267
+ "cpufeatures",1268
+ "digest",1269
+]1270
+1271
+[[package]]1272
+name = "sharded-slab"1273
+version = "0.1.7"1274
+source = "registry+https://github.com/rust-lang/crates.io-index"1275
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"1276
+dependencies = [1277
+ "lazy_static",1278
+]1279
+1280
+[[package]]1281
+name = "shlex"1282
+version = "2.0.1"1283
+source = "registry+https://github.com/rust-lang/crates.io-index"1284
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"1285
+1286
+[[package]]1287
+name = "signal-hook-registry"1288
+version = "1.4.8"1289
+source = "registry+https://github.com/rust-lang/crates.io-index"1290
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"1291
+dependencies = [1292
+ "errno",1293
+ "libc",1294
+]1295
+1296
+[[package]]1297
+name = "slab"1298
+version = "0.4.12"1299
+source = "registry+https://github.com/rust-lang/crates.io-index"1300
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"1301
+1302
+[[package]]1303
+name = "smallvec"1304
+version = "1.16.1"1305
+source = "registry+https://github.com/rust-lang/crates.io-index"1306
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"1307
+1308
+[[package]]1309
+name = "socket2"1310
+version = "0.6.5"1311
+source = "registry+https://github.com/rust-lang/crates.io-index"1312
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"1313
+dependencies = [1314
+ "libc",1315
+ "windows-sys 0.61.2",1316
+]1317
+1318
+[[package]]1319
+name = "sqlite-wasm-rs"1320
+version = "0.5.5"1321
+source = "registry+https://github.com/rust-lang/crates.io-index"1322
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"1323
+dependencies = [1324
+ "cc",1325
+ "js-sys",1326
+ "rsqlite-vfs",1327
+ "wasm-bindgen",1328
+]1329
+1330
+[[package]]1331
+name = "stable_deref_trait"1332
+version = "1.2.1"1333
+source = "registry+https://github.com/rust-lang/crates.io-index"1334
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"1335
+1336
+[[package]]1337
+name = "strsim"1338
+version = "0.11.1"1339
+source = "registry+https://github.com/rust-lang/crates.io-index"1340
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"1341
+1342
+[[package]]1343
+name = "subtle"1344
+version = "2.6.1"1345
+source = "registry+https://github.com/rust-lang/crates.io-index"1346
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"1347
+1348
+[[package]]1349
+name = "syn"1350
+version = "2.0.119"1351
+source = "registry+https://github.com/rust-lang/crates.io-index"1352
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"1353
+dependencies = [1354
+ "proc-macro2",1355
+ "quote",1356
+ "unicode-ident",1357
+]1358
+1359
+[[package]]1360
+name = "syn"1361
+version = "3.0.6"1362
+source = "registry+https://github.com/rust-lang/crates.io-index"1363
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"1364
+dependencies = [1365
+ "proc-macro2",1366
+ "quote",1367
+ "unicode-ident",1368
+]1369
+1370
+[[package]]1371
+name = "synstructure"1372
+version = "0.14.0"1373
+source = "registry+https://github.com/rust-lang/crates.io-index"1374
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"1375
+dependencies = [1376
+ "proc-macro2",1377
+ "quote",1378
+ "syn 3.0.6",1379
+]1380
+1381
+[[package]]1382
+name = "thiserror"1383
+version = "2.0.20"1384
+source = "registry+https://github.com/rust-lang/crates.io-index"1385
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"1386
+dependencies = [1387
+ "thiserror-impl",1388
+]1389
+1390
+[[package]]1391
+name = "thiserror-impl"1392
+version = "2.0.20"1393
+source = "registry+https://github.com/rust-lang/crates.io-index"1394
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"1395
+dependencies = [1396
+ "proc-macro2",1397
+ "quote",1398
+ "syn 3.0.6",1399
+]1400
+1401
+[[package]]1402
+name = "thread_local"1403
+version = "1.1.10"1404
+source = "registry+https://github.com/rust-lang/crates.io-index"1405
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"1406
+dependencies = [1407
+ "cfg-if",1408
+]1409
+1410
+[[package]]1411
+name = "tinystr"1412
+version = "0.8.4"1413
+source = "registry+https://github.com/rust-lang/crates.io-index"1414
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"1415
+dependencies = [1416
+ "displaydoc",1417
+ "zerovec",1418
+]1419
+1420
+[[package]]1421
+name = "tinyvec"1422
+version = "1.13.3"1423
+source = "registry+https://github.com/rust-lang/crates.io-index"1424
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"1425
+1426
+[[package]]1427
+name = "tokio"1428
+version = "1.53.1"1429
+source = "registry+https://github.com/rust-lang/crates.io-index"1430
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"1431
+dependencies = [1432
+ "bytes",1433
+ "libc",1434
+ "mio",1435
+ "pin-project-lite",1436
+ "signal-hook-registry",1437
+ "socket2",1438
+ "tokio-macros",1439
+ "windows-sys 0.61.2",1440
+]1441
+1442
+[[package]]1443
+name = "tokio-macros"1444
+version = "2.7.2"1445
+source = "registry+https://github.com/rust-lang/crates.io-index"1446
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"1447
+dependencies = [1448
+ "proc-macro2",1449
+ "quote",1450
+ "syn 3.0.6",1451
+]1452
+1453
+[[package]]1454
+name = "tokio-rustls"1455
+version = "0.26.5"1456
+source = "registry+https://github.com/rust-lang/crates.io-index"1457
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"1458
+dependencies = [1459
+ "rustls",1460
+ "tokio",1461
+]1462
+1463
+[[package]]1464
+name = "tokio-tungstenite"1465
+version = "0.28.0"1466
+source = "registry+https://github.com/rust-lang/crates.io-index"1467
+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"1468
+dependencies = [1469
+ "futures-util",1470
+ "log",1471
+ "rustls",1472
+ "rustls-pki-types",1473
+ "tokio",1474
+ "tokio-rustls",1475
+ "tungstenite",1476
+ "webpki-roots 0.26.11",1477
+]1478
+1479
+[[package]]1480
+name = "tokio-util"1481
+version = "0.7.19"1482
+source = "registry+https://github.com/rust-lang/crates.io-index"1483
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"1484
+dependencies = [1485
+ "bytes",1486
+ "futures-core",1487
+ "futures-sink",1488
+ "futures-util",1489
+ "pin-project-lite",1490
+ "tokio",1491
+]1492
+1493
+[[package]]1494
+name = "toml"1495
+version = "0.8.23"1496
+source = "registry+https://github.com/rust-lang/crates.io-index"1497
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"1498
+dependencies = [1499
+ "serde",1500
+ "serde_spanned",1501
+ "toml_datetime",1502
+ "toml_edit",1503
+]1504
+1505
+[[package]]1506
+name = "toml_datetime"1507
+version = "0.6.11"1508
+source = "registry+https://github.com/rust-lang/crates.io-index"1509
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"1510
+dependencies = [1511
+ "serde",1512
+]1513
+1514
+[[package]]1515
+name = "toml_edit"1516
+version = "0.22.27"1517
+source = "registry+https://github.com/rust-lang/crates.io-index"1518
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"1519
+dependencies = [1520
+ "indexmap",1521
+ "serde",1522
+ "serde_spanned",1523
+ "toml_datetime",1524
+ "toml_write",1525
+ "winnow",1526
+]1527
+1528
+[[package]]1529
+name = "toml_write"1530
+version = "0.1.2"1531
+source = "registry+https://github.com/rust-lang/crates.io-index"1532
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"1533
+1534
+[[package]]1535
+name = "tracing"1536
+version = "0.1.44"1537
+source = "registry+https://github.com/rust-lang/crates.io-index"1538
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"1539
+dependencies = [1540
+ "pin-project-lite",1541
+ "tracing-attributes",1542
+ "tracing-core",1543
+]1544
+1545
+[[package]]1546
+name = "tracing-attributes"1547
+version = "0.1.31"1548
+source = "registry+https://github.com/rust-lang/crates.io-index"1549
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"1550
+dependencies = [1551
+ "proc-macro2",1552
+ "quote",1553
+ "syn 2.0.119",1554
+]1555
+1556
+[[package]]1557
+name = "tracing-core"1558
+version = "0.1.36"1559
+source = "registry+https://github.com/rust-lang/crates.io-index"1560
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"1561
+dependencies = [1562
+ "once_cell",1563
+ "valuable",1564
+]1565
+1566
+[[package]]1567
+name = "tracing-log"1568
+version = "0.2.0"1569
+source = "registry+https://github.com/rust-lang/crates.io-index"1570
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"1571
+dependencies = [1572
+ "log",1573
+ "once_cell",1574
+ "tracing-core",1575
+]1576
+1577
+[[package]]1578
+name = "tracing-subscriber"1579
+version = "0.3.23"1580
+source = "registry+https://github.com/rust-lang/crates.io-index"1581
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"1582
+dependencies = [1583
+ "matchers",1584
+ "nu-ansi-term",1585
+ "once_cell",1586
+ "regex-automata",1587
+ "sharded-slab",1588
+ "smallvec",1589
+ "thread_local",1590
+ "tracing",1591
+ "tracing-core",1592
+ "tracing-log",1593
+]1594
+1595
+[[package]]1596
+name = "tungstenite"1597
+version = "0.28.0"1598
+source = "registry+https://github.com/rust-lang/crates.io-index"1599
+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"1600
+dependencies = [1601
+ "bytes",1602
+ "data-encoding",1603
+ "http",1604
+ "httparse",1605
+ "log",1606
+ "rand 0.9.5",1607
+ "rustls",1608
+ "rustls-pki-types",1609
+ "sha1",1610
+ "thiserror",1611
+ "utf-8",1612
+]1613
+1614
+[[package]]1615
+name = "typenum"1616
+version = "1.20.1"1617
+source = "registry+https://github.com/rust-lang/crates.io-index"1618
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"1619
+1620
+[[package]]1621
+name = "unicode-ident"1622
+version = "1.0.26"1623
+source = "registry+https://github.com/rust-lang/crates.io-index"1624
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"1625
+1626
+[[package]]1627
+name = "unicode-normalization"1628
+version = "0.1.25"1629
+source = "registry+https://github.com/rust-lang/crates.io-index"1630
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"1631
+dependencies = [1632
+ "tinyvec",1633
+]1634
+1635
+[[package]]1636
+name = "universal-hash"1637
+version = "0.5.1"1638
+source = "registry+https://github.com/rust-lang/crates.io-index"1639
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"1640
+dependencies = [1641
+ "crypto-common",1642
+ "subtle",1643
+]1644
+1645
+[[package]]1646
+name = "universal-time"1647
+version = "0.3.1"1648
+source = "registry+https://github.com/rust-lang/crates.io-index"1649
+checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856"1650
+1651
+[[package]]1652
+name = "untrusted"1653
+version = "0.9.0"1654
+source = "registry+https://github.com/rust-lang/crates.io-index"1655
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"1656
+1657
+[[package]]1658
+name = "url"1659
+version = "2.5.8"1660
+source = "registry+https://github.com/rust-lang/crates.io-index"1661
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"1662
+dependencies = [1663
+ "form_urlencoded",1664
+ "idna",1665
+ "percent-encoding",1666
+ "serde",1667
+ "serde_derive",1668
+]1669
+1670
+[[package]]1671
+name = "utf-8"1672
+version = "0.7.6"1673
+source = "registry+https://github.com/rust-lang/crates.io-index"1674
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"1675
+1676
+[[package]]1677
+name = "utf8_iter"1678
+version = "1.0.4"1679
+source = "registry+https://github.com/rust-lang/crates.io-index"1680
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"1681
+1682
+[[package]]1683
+name = "utf8parse"1684
+version = "0.2.2"1685
+source = "registry+https://github.com/rust-lang/crates.io-index"1686
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"1687
+1688
+[[package]]1689
+name = "valuable"1690
+version = "0.1.1"1691
+source = "registry+https://github.com/rust-lang/crates.io-index"1692
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"1693
+1694
+[[package]]1695
+name = "vcpkg"1696
+version = "0.2.15"1697
+source = "registry+https://github.com/rust-lang/crates.io-index"1698
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"1699
+1700
+[[package]]1701
+name = "version_check"1702
+version = "0.9.5"1703
+source = "registry+https://github.com/rust-lang/crates.io-index"1704
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"1705
+1706
+[[package]]1707
+name = "vineyard"1708
+version = "0.1.0"1709
+dependencies = [1710
+ "anyhow",1711
+ "clap",1712
+ "fs2",1713
+ "futures-util",1714
+ "http-body-util",1715
+ "hyper",1716
+ "hyper-util",1717
+ "nostr",1718
+ "nostr-database",1719
+ "rusqlite",1720
+ "serde",1721
+ "serde_json",1722
+ "tokio",1723
+ "tokio-tungstenite",1724
+ "tokio-util",1725
+ "toml",1726
+ "tracing",1727
+ "tracing-subscriber",1728
+ "url",1729
+]1730
+1731
+[[package]]1732
+name = "wasi"1733
+version = "0.11.1+wasi-snapshot-preview1"1734
+source = "registry+https://github.com/rust-lang/crates.io-index"1735
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"1736
+1737
+[[package]]1738
+name = "wasip2"1739
+version = "1.0.1+wasi-0.2.4"1740
+source = "registry+https://github.com/rust-lang/crates.io-index"1741
+checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"1742
+dependencies = [1743
+ "wit-bindgen",1744
+]1745
+1746
+[[package]]1747
+name = "wasm-bindgen"1748
+version = "0.2.128"1749
+source = "registry+https://github.com/rust-lang/crates.io-index"1750
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"1751
+dependencies = [1752
+ "cfg-if",1753
+ "once_cell",1754
+ "rustversion",1755
+ "wasm-bindgen-macro",1756
+ "wasm-bindgen-shared",1757
+]1758
+1759
+[[package]]1760
+name = "wasm-bindgen-macro"1761
+version = "0.2.128"1762
+source = "registry+https://github.com/rust-lang/crates.io-index"1763
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"1764
+dependencies = [1765
+ "quote",1766
+ "wasm-bindgen-macro-support",1767
+]1768
+1769
+[[package]]1770
+name = "wasm-bindgen-macro-support"1771
+version = "0.2.128"1772
+source = "registry+https://github.com/rust-lang/crates.io-index"1773
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"1774
+dependencies = [1775
+ "bumpalo",1776
+ "proc-macro2",1777
+ "quote",1778
+ "syn 3.0.6",1779
+ "wasm-bindgen-shared",1780
+]1781
+1782
+[[package]]1783
+name = "wasm-bindgen-shared"1784
+version = "0.2.128"1785
+source = "registry+https://github.com/rust-lang/crates.io-index"1786
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"1787
+dependencies = [1788
+ "unicode-ident",1789
+]1790
+1791
+[[package]]1792
+name = "webpki-roots"1793
+version = "0.26.11"1794
+source = "registry+https://github.com/rust-lang/crates.io-index"1795
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"1796
+dependencies = [1797
+ "webpki-roots 1.0.9",1798
+]1799
+1800
+[[package]]1801
+name = "webpki-roots"1802
+version = "1.0.9"1803
+source = "registry+https://github.com/rust-lang/crates.io-index"1804
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"1805
+dependencies = [1806
+ "rustls-pki-types",1807
+]1808
+1809
+[[package]]1810
+name = "winapi"1811
+version = "0.3.9"1812
+source = "registry+https://github.com/rust-lang/crates.io-index"1813
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"1814
+dependencies = [1815
+ "winapi-i686-pc-windows-gnu",1816
+ "winapi-x86_64-pc-windows-gnu",1817
+]1818
+1819
+[[package]]1820
+name = "winapi-i686-pc-windows-gnu"1821
+version = "0.4.0"1822
+source = "registry+https://github.com/rust-lang/crates.io-index"1823
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"1824
+1825
+[[package]]1826
+name = "winapi-x86_64-pc-windows-gnu"1827
+version = "0.4.0"1828
+source = "registry+https://github.com/rust-lang/crates.io-index"1829
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"1830
+1831
+[[package]]1832
+name = "windows-link"1833
+version = "0.2.1"1834
+source = "registry+https://github.com/rust-lang/crates.io-index"1835
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"1836
+1837
+[[package]]1838
+name = "windows-sys"1839
+version = "0.52.0"1840
+source = "registry+https://github.com/rust-lang/crates.io-index"1841
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"1842
+dependencies = [1843
+ "windows-targets",1844
+]1845
+1846
+[[package]]1847
+name = "windows-sys"1848
+version = "0.61.2"1849
+source = "registry+https://github.com/rust-lang/crates.io-index"1850
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"1851
+dependencies = [1852
+ "windows-link",1853
+]1854
+1855
+[[package]]1856
+name = "windows-targets"1857
+version = "0.52.6"1858
+source = "registry+https://github.com/rust-lang/crates.io-index"1859
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"1860
+dependencies = [1861
+ "windows_aarch64_gnullvm",1862
+ "windows_aarch64_msvc",1863
+ "windows_i686_gnu",1864
+ "windows_i686_gnullvm",1865
+ "windows_i686_msvc",1866
+ "windows_x86_64_gnu",1867
+ "windows_x86_64_gnullvm",1868
+ "windows_x86_64_msvc",1869
+]1870
+1871
+[[package]]1872
+name = "windows_aarch64_gnullvm"1873
+version = "0.52.6"1874
+source = "registry+https://github.com/rust-lang/crates.io-index"1875
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"1876
+1877
+[[package]]1878
+name = "windows_aarch64_msvc"1879
+version = "0.52.6"1880
+source = "registry+https://github.com/rust-lang/crates.io-index"1881
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"1882
+1883
+[[package]]1884
+name = "windows_i686_gnu"1885
+version = "0.52.6"1886
+source = "registry+https://github.com/rust-lang/crates.io-index"1887
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"1888
+1889
+[[package]]1890
+name = "windows_i686_gnullvm"1891
+version = "0.52.6"1892
+source = "registry+https://github.com/rust-lang/crates.io-index"1893
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"1894
+1895
+[[package]]1896
+name = "windows_i686_msvc"1897
+version = "0.52.6"1898
+source = "registry+https://github.com/rust-lang/crates.io-index"1899
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"1900
+1901
+[[package]]1902
+name = "windows_x86_64_gnu"1903
+version = "0.52.6"1904
+source = "registry+https://github.com/rust-lang/crates.io-index"1905
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"1906
+1907
+[[package]]1908
+name = "windows_x86_64_gnullvm"1909
+version = "0.52.6"1910
+source = "registry+https://github.com/rust-lang/crates.io-index"1911
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"1912
+1913
+[[package]]1914
+name = "windows_x86_64_msvc"1915
+version = "0.52.6"1916
+source = "registry+https://github.com/rust-lang/crates.io-index"1917
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"1918
+1919
+[[package]]1920
+name = "winnow"1921
+version = "0.7.15"1922
+source = "registry+https://github.com/rust-lang/crates.io-index"1923
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"1924
+dependencies = [1925
+ "memchr",1926
+]1927
+1928
+[[package]]1929
+name = "wit-bindgen"1930
+version = "0.46.0"1931
+source = "registry+https://github.com/rust-lang/crates.io-index"1932
+checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"1933
+1934
+[[package]]1935
+name = "writeable"1936
+version = "0.6.4"1937
+source = "registry+https://github.com/rust-lang/crates.io-index"1938
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"1939
+1940
+[[package]]1941
+name = "yoke"1942
+version = "0.8.3"1943
+source = "registry+https://github.com/rust-lang/crates.io-index"1944
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"1945
+dependencies = [1946
+ "stable_deref_trait",1947
+ "yoke-derive",1948
+ "zerofrom",1949
+]1950
+1951
+[[package]]1952
+name = "yoke-derive"1953
+version = "0.8.3"1954
+source = "registry+https://github.com/rust-lang/crates.io-index"1955
+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"1956
+dependencies = [1957
+ "proc-macro2",1958
+ "quote",1959
+ "syn 3.0.6",1960
+ "synstructure",1961
+]1962
+1963
+[[package]]1964
+name = "zerocopy"1965
+version = "0.8.57"1966
+source = "registry+https://github.com/rust-lang/crates.io-index"1967
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"1968
+dependencies = [1969
+ "zerocopy-derive",1970
+]1971
+1972
+[[package]]1973
+name = "zerocopy-derive"1974
+version = "0.8.57"1975
+source = "registry+https://github.com/rust-lang/crates.io-index"1976
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"1977
+dependencies = [1978
+ "proc-macro2",1979
+ "quote",1980
+ "syn 2.0.119",1981
+]1982
+1983
+[[package]]1984
+name = "zerofrom"1985
+version = "0.1.8"1986
+source = "registry+https://github.com/rust-lang/crates.io-index"1987
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"1988
+dependencies = [1989
+ "zerofrom-derive",1990
+]1991
+1992
+[[package]]1993
+name = "zerofrom-derive"1994
+version = "0.1.8"1995
+source = "registry+https://github.com/rust-lang/crates.io-index"1996
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"1997
+dependencies = [1998
+ "proc-macro2",1999
+ "quote",2000
+ "syn 3.0.6",2001
+ "synstructure",2002
+]2003
+2004
+[[package]]2005
+name = "zeroize"2006
+version = "1.9.0"2007
+source = "registry+https://github.com/rust-lang/crates.io-index"2008
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"2009
+2010
+[[package]]2011
+name = "zerotrie"2012
+version = "0.2.5"2013
+source = "registry+https://github.com/rust-lang/crates.io-index"2014
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"2015
+dependencies = [2016
+ "displaydoc",2017
+ "yoke",2018
+ "zerofrom",2019
+]2020
+2021
+[[package]]2022
+name = "zerovec"2023
+version = "0.11.8"2024
+source = "registry+https://github.com/rust-lang/crates.io-index"2025
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"2026
+dependencies = [2027
+ "yoke",2028
+ "zerofrom",2029
+ "zerovec-derive",2030
+]2031
+2032
+[[package]]2033
+name = "zerovec-derive"2034
+version = "0.11.6"2035
+source = "registry+https://github.com/rust-lang/crates.io-index"2036
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"2037
+dependencies = [2038
+ "proc-macro2",2039
+ "quote",2040
+ "syn 3.0.6",2041
+]2042
+2043
+[[package]]2044
+name = "zmij"2045
+version = "1.0.23"2046
+source = "registry+https://github.com/rust-lang/crates.io-index"2047
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"@@ -0,0 +1,68 @@
1
+[package]2
+name = "vineyard"3
+version = "0.1.0"4
+edition = "2024"5
+rust-version = "1.85"6
+description = "a persistent nostr relay for git forges"7
+publish = false8
+9
+[dependencies]10
+anyhow = "1"11
+clap = { version = "4", features = ["derive"] }12
+futures-util = "0.3"13
+fs2 = "0.4"14
+http-body-util = "0.1"15
+hyper = { version = "1", features = ["server", "http1"] }16
+hyper-util = { version = "0.1", features = ["tokio"] }17
+nostr = "=0.45.5"18
+nostr-database = "=0.45.2"19
+rusqlite = { version = "0.38", features = ["bundled"] }20
+serde = { version = "1", features = ["derive"] }21
+serde_json = "1"22
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "net", "sync", "time", "io-util"] }23
+tokio-util = { version = "0.7", features = ["rt"] }24
+tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }25
+toml = "0.8"26
+tracing = "0.1"27
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }28
+url = "2"29
+30
+[lints.clippy]31
+cargo = { level = "warn", priority = -1 }32
+nursery = { level = "warn", priority = -1 }33
+pedantic = { level = "warn", priority = -1 }34
+35
+blanket_clippy_restriction_lints = "allow"36
+restriction = { level = "warn", priority = -1 }37
+38
+arbitrary_source_item_ordering = "allow"39
+arithmetic_side_effects = "allow"40
+decimal_literal_representation = "allow"41
+else_if_without_else = "allow"42
+error_impl_error = "allow"43
+exhaustive_enums = "allow"44
+exhaustive_structs = "allow"45
+field_scoped_visibility_modifiers = "allow"46
+implicit_return = "allow"47
+indexing_slicing = "allow"48
+integer_division = "allow"49
+integer_division_remainder_used = "allow"50
+missing_trait_methods = "allow"51
+missing_docs_in_private_items = "allow"52
+missing_errors_doc = "allow"53
+missing_inline_in_public_items = "allow"54
+missing_panics_doc = "allow"55
+module_name_repetitions = "allow"56
+multiple_crate_versions = "allow"57
+question_mark_used = "allow"58
+ref_patterns = "allow"59
+pub_with_shorthand = "allow"60
+separated_literal_suffix = "allow"61
+single_call_fn = "allow"62
+std_instead_of_alloc = "allow"63
+std_instead_of_core = "allow"64
+tests_outside_test_module = "allow"65
+66
+[profile.release]67
+lto = "thin"68
+strip = true@@ -0,0 +1,108 @@
1
+# vineyard2
+3
+a nostr federation companion for brewery4
+5
+public repository discovery and signed event exchange between forges6
+git hosting and signing keys stay with brewery7
+8
+## run9
+10
+copy `vineyard.example.toml` and replace `forge_pubkeys` with brewery's public key11
+database paths are relative to the config file12
+13
+```sh14
+nix build15
+result/bin/vineyard check-config --config vineyard.toml16
+result/bin/vineyard serve --config vineyard.toml17
+```18
+19
+put tls termination in front of the loopback listener20
+forward websocket upgrades at the path in `public_url`21
+`/healthz` checks database access22
+request `application/nostr+json` at the websocket path for relay information23
+24
+## nixos25
+26
+import `nixosModules.vineyard` and configure the service27
+28
+```nix29
+services.vineyard = {30
+ enable = true;31
+ publicUrl = "wss://relay.example.org/";32
+ forgePubkeys = [ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ];33
+ peers = [ { url = "wss://peer.example.org/"; } ];34
+};35
+```36
+37
+the module runs vineyard under a dynamic user and stores data in `/var/lib/vineyard`38
+39
+## peers40
+41
+add one or more bootstrap relays to connect your forge to the network42
+43
+```toml44
+[[peers]]45
+url = "wss://peer.example.org/"46
+query_limit = 50047
+```48
+49
+use a query limit no larger than the peer supports50
+peers must accept forge events without nip42 authentication51
+remote repository announcements retain their signer's identity52
+brewery publishes its signed nip 65 relay list53
+peer relay lists are persisted and joined automatically54
+discovered peers are bounded by `max_discovered_peers`55
+discovered peers require wss and public dns addresses by default56
+set `allow_private_discovery` only for trusted private networks or local development57
+58
+delivery progress and failed-event retries persist in sqlite59
+incoming history uses a seven-day overlap60
+set a peer's `rescan_from` to an older unix timestamp for manual recovery61
+single-second history overflow stops replay without advancing its checkpoint62
+63
+## brewery64
+65
+build brewery with its `federation` feature or nix package option66
+67
+```sh68
+export BREWERY_FEDERATION=169
+export BREWERY_NOSTR_RELAYS=wss://relay.example.org/70
+export BREWERY_PUBLIC_URL=https://git.example.org71
+brewery repo issue-mode alice/widget users_and_tokens72
+brewery repo patch-mode alice/widget tokens73
+```74
+75
+repositories must be public76
+brewery retains git hosting and verifies openpgp binding proofs77
+the companion brewery changes persist replay progress in `federation.inbox.json`78
+set `BREWERY_FEDERATION_RESCAN_FROM` to a unix timestamp for an older rescan79
+80
+## limits81
+82
+defaults are in `vineyard.example.toml`83
+merged history is bounded by `max_query_bytes`84
+queries without an explicit limit fail when history exceeds the result cap85
+`max_repositories` bounds the shared repository directory86
+slow subscribers disconnect and must replay87
+peer limits use the direct socket address, so a proxy shares one allowance88
+89
+the database budget limits sqlite pages, not total disk usage90
+leave space for the wal and backups91
+accepted events are not evicted to make room92
+93
+## backup94
+95
+stop vineyard before copying its config and database96
+preserve any accompanying sqlite wal file97
+restore with the same forge public keys98
+99
+use the companion brewery backup command to retain `nostr.key` and federation state100
+keep the restored key private and restore a custom key path before starting brewery101
+a new forge key changes every repository's nostr address102
+103
+## development104
+105
+```sh106
+nix develop107
+nix flake check108
+```@@ -0,0 +1,27 @@
1
+{2
+ "nodes": {3
+ "nixpkgs": {4
+ "locked": {5
+ "lastModified": 1787360063,6
+ "narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",7
+ "owner": "NixOS",8
+ "repo": "nixpkgs",9
+ "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",10
+ "type": "github"11
+ },12
+ "original": {13
+ "owner": "NixOS",14
+ "repo": "nixpkgs",15
+ "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",16
+ "type": "github"17
+ }18
+ },19
+ "root": {20
+ "inputs": {21
+ "nixpkgs": "nixpkgs"22
+ }23
+ }24
+ },25
+ "root": "root",26
+ "version": 727
+}@@ -0,0 +1,38 @@
1
+{2
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/2c423e03bbafcff28bfadc6781a4a8257f205cb5";3
+ outputs = { self, nixpkgs }: let4
+ systems = [ "x86_64-linux" "aarch64-linux" ];5
+ forAllSystems = nixpkgs.lib.genAttrs systems;6
+ packageFor = system: let7
+ pkgs = nixpkgs.legacyPackages.${system};8
+ in pkgs.rustPlatform.buildRustPackage {9
+ pname = "vineyard";10
+ version = "0.1.0";11
+ src = pkgs.lib.cleanSourceWith {12
+ src = ./.;13
+ filter = path: type:14
+ pkgs.lib.cleanSourceFilter path type15
+ && !(type == "directory" && builtins.elem (builtins.baseNameOf path) [ "target" ".jj" ".git" ]);16
+ };17
+ cargoLock.lockFile = ./Cargo.lock;18
+ doCheck = true;19
+ };20
+ in {21
+ nixosModules.default = import ./nix/module.nix self;22
+ nixosModules.vineyard = self.nixosModules.default;23
+ packages = forAllSystems (system: {24
+ default = packageFor system;25
+ vineyard = packageFor system;26
+ });27
+ checks = forAllSystems (system: {28
+ vineyard = packageFor system;29
+ });30
+ devShells = forAllSystems (system: let31
+ pkgs = nixpkgs.legacyPackages.${system};32
+ in {33
+ default = pkgs.mkShell {34
+ packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config ];35
+ };36
+ });37
+ };38
+}@@ -0,0 +1,128 @@
1
+self: { config, lib, pkgs, ... }:2
+let3
+ cfg = config.services.vineyard;4
+ toml = pkgs.formats.toml { };5
+ peerToml = peer: {6
+ url = peer.url;7
+ query_limit = peer.queryLimit;8
+ } // lib.optionalAttrs (peer.rescanFrom != null) {9
+ rescan_from = peer.rescanFrom;10
+ };11
+ configFile = toml.generate "vineyard.toml" {12
+ listen = cfg.listen;13
+ public_url = cfg.publicUrl;14
+ database = "/var/lib/vineyard/events.sqlite";15
+ forge_pubkeys = cfg.forgePubkeys;16
+ peers = map peerToml cfg.peers;17
+ allow_private_discovery = cfg.allowPrivateDiscovery;18
+ limits = cfg.limits;19
+ };20
+ port = lib.strings.toIntBase10 (lib.last (lib.splitString ":" cfg.listen));21
+in22
+{23
+ options.services.vineyard = {24
+ enable = lib.mkEnableOption "vineyard";25
+ package = lib.mkOption {26
+ type = lib.types.package;27
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;28
+ description = "vineyard package to run";29
+ };30
+ listen = lib.mkOption {31
+ type = lib.types.str;32
+ default = "127.0.0.1:7777";33
+ description = "address and port for vineyard to listen on";34
+ };35
+ publicUrl = lib.mkOption {36
+ type = lib.types.str;37
+ default = "";38
+ description = "public websocket url for vineyard";39
+ };40
+ forgePubkeys = lib.mkOption {41
+ type = lib.types.listOf lib.types.str;42
+ default = [ ];43
+ description = "locally hosted forge public keys in hex";44
+ };45
+ peers = lib.mkOption {46
+ type = lib.types.listOf (lib.types.submodule {47
+ options = {48
+ url = lib.mkOption {49
+ type = lib.types.str;50
+ description = "peer relay url";51
+ };52
+ queryLimit = lib.mkOption {53
+ type = lib.types.ints.positive;54
+ default = 500;55
+ description = "peer query limit";56
+ };57
+ rescanFrom = lib.mkOption {58
+ type = lib.types.nullOr lib.types.ints.unsigned;59
+ default = null;60
+ description = "peer rescan timestamp";61
+ };62
+ };63
+ });64
+ default = [ ];65
+ description = "peer relays for event exchange";66
+ };67
+ allowPrivateDiscovery = lib.mkOption {68
+ type = lib.types.bool;69
+ default = false;70
+ description = "allow discovered relays on private networks";71
+ };72
+ limits = lib.mkOption {73
+ type = lib.types.attrsOf lib.types.int;74
+ default = { };75
+ description = "relay limit overrides";76
+ };77
+ openFirewall = lib.mkOption {78
+ type = lib.types.bool;79
+ default = false;80
+ description = "open firewall for listen port";81
+ };82
+ };83
+ config = lib.mkIf cfg.enable {84
+ assertions = [85
+ {86
+ assertion = cfg.publicUrl != "";87
+ message = "publicUrl must be set";88
+ }89
+ {90
+ assertion = cfg.forgePubkeys != [ ];91
+ message = "forgePubkeys must not be empty";92
+ }93
+ ];94
+ networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ port ];95
+ systemd.services.vineyard = {96
+ description = "vineyard nostr relay";97
+ wantedBy = [ "multi-user.target" ];98
+ after = [ "network-online.target" ];99
+ wants = [ "network-online.target" ];100
+ serviceConfig = {101
+ ExecStartPre = "${cfg.package}/bin/vineyard check-config --config ${configFile}";102
+ ExecStart = "${cfg.package}/bin/vineyard serve --config ${configFile}";103
+ DynamicUser = true;104
+ StateDirectory = "vineyard";105
+ StateDirectoryMode = "0700";106
+ WorkingDirectory = "/var/lib/vineyard";107
+ UMask = "0077";108
+ Restart = "on-failure";109
+ RestartSec = "5";110
+ NoNewPrivileges = true;111
+ ProtectSystem = "strict";112
+ ProtectHome = true;113
+ PrivateTmp = true;114
+ PrivateDevices = true;115
+ ProtectKernelTunables = true;116
+ ProtectKernelModules = true;117
+ ProtectControlGroups = true;118
+ RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];119
+ RestrictNamespaces = true;120
+ LockPersonality = true;121
+ RestrictRealtime = true;122
+ RestrictSUIDSGID = true;123
+ RemoveIPC = true;124
+ CapabilityBoundingSet = "";125
+ };126
+ };127
+ };128
+}@@ -0,0 +1,315 @@
1
+use std::collections::HashSet;2
+use std::fs::read_to_string;3
+use std::net::SocketAddr;4
+use std::path::{Path, PathBuf};5
+6
+use nostr::key::PublicKey;7
+use serde::Deserialize;8
+use tokio::sync::Semaphore;9
+10
+fn default_listen() -> SocketAddr {11
+ SocketAddr::from(([127, 0, 0, 1], 7777))12
+}13
+14
+fn default_database() -> PathBuf {15
+ PathBuf::from("vineyard.sqlite")16
+}17
+18
+const fn default_max_connections() -> usize {19
+ 3220
+}21
+22
+const fn default_max_connections_per_ip() -> usize {23
+ 824
+}25
+26
+const fn default_max_subscriptions() -> usize {27
+ 1628
+}29
+30
+const fn default_max_event_bytes() -> usize {31
+ 2_096_89632
+}33
+34
+const fn default_max_message_bytes() -> usize {35
+ 2_097_15236
+}37
+38
+const fn default_max_tags() -> usize {39
+ 12840
+}41
+42
+const fn default_max_filters() -> usize {43
+ 844
+}45
+46
+const fn default_max_filter_bytes() -> usize {47
+ 32_76848
+}49
+50
+const fn default_max_query_results() -> usize {51
+ 50052
+}53
+54
+const fn default_max_repositories() -> usize {55
+ 409656
+}57
+58
+const fn default_max_discovered_peers() -> usize {59
+ 3260
+}61
+62
+const fn default_max_query_bytes() -> usize {63
+ 16_777_21664
+}65
+66
+const fn default_events_per_minute() -> u32 {67
+ 12068
+}69
+70
+const fn default_queries_per_minute() -> u32 {71
+ 12072
+}73
+74
+const fn default_max_database_bytes() -> u64 {75
+ 1_073_741_82476
+}77
+78
+const fn default_min_free_bytes() -> u64 {79
+ 67_108_86480
+}81
+82
+const fn default_write_timeout_seconds() -> u64 {83
+ 1084
+}85
+86
+#[derive(Debug, Clone, Deserialize)]87
+#[serde(deny_unknown_fields)]88
+pub struct Config {89
+ #[serde(default = "default_listen")]90
+ pub listen: SocketAddr,91
+ pub public_url: String,92
+ #[serde(default = "default_database")]93
+ pub database: PathBuf,94
+ pub forge_pubkeys: Vec<String>,95
+ #[serde(default)]96
+ pub peers: Vec<Peer>,97
+ #[serde(default)]98
+ pub allow_private_discovery: bool,99
+ #[serde(default)]100
+ pub limits: Limits,101
+}102
+103
+#[derive(Debug, Clone, Deserialize)]104
+#[serde(deny_unknown_fields)]105
+pub struct Peer {106
+ pub url: String,107
+ #[serde(default = "default_max_query_results")]108
+ pub query_limit: usize,109
+ pub rescan_from: Option<u64>,110
+}111
+112
+#[derive(Debug, Clone, Deserialize)]113
+#[serde(deny_unknown_fields)]114
+pub struct Limits {115
+ #[serde(default = "default_max_repositories")]116
+ pub max_repositories: usize,117
+ #[serde(default = "default_max_discovered_peers")]118
+ pub max_discovered_peers: usize,119
+ #[serde(default = "default_max_connections")]120
+ pub max_connections: usize,121
+ #[serde(default = "default_max_connections_per_ip")]122
+ pub max_connections_per_ip: usize,123
+ #[serde(default = "default_max_subscriptions")]124
+ pub max_subscriptions: usize,125
+ #[serde(default = "default_max_event_bytes")]126
+ pub max_event_bytes: usize,127
+ #[serde(default = "default_max_message_bytes")]128
+ pub max_message_bytes: usize,129
+ #[serde(default = "default_max_tags")]130
+ pub max_tags: usize,131
+ #[serde(default = "default_max_filters")]132
+ pub max_filters: usize,133
+ #[serde(default = "default_max_filter_bytes")]134
+ pub max_filter_bytes: usize,135
+ #[serde(default = "default_max_query_results")]136
+ pub max_query_results: usize,137
+ #[serde(default = "default_max_query_bytes")]138
+ pub max_query_bytes: usize,139
+ #[serde(default = "default_events_per_minute")]140
+ pub events_per_minute: u32,141
+ #[serde(default = "default_queries_per_minute")]142
+ pub queries_per_minute: u32,143
+ #[serde(default = "default_max_database_bytes")]144
+ pub max_database_bytes: u64,145
+ #[serde(default = "default_min_free_bytes")]146
+ pub min_free_bytes: u64,147
+ #[serde(default = "default_write_timeout_seconds")]148
+ pub write_timeout_seconds: u64,149
+}150
+151
+impl Default for Limits {152
+ fn default() -> Self {153
+ Self {154
+ max_repositories: default_max_repositories(),155
+ max_discovered_peers: default_max_discovered_peers(),156
+ max_connections: default_max_connections(),157
+ max_connections_per_ip: default_max_connections_per_ip(),158
+ max_subscriptions: default_max_subscriptions(),159
+ max_event_bytes: default_max_event_bytes(),160
+ max_message_bytes: default_max_message_bytes(),161
+ max_tags: default_max_tags(),162
+ max_filters: default_max_filters(),163
+ max_filter_bytes: default_max_filter_bytes(),164
+ max_query_results: default_max_query_results(),165
+ max_query_bytes: default_max_query_bytes(),166
+ events_per_minute: default_events_per_minute(),167
+ queries_per_minute: default_queries_per_minute(),168
+ max_database_bytes: default_max_database_bytes(),169
+ min_free_bytes: default_min_free_bytes(),170
+ write_timeout_seconds: default_write_timeout_seconds(),171
+ }172
+ }173
+}174
+175
+impl Config {176
+ pub fn load(path: &Path) -> anyhow::Result<Self> {177
+ let text = read_to_string(path).map_err(|error| {178
+ anyhow::anyhow!("failed to read config {}: {error}", path.display())179
+ })?;180
+ let mut cfg: Self = toml::from_str(&text).map_err(|error| {181
+ anyhow::anyhow!("failed to parse config {}: {error}", path.display())182
+ })?;183
+ if cfg.database.is_relative() {184
+ if let Some(parent) = path.parent() {185
+ if !parent.as_os_str().is_empty() {186
+ cfg.database = parent.join(&cfg.database);187
+ }188
+ }189
+ }190
+ cfg.validate()?;191
+ Ok(cfg)192
+ }193
+194
+ pub fn validate(&self) -> anyhow::Result<()> {195
+ let url = relay_url(&self.public_url)196
+ .ok_or_else(|| anyhow::anyhow!("invalid public_url {}", self.public_url))?;197
+ self.forge_keys()?;198
+ if self.peers.len() > 16 {199
+ anyhow::bail!("at most 16 peer relays are supported");200
+ }201
+ let mut peers = HashSet::new();202
+ for peer in &self.peers {203
+ let peer_url = relay_url(&peer.url).ok_or_else(|| {204
+ anyhow::anyhow!(205
+ "peer urls must be ws or wss without credentials, queries or fragments"206
+ )207
+ })?;208
+ if peer_url == url || !peers.insert(peer_url) {209
+ anyhow::bail!("peer urls must be unique and distinct from public_url");210
+ }211
+ if !(2..10000).contains(&peer.query_limit) {212
+ anyhow::bail!("peer query_limit must be between 2 and 9999");213
+ }214
+ }215
+ if self.limits.max_connections == 0216
+ || self.limits.max_connections_per_ip == 0217
+ || self.limits.max_subscriptions == 0218
+ || self.limits.max_event_bytes == 0219
+ || self.limits.max_message_bytes == 0220
+ || self.limits.max_tags == 0221
+ || self.limits.max_filters == 0222
+ || self.limits.max_filter_bytes == 0223
+ || self.limits.max_query_results == 0224
+ || self.limits.max_query_bytes == 0225
+ || self.limits.events_per_minute == 0226
+ || self.limits.queries_per_minute == 0227
+ || self.limits.max_database_bytes == 0228
+ || self.limits.min_free_bytes == 0229
+ || self.limits.write_timeout_seconds == 0230
+ {231
+ anyhow::bail!("limits must all be positive");232
+ }233
+ if self.limits.max_connections_per_ip > self.limits.max_connections {234
+ anyhow::bail!("max_connections_per_ip must not exceed max_connections");235
+ }236
+ let min_message = self237
+ .limits238
+ .max_event_bytes239
+ .checked_add(256)240
+ .ok_or_else(|| anyhow::anyhow!("max_event_bytes too large"))?;241
+ if min_message > self.limits.max_message_bytes {242
+ anyhow::bail!("max_message_bytes must fit max_event_bytes plus 256 bytes of overhead");243
+ }244
+ if self.limits.max_event_bytes > self.limits.max_query_bytes {245
+ anyhow::bail!("max_query_bytes must fit one maximum size event");246
+ }247
+ if self.limits.max_repositories == 0 || self.limits.max_repositories >= 10000 {248
+ anyhow::bail!("max_repositories must be between 1 and 9999");249
+ }250
+ if self.limits.max_discovered_peers == 0 || self.limits.max_discovered_peers > 256 {251
+ anyhow::bail!("max_discovered_peers must be between 1 and 256");252
+ }253
+ if self.limits.max_query_results >= 10000 {254
+ anyhow::bail!("max_query_results must be below 10000");255
+ }256
+ if self.limits.max_connections > Semaphore::MAX_PERMITS {257
+ anyhow::bail!("max_connections exceeds the semaphore limit");258
+ }259
+ self.limits260
+ .max_filter_bytes261
+ .checked_mul(self.limits.max_subscriptions)262
+ .ok_or_else(|| anyhow::anyhow!("subscription byte budget is too large"))?;263
+ if self.websocket_path()? == "/healthz" {264
+ anyhow::bail!("public_url must not use the health endpoint");265
+ }266
+ Ok(())267
+ }268
+269
+ pub fn forge_keys(&self) -> anyhow::Result<HashSet<PublicKey>> {270
+ if self.forge_pubkeys.is_empty() {271
+ anyhow::bail!("forge_pubkeys must not be empty");272
+ }273
+ let mut set = HashSet::new();274
+ for raw in &self.forge_pubkeys {275
+ let trimmed = raw.trim();276
+ if trimmed.len() != 64 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {277
+ anyhow::bail!("invalid forge public key: {raw}");278
+ }279
+ let key: PublicKey = trimmed280
+ .parse()281
+ .map_err(|error| anyhow::anyhow!("invalid forge public key {raw}: {error}"))?;282
+ if !set.insert(key) {283
+ anyhow::bail!("duplicate forge public key: {raw}");284
+ }285
+ }286
+ Ok(set)287
+ }288
+289
+ pub fn websocket_path(&self) -> anyhow::Result<String> {290
+ let url = url::Url::parse(&self.public_url)291
+ .map_err(|error| anyhow::anyhow!("invalid public_url {}: {error}", self.public_url))?;292
+ let path = url.path();293
+ if path.is_empty() {294
+ Ok("/".to_owned())295
+ } else if path.starts_with('/') {296
+ Ok(path.to_owned())297
+ } else {298
+ Ok(format!("/{path}"))299
+ }300
+ }301
+}302
+303
+pub(crate) fn relay_url(raw: &str) -> Option<url::Url> {304
+ let url = url::Url::parse(raw).ok()?;305
+ if !matches!(url.scheme(), "ws" | "wss")306
+ || url.host_str().is_none()307
+ || !url.username().is_empty()308
+ || url.password().is_some()309
+ || url.query().is_some()310
+ || url.fragment().is_some()311
+ {312
+ return None;313
+ }314
+ Some(url)315
+}@@ -0,0 +1,650 @@
1
+use std::collections::{BTreeMap, HashMap, HashSet};2
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};3
+use std::sync::Arc;4
+use std::time::{Duration, SystemTime, UNIX_EPOCH};5
+6
+use anyhow::{Context as _, Result, bail};7
+use futures_util::{SinkExt as _, StreamExt as _};8
+use nostr::event::{Event, Kind};9
+use nostr::filter::{Filter, MatchEventOptions};10
+use nostr::key::PublicKey;11
+use nostr::message::MachineReadablePrefix;12
+use nostr_database::{NostrDatabase as _, RejectedReason, SaveEventStatus};13
+use serde_json::{Value, json};14
+use tokio::net::{TcpStream, lookup_host};15
+use tokio::time::{Instant, sleep, timeout, timeout_at};16
+use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};17
+use tokio_tungstenite::{18
+ MaybeTlsStream, WebSocketStream, client_async_tls_with_config, connect_async_with_config,19
+};20
+use tokio_util::sync::CancellationToken;21
+use tokio_util::task::TaskTracker;22
+23
+use crate::config::{Config, Peer, relay_url};24
+use crate::policy::{Policy, WritePolicyResult, coordinate};25
+use crate::relay::Relay;26
+use crate::store::Store;27
+28
+const KINDS: [u16; 10] = [5, 1617, 1621, 1630, 1631, 1632, 10002, 30382, 30617, 30618];29
+const OVERLAP: u64 = 7 * 24 * 60 * 60;30
+const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);31
+type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;32
+33
+#[derive(Clone)]34
+struct Link {35
+ peer: Peer,36
+ config: Arc<Config>,37
+ store: Arc<Store>,38
+ relay: Relay,39
+ policy: Policy,40
+ discovered: bool,41
+ stop: CancellationToken,42
+}43
+44
+struct Connection {45
+ socket: Socket,46
+ stop: CancellationToken,47
+ write_timeout: Duration,48
+ request_sequence: u64,49
+}50
+51
+pub fn spawn(52
+ config: Arc<Config>,53
+ store: Arc<Store>,54
+ relay: Relay,55
+ policy: Policy,56
+ task_tracker: &TaskTracker,57
+ stop: CancellationToken,58
+) {59
+ let tasks = task_tracker.clone();60
+ task_tracker61
+ .clone()62
+ .spawn(manage(config, store, relay, policy, tasks, stop));63
+}64
+65
+async fn manage(66
+ config: Arc<Config>,67
+ store: Arc<Store>,68
+ relay: Relay,69
+ policy: Policy,70
+ tasks: TaskTracker,71
+ stop: CancellationToken,72
+) {73
+ let mut active = HashMap::<String, CancellationToken>::new();74
+ loop {75
+ let mut desired = BTreeMap::new();76
+ for configured_peer in &config.peers {77
+ let mut normalized_peer = configured_peer.clone();78
+ let Some(url) = relay_url(&normalized_peer.url) else {79
+ continue;80
+ };81
+ normalized_peer.url = url.to_string();82
+ desired.insert(normalized_peer.url.clone(), (normalized_peer, false));83
+ }84
+ match store85
+ .journal86
+ .discovered(config.limits.max_discovered_peers)87
+ .await88
+ {89
+ Ok(urls) => {90
+ for raw in urls {91
+ if let Some(peer) = discovered_peer(&config, &raw) {92
+ desired.entry(peer.url.clone()).or_insert((peer, true));93
+ }94
+ }95
+ }96
+ Err(error) => tracing::error!(%error, "loading discovered peers failed"),97
+ }98
+99
+ active.retain(|url, peer_stop| {100
+ if desired.contains_key(url) {101
+ true102
+ } else {103
+ peer_stop.cancel();104
+ false105
+ }106
+ });107
+ for (url, (peer, discovered)) in desired {108
+ if active.contains_key(&url) {109
+ continue;110
+ }111
+ let peer_stop = stop.child_token();112
+ let link = Link {113
+ peer,114
+ config: Arc::clone(&config),115
+ store: Arc::clone(&store),116
+ relay: relay.clone(),117
+ policy: policy.clone(),118
+ discovered,119
+ stop: peer_stop.clone(),120
+ };121
+ tasks.spawn(run(link.clone(), true));122
+ tasks.spawn(run(link, false));123
+ active.insert(url, peer_stop);124
+ }125
+126
+ tokio::select! {127
+ () = stop.cancelled() => break,128
+ () = sleep(Duration::from_secs(10)) => {}129
+ }130
+ }131
+ for peer_stop in active.into_values() {132
+ peer_stop.cancel();133
+ }134
+}135
+136
+fn discovered_peer(config: &Config, raw: &str) -> Option<Peer> {137
+ let url = relay_url(raw)?;138
+ if (!config.allow_private_discovery && url.scheme() != "wss")139
+ || url == relay_url(&config.public_url)?140
+ {141
+ return None;142
+ }143
+ Some(Peer {144
+ url: url.to_string(),145
+ query_limit: config.limits.max_query_results,146
+ rescan_from: None,147
+ })148
+}149
+150
+async fn run(link: Link, outbound: bool) {151
+ let direction = if outbound { "outbound" } else { "inbound" };152
+ let mut backoff = 5;153
+ while !link.stop.is_cancelled() {154
+ let started = Instant::now();155
+ let result = if outbound {156
+ publish(&link).await157
+ } else {158
+ subscribe(&link).await159
+ };160
+ if link.stop.is_cancelled() {161
+ break;162
+ }163
+ if let Err(error) = result {164
+ tracing::warn!(peer = %link.peer.url, %direction, %error, "peer connection interrupted");165
+ }166
+ if started.elapsed() > Duration::from_secs(60) {167
+ backoff = 5;168
+ }169
+ tokio::select! {170
+ () = link.stop.cancelled() => break,171
+ () = sleep(Duration::from_secs(backoff)) => {},172
+ }173
+ backoff = (backoff * 2).min(60);174
+ }175
+}176
+177
+impl Connection {178
+ async fn open(link: &Link) -> Result<Self> {179
+ let config = WebSocketConfig::default()180
+ .max_message_size(Some(link.config.limits.max_message_bytes))181
+ .max_frame_size(Some(link.config.limits.max_message_bytes));182
+ let connected = async {183
+ if link.discovered {184
+ connect_discovered(link, config).await185
+ } else {186
+ connect_async_with_config(&link.peer.url, Some(config), false)187
+ .await188
+ .map(|(socket, _)| socket)189
+ .map_err(Into::into)190
+ }191
+ };192
+ let socket = tokio::select! {193
+ () = link.stop.cancelled() => bail!("stopping"),194
+ result = timeout(RESPONSE_TIMEOUT, connected) => {195
+ result.context("peer connection timed out")??196
+ }197
+ };198
+ Ok(Self {199
+ socket,200
+ stop: link.stop.clone(),201
+ write_timeout: Duration::from_secs(link.config.limits.write_timeout_seconds),202
+ request_sequence: 0,203
+ })204
+ }205
+206
+ async fn send(&mut self, value: Value) -> Result<()> {207
+ self.send_message(Message::Text(value.to_string().into()))208
+ .await209
+ }210
+211
+ async fn send_message(&mut self, message: Message) -> Result<()> {212
+ tokio::select! {213
+ () = self.stop.cancelled() => bail!("stopping"),214
+ result = timeout(self.write_timeout, self.socket.send(message)) => {215
+ result.context("peer write timed out")??;216
+ Ok(())217
+ }218
+ }219
+ }220
+221
+ async fn next(&mut self, deadline: Instant) -> Result<Option<Value>> {222
+ loop {223
+ let message = tokio::select! {224
+ () = self.stop.cancelled() => bail!("stopping"),225
+ result = timeout_at(deadline, self.socket.next()) => match result {226
+ Err(_) => return Ok(None),227
+ Ok(Some(message)) => message?,228
+ Ok(None) => bail!("peer disconnected"),229
+ }230
+ };231
+ match message {232
+ Message::Text(text) => return Ok(Some(serde_json::from_str(&text)?)),233
+ Message::Ping(payload) => self.send_message(Message::Pong(payload)).await?,234
+ Message::Close(_) => bail!("peer closed the connection"),235
+ Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {}236
+ }237
+ }238
+ }239
+}240
+241
+async fn connect_discovered(link: &Link, config: WebSocketConfig) -> Result<Socket> {242
+ let url = url::Url::parse(&link.peer.url)?;243
+ let host = url.host_str().context("discovered peer has no host")?;244
+ let port = url245
+ .port_or_known_default()246
+ .context("discovered peer has no port")?;247
+ let mut last_error = None;248
+ for address in lookup_host((host, port)).await? {249
+ if !link.config.allow_private_discovery && !public_ip(address.ip()) {250
+ continue;251
+ }252
+ match TcpStream::connect(address).await {253
+ Ok(stream) => {254
+ return client_async_tls_with_config(&link.peer.url, stream, Some(config), None)255
+ .await256
+ .map(|(socket, _)| socket)257
+ .map_err(Into::into);258
+ }259
+ Err(error) => last_error = Some(error),260
+ }261
+ }262
+ match last_error {263
+ Some(error) => Err(error.into()),264
+ None => bail!("discovered peer did not resolve to an allowed address"),265
+ }266
+}267
+268
+fn public_ip(address: IpAddr) -> bool {269
+ match address {270
+ IpAddr::V4(ipv4) => public_ipv4(ipv4),271
+ IpAddr::V6(ipv6) => public_ipv6(ipv6),272
+ }273
+}274
+275
+fn public_ipv4(ip: Ipv4Addr) -> bool {276
+ let [first, second, _, _] = ip.octets();277
+ !(ip.is_private()278
+ || ip.is_loopback()279
+ || ip.is_link_local()280
+ || ip.is_broadcast()281
+ || ip.is_documentation()282
+ || ip.is_unspecified()283
+ || ip.is_multicast()284
+ || first == 0285
+ || (first == 100 && (64..=127).contains(&second))286
+ || (first == 198 && matches!(second, 18 | 19)))287
+}288
+289
+fn public_ipv6(ip: Ipv6Addr) -> bool {290
+ if let Some(ipv4) = ip.to_ipv4_mapped() {291
+ return public_ipv4(ipv4);292
+ }293
+ let segments = ip.segments();294
+ !(ip.is_loopback()295
+ || ip.is_unspecified()296
+ || ip.is_unique_local()297
+ || ip.is_unicast_link_local()298
+ || ip.is_multicast()299
+ || (segments[0] == 0x2001 && segments[1] == 0x0db8))300
+}301
+302
+async fn publish(link: &Link) -> Result<()> {303
+ let mut connection = Connection::open(link).await?;304
+ let mut announced = HashSet::new();305
+ loop {306
+ let pending = link.store.journal.pending(&link.peer.url, 32).await?;307
+ if pending.is_empty() {308
+ connection309
+ .next(Instant::now() + Duration::from_secs(1))310
+ .await?;311
+ continue;312
+ }313
+ for (sequence, id) in pending {314
+ if announced.len() >= link.config.limits.max_repositories {315
+ announced.clear();316
+ }317
+ if let Some(event) = link.store.event_by_id(&id).await? {318
+ let repository_event = repository_coordinate(&event).is_some();319
+ if let Some(announcement) = announcement_for(link, &event).await? {320
+ if announced.insert(announcement.id) {321
+ if let Some(error) = publish_event(&mut connection, &announcement).await? {322
+ announced.remove(&announcement.id);323
+ defer(link, sequence, &error).await?;324
+ continue;325
+ }326
+ }327
+ } else if repository_event {328
+ link.store329
+ .journal330
+ .acknowledge(&link.peer.url, sequence)331
+ .await?;332
+ continue;333
+ }334
+ if let Some(error) = publish_event(&mut connection, &event).await? {335
+ if link.store.event_by_id(&id).await?.is_some() {336
+ defer(link, sequence, &error).await?;337
+ continue;338
+ }339
+ } else if event.kind == Kind::from(30617) {340
+ announced.insert(event.id);341
+ }342
+ }343
+ link.store344
+ .journal345
+ .acknowledge(&link.peer.url, sequence)346
+ .await?;347
+ }348
+ }349
+}350
+351
+async fn defer(link: &Link, sequence: u64, error: &str) -> Result<()> {352
+ let retry_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 300;353
+ link.store354
+ .journal355
+ .defer(&link.peer.url, sequence, error, retry_at)356
+ .await?;357
+ tracing::warn!(peer = %link.peer.url, sequence, %error, "peer delivery deferred");358
+ Ok(())359
+}360
+361
+async fn publish_event(connection: &mut Connection, event: &Event) -> Result<Option<String>> {362
+ tokio::select! {363
+ () = connection.stop.cancelled() => bail!("stopping"),364
+ () = sleep(Duration::from_secs(1)) => {},365
+ }366
+ connection.send(json!(["EVENT", event])).await?;367
+ let id = event.id.to_hex();368
+ let deadline = Instant::now() + RESPONSE_TIMEOUT;369
+ loop {370
+ let frame = connection371
+ .next(deadline)372
+ .await?373
+ .context("peer acknowledgement timed out")?;374
+ if frame[0] == "OK" && frame[1].as_str() == Some(&id) {375
+ let reason = frame[3].as_str().unwrap_or("peer rejected event");376
+ return Ok(if frame[2] == true || reason.starts_with("duplicate:") {377
+ None378
+ } else {379
+ Some(reason.chars().take(512).collect())380
+ });381
+ }382
+ }383
+}384
+385
+async fn announcement_for(link: &Link, event: &Event) -> Result<Option<Event>> {386
+ let Some((author, identifier)) = repository_coordinate(event) else {387
+ return Ok(None);388
+ };389
+ Ok(link390
+ .store391
+ .query(392
+ Filter::new()393
+ .kind(Kind::from(30617))394
+ .author(author)395
+ .identifier(identifier)396
+ .limit(1),397
+ )398
+ .await?399
+ .into_iter()400
+ .next())401
+}402
+403
+fn repository_coordinate(event: &Event) -> Option<(PublicKey, String)> {404
+ if event.kind == Kind::from(30618) {405
+ event406
+ .tags407
+ .identifier()408
+ .map(|identifier| (event.pubkey, identifier))409
+ } else if matches!(event.kind.as_u16(), 1617 | 1621 | 1630..=1632) {410
+ event.tags.iter().find_map(|tag| {411
+ let values = tag.as_slice();412
+ if values.first().map(String::as_str) != Some("a") {413
+ return None;414
+ }415
+ let (kind, author, identifier) = coordinate(values.get(1)?)?;416
+ if kind != 30617 {417
+ return None;418
+ }419
+ Some((author, identifier.to_owned()))420
+ })421
+ } else {422
+ None423
+ }424
+}425
+426
+async fn import(link: &Link, value: Value) -> Result<()> {427
+ let event: Event = serde_json::from_value(value)?;428
+ if event.verify().is_err() {429
+ tracing::debug!(peer = %link.peer.url, event = %event.id, "ignored invalid peer event");430
+ return Ok(());431
+ }432
+ match link.policy.admit_peer(&event).await {433
+ WritePolicyResult::Accept => {}434
+ WritePolicyResult::Respond { status: true, .. } => return Ok(()),435
+ WritePolicyResult::Respond {436
+ prefix: MachineReadablePrefix::Error | MachineReadablePrefix::RateLimited,437
+ message,438
+ ..439
+ } => bail!("peer import paused by {message}"),440
+ WritePolicyResult::Respond { message, .. } => {441
+ tracing::debug!(peer = %link.peer.url, event = %event.id, %message, "ignored out of scope peer event");442
+ return Ok(());443
+ }444
+ }445
+ match link.relay.add_event(event).await? {446
+ SaveEventStatus::Success447
+ | SaveEventStatus::Rejected(448
+ RejectedReason::Duplicate449
+ | RejectedReason::Deleted450
+ | RejectedReason::Expired451
+ | RejectedReason::Replaced452
+ | RejectedReason::InvalidDelete453
+ | RejectedReason::Vanished,454
+ ) => Ok(()),455
+ status @ SaveEventStatus::Rejected(_) => {456
+ bail!("peer event was not stored with status {status:?}")457
+ }458
+ }459
+}460
+461
+async fn subscribe(link: &Link) -> Result<()> {462
+ let mut connection = Connection::open(link).await?;463
+ connection464
+ .send(json!(["REQ", "live", {"kinds": KINDS, "limit": 0_u8}]))465
+ .await?;466
+ let deadline = Instant::now() + RESPONSE_TIMEOUT;467
+ loop {468
+ let frame = connection469
+ .next(deadline)470
+ .await?471
+ .context("live subscription timed out")?;472
+ if frame[0] == "EOSE" && frame[1] == "live" {473
+ break;474
+ }475
+ handle_live(link, frame).await?;476
+ }477
+ let mut completed = HashSet::new();478
+ let mut next_replay = Instant::now();479
+ loop {480
+ let replay_all = Instant::now() >= next_replay;481
+ let until = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();482
+ for (scope, filter) in [483
+ ("announcements".to_owned(), json!({"kinds": [30_617_u16]})),484
+ ("bindings".to_owned(), json!({"kinds": [30_382_u16]})),485
+ ("relays".to_owned(), json!({"kinds": [10_002_u16]})),486
+ ] {487
+ if replay_all || !completed.contains(&scope) {488
+ replay(link, &mut connection, &scope, filter, until).await?;489
+ completed.insert(scope);490
+ }491
+ }492
+ let mut active_scopes = HashSet::from([493
+ "announcements".to_owned(),494
+ "bindings".to_owned(),495
+ "relays".to_owned(),496
+ ]);497
+ for (author, identifier) in link498
+ .store499
+ .journal500
+ .repositories(link.config.limits.max_repositories)501
+ .await?502
+ {503
+ let address = format!("30617:{author}:{identifier}");504
+ for (scope, filter) in [505
+ (506
+ format!("state:{address}"),507
+ json!({"kinds": [30_618_u16], "authors": [author], "#d": [identifier]}),508
+ ),509
+ (510
+ format!("contributions:{address}"),511
+ json!({"kinds": [1_617_u16, 1_621_u16, 1_630_u16, 1_631_u16, 1_632_u16], "#a": [address]}),512
+ ),513
+ ] {514
+ active_scopes.insert(scope.clone());515
+ if replay_all || !completed.contains(&scope) {516
+ replay(link, &mut connection, &scope, filter, until).await?;517
+ completed.insert(scope);518
+ }519
+ }520
+ }521
+ completed.retain(|scope| active_scopes.contains(scope));522
+ if replay_all {523
+ replay(524
+ link,525
+ &mut connection,526
+ "deletions",527
+ json!({"kinds": [5_u16]}),528
+ until,529
+ )530
+ .await?;531
+ next_replay = Instant::now() + Duration::from_secs(300);532
+ }533
+ let refresh = Instant::now() + Duration::from_secs(30);534
+ while let Some(frame) = connection.next(refresh).await? {535
+ handle_live(link, frame).await?;536
+ }537
+ }538
+}539
+540
+async fn handle_live(link: &Link, frame: Value) -> Result<()> {541
+ if frame[1] != "live" {542
+ return Ok(());543
+ }544
+ match frame[0].as_str() {545
+ Some("EVENT") => import(link, frame[2].clone()).await,546
+ Some("CLOSED") => bail!("peer closed live subscription with {}", frame[2]),547
+ _ => Ok(()),548
+ }549
+}550
+551
+async fn replay(552
+ link: &Link,553
+ connection: &mut Connection,554
+ scope: &str,555
+ filter: Value,556
+ until: u64,557
+) -> Result<()> {558
+ let checkpoint = link559
+ .store560
+ .journal561
+ .checkpoint(&link.peer.url, scope)562
+ .await?563
+ .unwrap_or(0);564
+ let overlap_start = checkpoint.saturating_sub(OVERLAP);565
+ let replay_start = link566
+ .peer567
+ .rescan_from568
+ .map_or(overlap_start, |from| from.min(overlap_start));569
+ let mut ranges = vec![(replay_start.min(until), until)];570
+ while let Some((since, range_until)) = ranges.pop() {571
+ if replay_range(link, connection, filter.clone(), since, range_until).await? {572
+ link.store573
+ .journal574
+ .advance(&link.peer.url, scope, range_until)575
+ .await?;576
+ } else {577
+ if since == range_until {578
+ bail!(579
+ "recovery required for {scope} at {since}, one second exceeds the peer history limit"580
+ );581
+ }582
+ let middle = since + (range_until - since) / 2;583
+ ranges.push((middle + 1, range_until));584
+ ranges.push((since, middle));585
+ }586
+ }587
+ Ok(())588
+}589
+590
+async fn replay_range(591
+ link: &Link,592
+ connection: &mut Connection,593
+ mut filter: Value,594
+ since: u64,595
+ until: u64,596
+) -> Result<bool> {597
+ let pause = Instant::now() + Duration::from_secs(1);598
+ while let Some(frame) = connection.next(pause).await? {599
+ handle_live(link, frame).await?;600
+ }601
+ filter["since"] = json!(since);602
+ filter["until"] = json!(until);603
+ filter["limit"] = json!(link.peer.query_limit);604
+ let matching: Filter = serde_json::from_value(filter.clone())?;605
+ connection.request_sequence += 1;606
+ let subscription = format!("history-{}", connection.request_sequence);607
+ connection608
+ .send(json!(["REQ", subscription, filter]))609
+ .await?;610
+ let deadline = Instant::now() + RESPONSE_TIMEOUT;611
+ let mut received = HashSet::new();612
+ let complete = loop {613
+ let frame = connection614
+ .next(deadline)615
+ .await?616
+ .context("peer history timed out")?;617
+ if frame[1] != subscription {618
+ handle_live(link, frame).await?;619
+ continue;620
+ }621
+ match frame[0].as_str() {622
+ Some("EVENT") => {623
+ let event: Event = serde_json::from_value(frame[2].clone())?;624
+ if !matching.match_event(&event, MatchEventOptions::new()) {625
+ bail!("peer returned an event outside the requested history");626
+ }627
+ received.insert(event.id);628
+ import(link, frame[2].clone()).await?;629
+ if received.len() >= link.peer.query_limit {630
+ break false;631
+ }632
+ }633
+ Some("EOSE") => break true,634
+ Some("CLOSED") => {635
+ let message = frame[2].as_str().unwrap_or_default();636
+ if (message.starts_with("rate-limited:") || message.starts_with("error:"))637
+ && (message.contains("byte limit")638
+ || message.contains("result limit")639
+ || message.contains("too many results"))640
+ {641
+ break false;642
+ }643
+ bail!("peer closed history with {message}");644
+ }645
+ _ => {}646
+ }647
+ };648
+ connection.send(json!(["CLOSE", subscription])).await?;649
+ Ok(complete)650
+}@@ -0,0 +1,1028 @@
1
+use std::cmp::Ordering;2
+use std::collections::{BTreeSet, HashSet};3
+use std::error::Error;4
+use std::path::{Path, PathBuf};5
+use std::sync::Arc;6
+use std::time::{Duration, SystemTime, UNIX_EPOCH};7
+8
+use anyhow::{Context as _, Result};9
+use nostr::event::{Event, EventId, Kind, Signature, Tags};10
+use nostr::filter::Filter;11
+use nostr::key::PublicKey;12
+use nostr::types::Timestamp;13
+use nostr_database::{DatabaseEventStatus, RejectedReason, SaveEventStatus};14
+use rusqlite::{15
+ Connection, OptionalExtension as _, Params, Row, Transaction, params, params_from_iter,16
+};17
+use tokio::sync::{Mutex, MutexGuard};18
+use tokio::task::spawn_blocking;19
+20
+use crate::config::relay_url;21
+use crate::query::EventQuery;22
+23
+const SCHEMA: &str = "24
+CREATE TABLE IF NOT EXISTS events(25
+ id BLOB PRIMARY KEY NOT NULL CHECK(length(id) = 32),26
+ pubkey BLOB NOT NULL CHECK(length(pubkey) = 32),27
+ created_at INTEGER NOT NULL CHECK(created_at > 0),28
+ kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 65535),29
+ content TEXT NOT NULL,30
+ tags JSONB NOT NULL,31
+ sig BLOB NOT NULL CHECK(length(sig) = 64)32
+) WITHOUT ROWID;33
+34
+CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);35
+CREATE INDEX IF NOT EXISTS idx_events_pubkey_created_at ON events(pubkey, created_at DESC);36
+CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind_created_at37
+ON events(pubkey, kind, created_at DESC);38
+CREATE INDEX IF NOT EXISTS idx_events_kind_created_at ON events(kind, created_at DESC);39
+40
+DROP INDEX IF EXISTS idx_events_pubkey;41
+DROP INDEX IF EXISTS idx_events_kind;42
+43
+CREATE TABLE IF NOT EXISTS event_tags(44
+ event_id BLOB NOT NULL CHECK(length(event_id) = 32),45
+ tag_name TEXT NOT NULL CHECK(length(tag_name) = 1),46
+ tag_value TEXT NOT NULL,47
+ FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE,48
+ PRIMARY KEY(tag_name, tag_value, event_id)49
+);50
+51
+CREATE INDEX IF NOT EXISTS idx_tags_event_id ON event_tags(event_id);52
+53
+CREATE TABLE IF NOT EXISTS deleted_ids(54
+ event_id BLOB PRIMARY KEY NOT NULL CHECK(length(event_id) = 32)55
+) WITHOUT ROWID;56
+57
+CREATE TABLE IF NOT EXISTS deleted_coordinates(58
+ pubkey BLOB NOT NULL CHECK(length(pubkey) = 32),59
+ kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 65535),60
+ identifier TEXT NOT NULL,61
+ deleted_at INTEGER NOT NULL CHECK(deleted_at > 0),62
+ PRIMARY KEY(pubkey, kind, identifier)63
+) WITHOUT ROWID;64
+65
+DROP TRIGGER IF EXISTS sanctum_deleted_after_insert;66
+DROP TABLE IF EXISTS sanctum_deleted_events;67
+68
+CREATE TABLE IF NOT EXISTS vineyard_delivery(69
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,70
+ event_id BLOB NOT NULL UNIQUE REFERENCES events(id) ON DELETE CASCADE71
+);72
+73
+CREATE TABLE IF NOT EXISTS vineyard_peers(74
+ peer TEXT PRIMARY KEY,75
+ delivered INTEGER NOT NULL DEFAULT 076
+);77
+78
+CREATE TABLE IF NOT EXISTS vineyard_checkpoints(79
+ peer TEXT,80
+ scope TEXT,81
+ until INTEGER NOT NULL,82
+ PRIMARY KEY(peer, scope)83
+);84
+85
+CREATE TABLE IF NOT EXISTS vineyard_retries(86
+ peer TEXT,87
+ sequence INTEGER REFERENCES vineyard_delivery(sequence) ON DELETE CASCADE,88
+ retry_at INTEGER,89
+ error TEXT,90
+ PRIMARY KEY(peer, sequence)91
+);92
+93
+CREATE TABLE IF NOT EXISTS vineyard_discovered_peers(94
+ signer BLOB,95
+ url TEXT,96
+ discovered_at INTEGER,97
+ PRIMARY KEY(signer, url)98
+) WITHOUT ROWID;99
+100
+CREATE INDEX IF NOT EXISTS vineyard_retries_due101
+ON vineyard_retries(peer, retry_at, sequence);102
+103
+CREATE INDEX IF NOT EXISTS vineyard_discovered_urls104
+ON vineyard_discovered_peers(url);105
+106
+CREATE TRIGGER IF NOT EXISTS vineyard_delivery_after_insert107
+AFTER INSERT ON events108
+BEGIN109
+ INSERT OR IGNORE INTO vineyard_delivery(event_id) VALUES (NEW.id);110
+END;111
+112
+";113
+114
+const BACKFILL_DELIVERY: &str = "115
+INSERT INTO vineyard_delivery(event_id)116
+SELECT event.id117
+FROM events AS event118
+LEFT JOIN vineyard_delivery AS delivery ON delivery.event_id = event.id119
+WHERE delivery.event_id IS NULL120
+ORDER BY121
+ CASE WHEN event.kind = 30617 THEN 0 ELSE 1 END,122
+ event.created_at,123
+ event.id124
+";125
+126
+const DISCOVERED_RELAY_TAGS: &str = "127
+SELECT event.pubkey, json_extract(tag.value, '$[1]'), event.created_at128
+FROM events AS event, json_each(event.tags) AS tag129
+WHERE event.kind = 10002130
+ AND json_extract(tag.value, '$[0]') = 'r'131
+ AND typeof(json_extract(tag.value, '$[1]')) = 'text'132
+ORDER BY event.created_at DESC, event.id133
+";134
+135
+const DUE_DELIVERIES: &str = "136
+SELECT delivery.sequence, delivery.event_id137
+FROM vineyard_delivery AS delivery138
+JOIN vineyard_retries AS retry ON retry.sequence = delivery.sequence139
+WHERE retry.peer = ?1 AND retry.retry_at <= ?2140
+ORDER BY delivery.sequence141
+LIMIT ?3142
+";143
+144
+const NEW_DELIVERIES: &str = "145
+SELECT sequence, event_id146
+FROM vineyard_delivery147
+WHERE sequence > ?1148
+ORDER BY sequence149
+LIMIT ?2150
+";151
+152
+const REPOSITORIES: &str = "153
+SELECT lower(hex(event.pubkey)), tag.tag_value, event.tags154
+FROM events AS event155
+JOIN event_tags AS tag ON event.id = tag.event_id156
+WHERE event.kind = 30617 AND tag.tag_name = 'd'157
+ORDER BY 1, 2, event.created_at DESC, event.id158
+";159
+160
+type Delivery = (u64, EventId);161
+162
+#[derive(Debug, Clone)]163
+pub struct Journal {164
+ connection: Arc<Mutex<Connection>>,165
+ writes: Arc<Mutex<()>>,166
+}167
+168
+struct StoredEvent {169
+ id: Vec<u8>,170
+ pubkey: Vec<u8>,171
+ created_at: i64,172
+ kind: i64,173
+ content: String,174
+ tags: String,175
+ signature: Vec<u8>,176
+}177
+178
+impl Journal {179
+ pub async fn open(180
+ path: PathBuf,181
+ max_database_bytes: u64,182
+ max_discovered_peers: usize,183
+ excluded_peers: Vec<String>,184
+ writes: Arc<Mutex<()>>,185
+ ) -> Result<Self> {186
+ let connection = spawn_blocking(move || {187
+ open_connection(188
+ &path,189
+ max_database_bytes,190
+ max_discovered_peers,191
+ &excluded_peers,192
+ )193
+ })194
+ .await195
+ .context("journal task panicked")??;196
+197
+ Ok(Self {198
+ connection: Arc::new(Mutex::new(connection)),199
+ writes,200
+ })201
+ }202
+203
+ pub async fn pending(&self, peer: &str, limit: usize) -> Result<Vec<Delivery>> {204
+ let peer_url = peer.to_owned();205
+ self.run(move |connection| pending_deliveries(connection, &peer_url, limit))206
+ .await207
+ }208
+209
+ pub async fn acknowledge(&self, peer: &str, sequence: u64) -> Result<()> {210
+ let peer_url = peer.to_owned();211
+ self.write(move |connection| acknowledge_delivery(connection, &peer_url, sequence))212
+ .await213
+ }214
+215
+ pub async fn defer(&self, peer: &str, sequence: u64, error: &str, retry_at: u64) -> Result<()> {216
+ let peer_url = peer.to_owned();217
+ let message = error.chars().take(512).collect::<String>();218
+ self.write(move |connection| {219
+ defer_delivery(connection, &peer_url, sequence, &message, retry_at)220
+ })221
+ .await222
+ }223
+224
+ pub async fn checkpoint(&self, peer: &str, scope: &str) -> Result<Option<u64>> {225
+ let peer_url = peer.to_owned();226
+ let scope_name = scope.to_owned();227
+ self.run(move |connection| read_checkpoint(connection, &peer_url, &scope_name))228
+ .await229
+ }230
+231
+ pub async fn advance(&self, peer: &str, scope: &str, until: u64) -> Result<()> {232
+ let peer_url = peer.to_owned();233
+ let scope_name = scope.to_owned();234
+ self.write(move |connection| write_checkpoint(connection, &peer_url, &scope_name, until))235
+ .await236
+ }237
+238
+ pub async fn repositories(&self, limit: usize) -> Result<Vec<(String, String)>> {239
+ self.run(move |connection| list_repositories(connection, limit))240
+ .await241
+ }242
+243
+ pub async fn healthy(&self) -> bool {244
+ self.run(|connection| Ok(connection.query_row("SELECT 1", [], |_| Ok(()))?))245
+ .await246
+ .is_ok()247
+ }248
+249
+ pub async fn save_locked(250
+ &self,251
+ event: Event,252
+ _write: &MutexGuard<'_, ()>,253
+ ) -> Result<SaveEventStatus> {254
+ self.run(move |connection| save_event(connection, &event))255
+ .await256
+ }257
+258
+ pub async fn status(&self, event_id: EventId) -> Result<DatabaseEventStatus> {259
+ self.run(move |connection| event_status(connection, &event_id))260
+ .await261
+ }262
+263
+ pub async fn event(&self, event_id: EventId) -> Result<Option<Event>> {264
+ self.run(move |connection| event_by_id(connection, &event_id))265
+ .await266
+ }267
+268
+ pub async fn count(&self, filter: Filter) -> Result<usize> {269
+ self.run(move |connection| Ok(query_events(connection, &filter, usize::MAX)?.len()))270
+ .await271
+ }272
+273
+ pub async fn query(&self, filter: Filter, max_bytes: usize) -> Result<BTreeSet<Event>> {274
+ self.run(move |connection| query_events(connection, &filter, max_bytes))275
+ .await276
+ }277
+278
+ pub async fn replace_discovered_locked(279
+ &self,280
+ signer: PublicKey,281
+ urls: Vec<String>,282
+ max: usize,283
+ _write: &MutexGuard<'_, ()>,284
+ ) -> Result<()> {285
+ let signer_bytes = signer.as_bytes().to_vec();286
+ self.run(move |connection| replace_discovered(connection, &signer_bytes, urls, max))287
+ .await288
+ }289
+290
+ pub async fn discovered(&self, limit: usize) -> Result<Vec<String>> {291
+ self.run(move |connection| list_discovered(connection, limit))292
+ .await293
+ }294
+295
+ pub async fn forget_discovered(&self, urls: Vec<String>) -> Result<()> {296
+ self.write(move |connection| forget_discovered(connection, urls))297
+ .await298
+ }299
+300
+ async fn write<T, F>(&self, operation: F) -> Result<T>301
+ where302
+ T: Send + 'static,303
+ F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,304
+ {305
+ let _write = self.writes.lock().await;306
+ self.run(operation).await307
+ }308
+309
+ async fn run<T, F>(&self, operation: F) -> Result<T>310
+ where311
+ T: Send + 'static,312
+ F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,313
+ {314
+ let shared_connection = Arc::clone(&self.connection);315
+ let mut connection = shared_connection.lock_owned().await;316
+ spawn_blocking(move || operation(&mut connection))317
+ .await318
+ .context("journal task panicked")?319
+ }320
+}321
+322
+fn open_connection(323
+ path: &Path,324
+ max_database_bytes: u64,325
+ max_discovered_peers: usize,326
+ excluded_peers: &[String],327
+) -> Result<Connection> {328
+ let mut connection =329
+ Connection::open(path).with_context(|| format!("opening journal {}", path.display()))?;330
+ configure(&connection, max_database_bytes)?;331
+ migrate(&mut connection, max_discovered_peers, excluded_peers)?;332
+ Ok(connection)333
+}334
+335
+fn pending_deliveries(connection: &Connection, peer: &str, limit: usize) -> Result<Vec<Delivery>> {336
+ if limit == 0 {337
+ return Ok(Vec::new());338
+ }339
+340
+ let retry_limit = limit / 2;341
+ let mut pending = if retry_limit == 0 {342
+ Vec::new()343
+ } else {344
+ read_deliveries(345
+ connection,346
+ DUE_DELIVERIES,347
+ params![peer, unix_seconds(), integer(retry_limit)?],348
+ )?349
+ };350
+351
+ let remaining = limit - pending.len();352
+ if remaining == 0 {353
+ return Ok(pending);354
+ }355
+356
+ let delivered = read_delivered(connection, peer)?;357
+ pending.extend(read_deliveries(358
+ connection,359
+ NEW_DELIVERIES,360
+ params![delivered, integer(remaining)?],361
+ )?);362
+ Ok(pending)363
+}364
+365
+fn read_deliveries<P>(connection: &Connection, sql: &str, parameters: P) -> Result<Vec<Delivery>>366
+where367
+ P: Params,368
+{369
+ let mut statement = connection.prepare(sql)?;370
+ let mut rows = statement.query(parameters)?;371
+ let mut deliveries = Vec::new();372
+373
+ while let Some(row) = rows.next()? {374
+ let sequence = row.get::<_, i64>(0)?;375
+ let event_id = row.get::<_, Vec<u8>>(1)?;376
+ deliveries.push((377
+ u64::try_from(sequence).context("negative delivery sequence")?,378
+ EventId::from_slice(&event_id)379
+ .map_err(|error| anyhow::anyhow!("invalid event id {error}"))?,380
+ ));381
+ }382
+383
+ Ok(deliveries)384
+}385
+386
+fn read_delivered(connection: &Connection, peer: &str) -> Result<i64> {387
+ Ok(connection388
+ .query_row(389
+ "SELECT delivered FROM vineyard_peers WHERE peer = ?1",390
+ [peer],391
+ |row| row.get(0),392
+ )393
+ .optional()?394
+ .unwrap_or(0))395
+}396
+397
+fn acknowledge_delivery(connection: &mut Connection, peer: &str, sequence: u64) -> Result<()> {398
+ let stored_sequence = integer(sequence)?;399
+ let transaction = connection.transaction()?;400
+ transaction.execute(401
+ "DELETE FROM vineyard_retries WHERE peer = ?1 AND sequence = ?2",402
+ params![peer, stored_sequence],403
+ )?;404
+ advance_delivery(&transaction, peer, stored_sequence)?;405
+ transaction.commit()?;406
+ Ok(())407
+}408
+409
+fn defer_delivery(410
+ connection: &mut Connection,411
+ peer: &str,412
+ sequence: u64,413
+ error: &str,414
+ retry_at: u64,415
+) -> Result<()> {416
+ let stored_sequence = integer(sequence)?;417
+ let stored_retry_at = integer(retry_at)?;418
+ let transaction = connection.transaction()?;419
+ advance_delivery(&transaction, peer, stored_sequence)?;420
+ transaction.execute(421
+ "422
+ INSERT INTO vineyard_retries(peer, sequence, retry_at, error)423
+ VALUES (?1, ?2, ?3, ?4)424
+ ON CONFLICT(peer, sequence) DO UPDATE SET425
+ retry_at = excluded.retry_at,426
+ error = excluded.error427
+ ",428
+ params![peer, stored_sequence, stored_retry_at, error],429
+ )?;430
+ transaction.commit()?;431
+ Ok(())432
+}433
+434
+fn read_checkpoint(connection: &Connection, peer: &str, scope: &str) -> Result<Option<u64>> {435
+ let checkpoint = connection436
+ .query_row(437
+ "SELECT until FROM vineyard_checkpoints WHERE peer = ?1 AND scope = ?2",438
+ params![peer, scope],439
+ |row| row.get::<_, i64>(0),440
+ )441
+ .optional()?;442
+443
+ checkpoint444
+ .map(|value| u64::try_from(value).context("negative checkpoint"))445
+ .transpose()446
+}447
+448
+fn write_checkpoint(connection: &Connection, peer: &str, scope: &str, until: u64) -> Result<()> {449
+ connection.execute(450
+ "451
+ INSERT INTO vineyard_checkpoints(peer, scope, until)452
+ VALUES (?1, ?2, ?3)453
+ ON CONFLICT(peer, scope) DO UPDATE SET454
+ until = max(vineyard_checkpoints.until, excluded.until)455
+ ",456
+ params![peer, scope, integer(until)?],457
+ )?;458
+ Ok(())459
+}460
+461
+fn list_repositories(connection: &Connection, limit: usize) -> Result<Vec<(String, String)>> {462
+ let mut statement = connection.prepare(REPOSITORIES)?;463
+ let mut rows = statement.query([])?;464
+ let mut repositories = Vec::new();465
+ let mut seen = BTreeSet::new();466
+ while let Some(row) = rows.next()? {467
+ let tags = serde_json::from_str::<Tags>(&row.get::<_, String>(2)?)?;468
+ if tags469
+ .expiration()470
+ .is_some_and(|expiration| expiration < Timestamp::now())471
+ {472
+ continue;473
+ }474
+ let repository = (row.get(0)?, row.get(1)?);475
+ if !seen.insert(repository.clone()) {476
+ continue;477
+ }478
+ repositories.push(repository);479
+ if repositories.len() == limit {480
+ break;481
+ }482
+ }483
+ Ok(repositories)484
+}485
+486
+fn event_status(connection: &Connection, event_id: &EventId) -> Result<DatabaseEventStatus> {487
+ let deleted = connection.query_row(488
+ "SELECT EXISTS(SELECT 1 FROM deleted_ids WHERE event_id = ?1)",489
+ [event_id.as_bytes().as_slice()],490
+ |row| row.get::<_, bool>(0),491
+ )?;492
+ if deleted {493
+ return Ok(DatabaseEventStatus::Deleted);494
+ }495
+ let saved = connection.query_row(496
+ "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",497
+ [event_id.as_bytes().as_slice()],498
+ |row| row.get::<_, bool>(0),499
+ )?;500
+ Ok(if saved {501
+ DatabaseEventStatus::Saved502
+ } else {503
+ DatabaseEventStatus::NotExistent504
+ })505
+}506
+507
+fn event_by_id(connection: &Connection, event_id: &EventId) -> Result<Option<Event>> {508
+ connection509
+ .query_row(510
+ "511
+ SELECT id, pubkey, created_at, kind, content, tags, sig512
+ FROM events513
+ WHERE id = ?1514
+ ",515
+ [event_id.as_bytes().as_slice()],516
+ StoredEvent::read,517
+ )518
+ .optional()?519
+ .map(StoredEvent::into_event)520
+ .transpose()521
+}522
+523
+fn save_event(connection: &mut Connection, event: &Event) -> Result<SaveEventStatus> {524
+ if event.kind.is_ephemeral() {525
+ return Ok(SaveEventStatus::Rejected(RejectedReason::Ephemeral));526
+ }527
+528
+ let transaction = connection.transaction()?;529
+ if event_exists(&transaction, &event.id)? {530
+ return Ok(SaveEventStatus::Rejected(RejectedReason::Duplicate));531
+ }532
+ if event_deleted(&transaction, &event.id)? || coordinate_deleted(&transaction, event)? {533
+ return Ok(SaveEventStatus::Rejected(RejectedReason::Deleted));534
+ }535
+ if replacement_rejected(&transaction, event)? {536
+ return Ok(SaveEventStatus::Rejected(RejectedReason::Replaced));537
+ }538
+ if event.kind == Kind::EventDeletion && !apply_deletion(&transaction, event)? {539
+ return Ok(SaveEventStatus::Rejected(RejectedReason::InvalidDelete));540
+ }541
+542
+ transaction.execute(543
+ "544
+ INSERT INTO events(id, pubkey, created_at, kind, content, tags, sig)545
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)546
+ ",547
+ params![548
+ event.id.as_bytes().as_slice(),549
+ event.pubkey.as_bytes().as_slice(),550
+ integer(event.created_at.as_secs())?,551
+ i64::from(event.kind.as_u16()),552
+ &event.content,553
+ serde_json::to_string(&event.tags)?,554
+ event.sig.as_bytes().as_slice(),555
+ ],556
+ )?;557
+ for tag in event.tags.iter() {558
+ let (Some(name), Some(value)) = (tag.single_letter_tag(), tag.content()) else {559
+ continue;560
+ };561
+ transaction.execute(562
+ "563
+ INSERT OR IGNORE INTO event_tags(event_id, tag_name, tag_value)564
+ VALUES (?1, ?2, ?3)565
+ ",566
+ params![event.id.as_bytes().as_slice(), name.to_string(), value],567
+ )?;568
+ }569
+ transaction.commit()?;570
+ Ok(SaveEventStatus::Success)571
+}572
+573
+fn event_exists(transaction: &Transaction<'_>, event_id: &EventId) -> Result<bool> {574
+ Ok(transaction.query_row(575
+ "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",576
+ [event_id.as_bytes().as_slice()],577
+ |row| row.get(0),578
+ )?)579
+}580
+581
+fn event_deleted(transaction: &Transaction<'_>, event_id: &EventId) -> Result<bool> {582
+ Ok(transaction.query_row(583
+ "SELECT EXISTS(SELECT 1 FROM deleted_ids WHERE event_id = ?1)",584
+ [event_id.as_bytes().as_slice()],585
+ |row| row.get(0),586
+ )?)587
+}588
+589
+fn coordinate_deleted(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {590
+ let Some(coordinate) = event.coordinate() else {591
+ return Ok(false);592
+ };593
+ let deleted_at = transaction594
+ .query_row(595
+ "596
+ SELECT deleted_at597
+ FROM deleted_coordinates598
+ WHERE pubkey = ?1 AND kind = ?2 AND identifier = ?3599
+ ",600
+ params![601
+ coordinate.public_key.as_bytes().as_slice(),602
+ i64::from(coordinate.kind.as_u16()),603
+ coordinate.identifier,604
+ ],605
+ |row| row.get::<_, i64>(0),606
+ )607
+ .optional()?;608
+ Ok(deleted_at.is_some_and(|stored_deleted_at| {609
+ u64::try_from(stored_deleted_at).map_or(true, |value| event.created_at.as_secs() <= value)610
+ }))611
+}612
+613
+fn replacement_rejected(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {614
+ let existing = if event.kind.is_replaceable() {615
+ transaction616
+ .query_row(617
+ "618
+ SELECT id, created_at619
+ FROM events620
+ WHERE pubkey = ?1 AND kind = ?2621
+ LIMIT 1622
+ ",623
+ params![624
+ event.pubkey.as_bytes().as_slice(),625
+ i64::from(event.kind.as_u16()),626
+ ],627
+ |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),628
+ )629
+ .optional()?630
+ } else if event.kind.is_addressable() {631
+ let Some(identifier) = event.tags.identifier() else {632
+ return Ok(false);633
+ };634
+ transaction635
+ .query_row(636
+ "637
+ SELECT event.id, event.created_at638
+ FROM events AS event639
+ JOIN event_tags AS tag ON tag.event_id = event.id640
+ WHERE event.pubkey = ?1641
+ AND event.kind = ?2642
+ AND tag.tag_name = 'd'643
+ AND tag.tag_value = ?3644
+ LIMIT 1645
+ ",646
+ params![647
+ event.pubkey.as_bytes().as_slice(),648
+ i64::from(event.kind.as_u16()),649
+ identifier,650
+ ],651
+ |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),652
+ )653
+ .optional()?654
+ } else {655
+ return Ok(false);656
+ };657
+658
+ let Some((stored_id, stored_at)) = existing else {659
+ return Ok(false);660
+ };661
+ let incoming_at = integer(event.created_at.as_secs())?;662
+ match stored_at.cmp(&incoming_at) {663
+ Ordering::Greater => Ok(true),664
+ Ordering::Equal if stored_id.as_slice() < event.id.as_bytes().as_slice() => Ok(true),665
+ Ordering::Equal | Ordering::Less => {666
+ transaction.execute("DELETE FROM events WHERE id = ?1", [stored_id])?;667
+ Ok(false)668
+ }669
+ }670
+}671
+672
+fn apply_deletion(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {673
+ for event_id in event.tags.event_ids() {674
+ let owner = transaction675
+ .query_row(676
+ "SELECT pubkey FROM events WHERE id = ?1",677
+ [event_id.as_bytes().as_slice()],678
+ |row| row.get::<_, Vec<u8>>(0),679
+ )680
+ .optional()?;681
+ if owner.is_some_and(|saved_owner| saved_owner.as_slice() != event.pubkey.as_bytes()) {682
+ return Ok(false);683
+ }684
+ transaction.execute(685
+ "INSERT OR IGNORE INTO deleted_ids(event_id) VALUES (?1)",686
+ [event_id.as_bytes().as_slice()],687
+ )?;688
+ transaction.execute(689
+ "DELETE FROM events WHERE id = ?1 AND pubkey = ?2",690
+ params![691
+ event_id.as_bytes().as_slice(),692
+ event.pubkey.as_bytes().as_slice(),693
+ ],694
+ )?;695
+ }696
+697
+ for coordinate in event.tags.coordinates() {698
+ if coordinate.public_key != event.pubkey {699
+ return Ok(false);700
+ }701
+ transaction.execute(702
+ "703
+ INSERT INTO deleted_coordinates(pubkey, kind, identifier, deleted_at)704
+ VALUES (?1, ?2, ?3, ?4)705
+ ON CONFLICT(pubkey, kind, identifier) DO UPDATE SET706
+ deleted_at = max(deleted_coordinates.deleted_at, excluded.deleted_at)707
+ ",708
+ params![709
+ coordinate.public_key.as_bytes().as_slice(),710
+ i64::from(coordinate.kind.as_u16()),711
+ &coordinate.identifier,712
+ integer(event.created_at.as_secs())?,713
+ ],714
+ )?;715
+ if coordinate.kind.is_replaceable() {716
+ transaction.execute(717
+ "718
+ DELETE FROM events719
+ WHERE pubkey = ?1 AND kind = ?2 AND created_at <= ?3720
+ ",721
+ params![722
+ coordinate.public_key.as_bytes().as_slice(),723
+ i64::from(coordinate.kind.as_u16()),724
+ integer(event.created_at.as_secs())?,725
+ ],726
+ )?;727
+ } else {728
+ transaction.execute(729
+ "730
+ DELETE FROM events731
+ WHERE id IN (732
+ SELECT stored.id733
+ FROM events AS stored734
+ JOIN event_tags AS tag ON tag.event_id = stored.id735
+ WHERE stored.pubkey = ?1736
+ AND stored.kind = ?2737
+ AND stored.created_at <= ?3738
+ AND tag.tag_name = 'd'739
+ AND tag.tag_value = ?4740
+ )741
+ ",742
+ params![743
+ coordinate.public_key.as_bytes().as_slice(),744
+ i64::from(coordinate.kind.as_u16()),745
+ integer(event.created_at.as_secs())?,746
+ &coordinate.identifier,747
+ ],748
+ )?;749
+ }750
+ }751
+ Ok(true)752
+}753
+754
+fn query_events(755
+ connection: &Connection,756
+ filter: &Filter,757
+ max_bytes: usize,758
+) -> Result<BTreeSet<Event>> {759
+ let query = EventQuery::new(filter)?;760
+ if query.limit == 0 {761
+ return Ok(BTreeSet::new());762
+ }763
+ let mut statement = connection.prepare(&query.sql)?;764
+ let mut rows = statement.query(params_from_iter(query.parameters))?;765
+ let mut events = BTreeSet::new();766
+ let mut bytes = 0_usize;767
+768
+ while let Some(row) = rows.next()? {769
+ let event = StoredEvent::read(row)?.into_event()?;770
+ if event.is_expired() {771
+ continue;772
+ }773
+ bytes = bytes774
+ .checked_add(event.as_json().len())775
+ .context("history byte count overflow")?;776
+ if bytes > max_bytes {777
+ anyhow::bail!("history byte limit exceeded");778
+ }779
+ events.insert(event);780
+ if events.len() == query.limit {781
+ break;782
+ }783
+ }784
+785
+ Ok(events)786
+}787
+788
+fn replace_discovered(789
+ connection: &mut Connection,790
+ signer: &[u8],791
+ urls: Vec<String>,792
+ max: usize,793
+) -> Result<()> {794
+ let transaction = connection.transaction()?;795
+ transaction.execute(796
+ "DELETE FROM vineyard_discovered_peers WHERE signer = ?1",797
+ [signer],798
+ )?;799
+ let stored_count = transaction.query_row(800
+ "SELECT count(DISTINCT url) FROM vineyard_discovered_peers",801
+ [],802
+ |row| row.get::<_, i64>(0),803
+ )?;804
+ let count = usize::try_from(stored_count).context("negative discovered peer count")?;805
+806
+ for url in urls.into_iter().take(max.saturating_sub(count)) {807
+ transaction.execute(808
+ "809
+ INSERT OR IGNORE INTO vineyard_discovered_peers(signer, url, discovered_at)810
+ VALUES (?1, ?2, unixepoch())811
+ ",812
+ params![signer, url],813
+ )?;814
+ }815
+816
+ transaction.commit()?;817
+ Ok(())818
+}819
+820
+fn list_discovered(connection: &Connection, limit: usize) -> Result<Vec<String>> {821
+ let mut statement = connection822
+ .prepare("SELECT DISTINCT url FROM vineyard_discovered_peers ORDER BY url LIMIT ?1")?;823
+ let mut rows = statement.query([integer(limit)?])?;824
+ let mut urls = Vec::new();825
+ while let Some(row) = rows.next()? {826
+ urls.push(row.get(0)?);827
+ }828
+ Ok(urls)829
+}830
+831
+fn forget_discovered(connection: &mut Connection, urls: Vec<String>) -> Result<()> {832
+ let transaction = connection.transaction()?;833
+ for url in urls {834
+ transaction.execute(835
+ "DELETE FROM vineyard_discovered_peers WHERE url = ?1",836
+ [url],837
+ )?;838
+ }839
+ transaction.commit()?;840
+ Ok(())841
+}842
+843
+impl StoredEvent {844
+ fn read(row: &Row<'_>) -> rusqlite::Result<Self> {845
+ Ok(Self {846
+ id: row.get(0)?,847
+ pubkey: row.get(1)?,848
+ created_at: row.get(2)?,849
+ kind: row.get(3)?,850
+ content: row.get(4)?,851
+ tags: row.get(5)?,852
+ signature: row.get(6)?,853
+ })854
+ }855
+856
+ fn into_event(self) -> Result<Event> {857
+ let id = EventId::from_slice(&self.id).context("invalid stored event id")?;858
+ let pubkey = PublicKey::from_slice(&self.pubkey).context("invalid stored public key")?;859
+ let created_at: Timestamp = self860
+ .created_at861
+ .try_into()862
+ .context("invalid event timestamp")?;863
+ let kind = Kind::from_u16(self.kind.try_into().context("invalid stored event kind")?);864
+ let tags: Tags = serde_json::from_str(&self.tags).context("invalid stored event tags")?;865
+ let signature =866
+ Signature::from_slice(&self.signature).context("invalid stored event signature")?;867
+ Ok(Event::new(868
+ id,869
+ pubkey,870
+ created_at,871
+ kind,872
+ tags,873
+ self.content,874
+ signature,875
+ ))876
+ }877
+}878
+879
+fn configure(connection: &Connection, max_database_bytes: u64) -> Result<()> {880
+ connection.busy_timeout(Duration::from_secs(5))?;881
+ connection.pragma_update(None, "journal_mode", "WAL")?;882
+ let journal_mode: String =883
+ connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?;884
+ if !journal_mode.eq_ignore_ascii_case("wal") {885
+ anyhow::bail!("journal mode is not wal");886
+ }887
+888
+ connection.pragma_update(None, "synchronous", "FULL")?;889
+ connection.pragma_update(None, "foreign_keys", "ON")?;890
+891
+ let stored_page_size =892
+ connection.query_row("PRAGMA page_size", [], |row| row.get::<_, i64>(0))?;893
+ let page_size = u64::try_from(stored_page_size).context("invalid database page size")?;894
+ let page_budget = max_database_bytes / page_size;895
+ if page_budget == 0 {896
+ anyhow::bail!("database budget is smaller than one page");897
+ }898
+ let max_pages = integer(page_budget)?;899
+ let page_count = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0))?;900
+ if page_count > max_pages {901
+ anyhow::bail!("database exceeds size budget");902
+ }903
+ connection.pragma_update(None, "max_page_count", max_pages)?;904
+ Ok(())905
+}906
+907
+fn migrate(908
+ connection: &mut Connection,909
+ max_discovered_peers: usize,910
+ excluded_peers: &[String],911
+) -> Result<()> {912
+ let transaction = connection.transaction()?;913
+ migrate_legacy_tables(&transaction)?;914
+ transaction.execute_batch(SCHEMA)?;915
+ transaction.execute(BACKFILL_DELIVERY, [])?;916
+ transaction.commit()?;917
+ backfill_discovered(connection, max_discovered_peers, excluded_peers)?;918
+ Ok(())919
+}920
+921
+fn migrate_legacy_tables(transaction: &Transaction<'_>) -> Result<()> {922
+ for (old, new) in [923
+ ("sanctum_delivery", "vineyard_delivery"),924
+ ("sanctum_peers", "vineyard_peers"),925
+ ("sanctum_checkpoints", "vineyard_checkpoints"),926
+ ("sanctum_retries", "vineyard_retries"),927
+ ("sanctum_discovered_peers", "vineyard_discovered_peers"),928
+ ] {929
+ let old_exists = table_exists(transaction, old)?;930
+ let new_exists = table_exists(transaction, new)?;931
+ if old_exists && new_exists {932
+ anyhow::bail!("database contains both {old} and {new}");933
+ }934
+ if old_exists {935
+ transaction.execute_batch(&format!("ALTER TABLE {old} RENAME TO {new}"))?;936
+ }937
+ }938
+ transaction.execute_batch(939
+ "940
+ DROP TRIGGER IF EXISTS sanctum_delivery_after_insert;941
+ DROP INDEX IF EXISTS sanctum_retries_due;942
+ DROP INDEX IF EXISTS sanctum_discovered_urls;943
+ ",944
+ )?;945
+ Ok(())946
+}947
+948
+fn table_exists(transaction: &Transaction<'_>, name: &str) -> Result<bool> {949
+ Ok(transaction.query_row(950
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",951
+ [name],952
+ |row| row.get(0),953
+ )?)954
+}955
+956
+fn backfill_discovered(957
+ connection: &mut Connection,958
+ max_discovered_peers: usize,959
+ excluded_peers: &[String],960
+) -> Result<()> {961
+ let excluded = excluded_peers.iter().cloned().collect::<HashSet<_>>();962
+ let relay_tags = {963
+ let mut statement = connection.prepare(DISCOVERED_RELAY_TAGS)?;964
+ let mut rows = statement.query([])?;965
+ let mut relay_tags = Vec::new();966
+ while let Some(row) = rows.next()? {967
+ relay_tags.push((968
+ row.get::<_, Vec<u8>>(0)?,969
+ row.get::<_, String>(1)?,970
+ row.get::<_, i64>(2)?,971
+ ));972
+ }973
+ relay_tags974
+ };975
+976
+ let transaction = connection.transaction()?;977
+ transaction.execute("DELETE FROM vineyard_discovered_peers", [])?;978
+ let mut urls = HashSet::new();979
+ for (signer, raw_url, discovered_at) in relay_tags {980
+ let Some(url) = relay_url(&raw_url).map(|url| url.to_string()) else {981
+ continue;982
+ };983
+ if excluded.contains(&url) || (!urls.contains(&url) && urls.len() >= max_discovered_peers) {984
+ continue;985
+ }986
+ urls.insert(url.clone());987
+ transaction.execute(988
+ "989
+ INSERT OR IGNORE INTO vineyard_discovered_peers(signer, url, discovered_at)990
+ VALUES (?1, ?2, ?3)991
+ ",992
+ params![signer, url, discovered_at],993
+ )?;994
+ }995
+ transaction.commit()?;996
+ Ok(())997
+}998
+999
+fn advance_delivery(transaction: &Transaction<'_>, peer: &str, sequence: i64) -> Result<()> {1000
+ transaction.execute(1001
+ "1002
+ INSERT INTO vineyard_peers(peer, delivered)1003
+ VALUES (?1, ?2)1004
+ ON CONFLICT(peer) DO UPDATE SET1005
+ delivered = max(vineyard_peers.delivered, excluded.delivered)1006
+ ",1007
+ params![peer, sequence],1008
+ )?;1009
+ Ok(())1010
+}1011
+1012
+fn integer<T>(value: T) -> Result<i64>1013
+where1014
+ T: TryInto<i64>,1015
+ T::Error: Error + Send + Sync + 'static,1016
+{1017
+ value1018
+ .try_into()1019
+ .context("value exceeds sqlite integer range")1020
+}1021
+1022
+fn unix_seconds() -> i64 {1023
+ SystemTime::now()1024
+ .duration_since(UNIX_EPOCH)1025
+ .map_or(0, |duration| {1026
+ i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)1027
+ })1028
+}@@ -0,0 +1,8 @@
1
+pub mod config;2
+mod federation;3
+mod journal;4
+pub mod policy;5
+mod query;6
+mod relay;7
+pub mod server;8
+pub mod store;@@ -0,0 +1,61 @@
1
+use std::io::{Write as _, stdout};2
+use std::path::PathBuf;3
+4
+use anyhow::Context as _;5
+use clap::{Parser, Subcommand};6
+use tokio::signal::ctrl_c;7
+#[cfg(unix)]8
+use tokio::signal::unix::{SignalKind, signal};9
+use vineyard::config::Config;10
+use vineyard::server::spawn;11
+12
+#[derive(Parser)]13
+#[command(version, about = "a persistent nostr relay for git forges")]14
+struct Cli {15
+ #[command(subcommand)]16
+ command: Command,17
+}18
+19
+#[derive(Subcommand)]20
+enum Command {21
+ Serve {22
+ #[arg(long, default_value = "vineyard.toml")]23
+ config: PathBuf,24
+ },25
+ CheckConfig {26
+ #[arg(long, default_value = "vineyard.toml")]27
+ config: PathBuf,28
+ },29
+}30
+31
+#[tokio::main(worker_threads = 2)]32
+async fn main() -> anyhow::Result<()> {33
+ tracing_subscriber::fmt()34
+ .with_env_filter(35
+ tracing_subscriber::EnvFilter::try_from_default_env()36
+ .unwrap_or_else(|_| "vineyard=info".into()),37
+ )38
+ .init();39
+ match Cli::parse().command {40
+ Command::CheckConfig { config } => {41
+ Config::load(&config)?;42
+ writeln!(stdout().lock(), "configuration is valid")?;43
+ }44
+ Command::Serve { config } => {45
+ let relay = spawn(Config::load(&config)?).await?;46
+ #[cfg(unix)]47
+ {48
+ let mut terminate =49
+ signal(SignalKind::terminate()).context("installing termination handler")?;50
+ tokio::select! {51
+ result = ctrl_c() => result?,52
+ _ = terminate.recv() => {},53
+ }54
+ }55
+ #[cfg(not(unix))]56
+ ctrl_c().await?;57
+ relay.shutdown().await?;58
+ }59
+ }60
+ Ok(())61
+}@@ -0,0 +1,450 @@
1
+use std::borrow::Cow;2
+use std::collections::{HashMap, HashSet};3
+use std::net::{IpAddr, SocketAddr};4
+use std::sync::{Arc, Mutex, PoisonError};5
+use std::time::{Duration, Instant};6
+7
+use nostr::event::{Event, EventId, Kind};8
+use nostr::filter::Filter;9
+use nostr::key::PublicKey;10
+use nostr::message::MachineReadablePrefix;11
+use nostr::nips::nip65::extract_relay_list;12
+use nostr::types::Timestamp;13
+use nostr_database::{DatabaseEventStatus, NostrDatabase, error::Error};14
+use serde::Deserialize;15
+16
+use crate::config::{Config, Limits};17
+18
+#[derive(Debug)]19
+struct Usage {20
+ window: Instant,21
+ events: u32,22
+ queries: u32,23
+}24
+25
+#[derive(Debug, Clone)]26
+pub struct Policy {27
+ database: Arc<dyn NostrDatabase>,28
+ limits: Limits,29
+ forge_keys: HashSet<PublicKey>,30
+ peers: Arc<Mutex<HashMap<IpAddr, Usage>>>,31
+}32
+33
+#[derive(Deserialize)]34
+#[serde(deny_unknown_fields)]35
+struct Binding {36
+ version: u64,37
+ action: String,38
+ fingerprint: String,39
+ certificate: Option<String>,40
+ signature: Option<String>,41
+}42
+43
+pub(crate) enum WritePolicyResult {44
+ Accept,45
+ Respond {46
+ prefix: MachineReadablePrefix,47
+ message: Cow<'static, str>,48
+ status: bool,49
+ },50
+}51
+52
+impl WritePolicyResult {53
+ const fn accepted(prefix: MachineReadablePrefix, message: &'static str) -> Self {54
+ Self::Respond {55
+ prefix,56
+ message: Cow::Borrowed(message),57
+ status: true,58
+ }59
+ }60
+61
+ const fn reject(prefix: MachineReadablePrefix, message: &'static str) -> Self {62
+ Self::Respond {63
+ prefix,64
+ message: Cow::Borrowed(message),65
+ status: false,66
+ }67
+ }68
+}69
+70
+pub(crate) enum QueryPolicyResult {71
+ Accept,72
+ Reject {73
+ prefix: MachineReadablePrefix,74
+ message: Cow<'static, str>,75
+ },76
+}77
+78
+impl QueryPolicyResult {79
+ const fn reject(prefix: MachineReadablePrefix, message: &'static str) -> Self {80
+ Self::Reject {81
+ prefix,82
+ message: Cow::Borrowed(message),83
+ }84
+ }85
+}86
+87
+impl Policy {88
+ pub fn new(database: Arc<dyn NostrDatabase>, config: &Config) -> anyhow::Result<Self> {89
+ config.validate()?;90
+ Ok(Self {91
+ database,92
+ limits: config.limits.clone(),93
+ forge_keys: config.forge_keys()?,94
+ peers: Arc::new(Mutex::new(HashMap::new())),95
+ })96
+ }97
+98
+ fn charge(&self, ip: IpAddr, publish: bool) -> bool {99
+ let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);100
+ peers.retain(|_, usage| usage.window.elapsed() < Duration::from_secs(60));101
+ if peers.len() >= 4096 && !peers.contains_key(&ip) {102
+ return false;103
+ }104
+ let usage = peers.entry(ip).or_insert_with(|| Usage {105
+ window: Instant::now(),106
+ events: 0,107
+ queries: 0,108
+ });109
+ let (count, limit) = if publish {110
+ (&mut usage.events, self.limits.events_per_minute)111
+ } else {112
+ (&mut usage.queries, self.limits.queries_per_minute)113
+ };114
+ if *count >= limit {115
+ return false;116
+ }117
+ *count += 1;118
+ drop(peers);119
+ true120
+ }121
+122
+ pub(crate) async fn admit_event(123
+ &self,124
+ event: &Event,125
+ address: &SocketAddr,126
+ ) -> WritePolicyResult {127
+ if !self.charge(address.ip(), true) {128
+ return WritePolicyResult::reject(129
+ MachineReadablePrefix::RateLimited,130
+ "publish rate exceeded",131
+ );132
+ }133
+ self.admit(event).await.unwrap_or_else(|error| {134
+ tracing::error!(%error, "event admission database failure");135
+ WritePolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")136
+ })137
+ }138
+139
+ pub(crate) async fn admit_peer(&self, event: &Event) -> WritePolicyResult {140
+ match self.admit(event).await {141
+ Ok(result) => result,142
+ Err(error) => {143
+ tracing::error!(%error, "event admission database failure");144
+ WritePolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")145
+ }146
+ }147
+ }148
+149
+ pub(crate) async fn admit_query(150
+ &self,151
+ filter: &Filter,152
+ address: &SocketAddr,153
+ ) -> QueryPolicyResult {154
+ if !self.charge(address.ip(), false) {155
+ return QueryPolicyResult::reject(156
+ MachineReadablePrefix::RateLimited,157
+ "query rate exceeded",158
+ );159
+ }160
+ if filter.as_json().len() > self.limits.max_filter_bytes {161
+ return QueryPolicyResult::reject(162
+ MachineReadablePrefix::RateLimited,163
+ "filter byte limit exceeded",164
+ );165
+ }166
+ if filter.search.is_some() {167
+ return QueryPolicyResult::reject(168
+ MachineReadablePrefix::Unsupported,169
+ "search is not supported",170
+ );171
+ }172
+ if let Some(limit) = filter.limit {173
+ return if limit > self.limits.max_query_results {174
+ QueryPolicyResult::reject(175
+ MachineReadablePrefix::RateLimited,176
+ "query result limit exceeded",177
+ )178
+ } else {179
+ QueryPolicyResult::Accept180
+ };181
+ }182
+ match self183
+ .database184
+ .count(filter.clone().limit(self.limits.max_query_results + 1))185
+ .await186
+ {187
+ Ok(count) if count <= self.limits.max_query_results => QueryPolicyResult::Accept,188
+ Ok(_) => QueryPolicyResult::reject(189
+ MachineReadablePrefix::RateLimited,190
+ "history result limit exceeded",191
+ ),192
+ Err(error) => {193
+ tracing::error!(%error, "query admission database failure");194
+ QueryPolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")195
+ }196
+ }197
+ }198
+199
+ async fn admit(&self, event: &Event) -> Result<WritePolicyResult, Error> {200
+ if let Some(result) = self.check_event(event) {201
+ return Ok(result);202
+ }203
+ match self.database.check_id(&event.id).await? {204
+ DatabaseEventStatus::Saved => {205
+ return Ok(WritePolicyResult::accepted(206
+ MachineReadablePrefix::Duplicate,207
+ "already stored",208
+ ));209
+ }210
+ DatabaseEventStatus::Deleted => return Ok(blocked("event is deleted")),211
+ DatabaseEventStatus::NotExistent => {}212
+ }213
+ self.admit_kind(event).await214
+ }215
+216
+ fn check_event(&self, event: &Event) -> Option<WritePolicyResult> {217
+ if event.as_json().len() > self.limits.max_event_bytes218
+ || event.tags.len() > self.limits.max_tags219
+ {220
+ return Some(blocked("event exceeds size or tag limit"));221
+ }222
+ if event.created_at.as_secs() == 0223
+ || event.created_at.as_secs() > Timestamp::now().as_secs().saturating_add(900)224
+ {225
+ return Some(blocked("event timestamp is outside the supported range"));226
+ }227
+ if event.is_expired() || event.is_protected() {228
+ return Some(blocked("expired or protected events are not accepted"));229
+ }230
+ None231
+ }232
+233
+ async fn admit_kind(&self, event: &Event) -> Result<WritePolicyResult, Error> {234
+ match event.kind.as_u16() {235
+ 10002 => {236
+ if !self.forge_keys.contains(&event.pubkey)237
+ && !self.registered_forge(event.pubkey).await?238
+ {239
+ return Ok(blocked("relay list signer has no registered repository"));240
+ }241
+ let relay_tags = event242
+ .tags243
+ .iter()244
+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "r"))245
+ .count();246
+ if relay_tags > 16 || extract_relay_list(event).count() != relay_tags {247
+ return Ok(blocked("relay list is invalid or too large"));248
+ }249
+ }250
+ 30617 => {251
+ let Some(identifier) = identifier(event) else {252
+ return Ok(blocked("one valid repository identifier is required"));253
+ };254
+ if !self.registered(event.pubkey, identifier).await? {255
+ let count = self256
+ .database257
+ .query(258
+ Filter::new()259
+ .kind(Kind::from(30617))260
+ .limit(self.limits.max_repositories + 1),261
+ )262
+ .await?263
+ .len();264
+ if count >= self.limits.max_repositories {265
+ return Ok(WritePolicyResult::reject(266
+ MachineReadablePrefix::RateLimited,267
+ "repository directory is full",268
+ ));269
+ }270
+ }271
+ }272
+ 30618 => {273
+ let Some(identifier) = identifier(event) else {274
+ return Ok(blocked("one valid repository identifier is required"));275
+ };276
+ if !self.registered(event.pubkey, identifier).await? {277
+ return Ok(blocked("repository is not registered"));278
+ }279
+ }280
+ 1617 | 1621 | 1630..=1632 => {281
+ let mut addresses = 0_usize;282
+ for tag in event283
+ .tags284
+ .iter()285
+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "a"))286
+ {287
+ let Some((kind, author, identifier)) =288
+ tag.as_slice().get(1).and_then(|value| coordinate(value))289
+ else {290
+ return Ok(blocked("invalid repository address"));291
+ };292
+ if kind != 30617 || !self.registered(author, identifier).await? {293
+ return Ok(blocked("repository is not registered"));294
+ }295
+ if matches!(event.kind.as_u16(), 1630..=1632) && author != event.pubkey {296
+ return Ok(blocked("status signer does not own the repository"));297
+ }298
+ addresses += 1;299
+ }300
+ if addresses != 1 {301
+ return Ok(blocked("exactly one repository address is required"));302
+ }303
+ }304
+ 30382 => {305
+ if !valid_binding(event) {306
+ return Ok(blocked("invalid openpgp binding envelope"));307
+ }308
+ }309
+ 5 => return self.deletion(event).await,310
+ _ => {311
+ return Ok(WritePolicyResult::reject(312
+ MachineReadablePrefix::Unsupported,313
+ "event kind is not supported",314
+ ));315
+ }316
+ }317
+ Ok(WritePolicyResult::Accept)318
+ }319
+320
+ async fn registered(&self, author: PublicKey, identifier: &str) -> Result<bool, Error> {321
+ Ok(self322
+ .database323
+ .query(324
+ Filter::new()325
+ .kind(Kind::from(30617))326
+ .author(author)327
+ .identifier(identifier)328
+ .limit(1),329
+ )330
+ .await?331
+ .iter()332
+ .any(|event| !event.is_expired()))333
+ }334
+335
+ async fn registered_forge(&self, author: PublicKey) -> Result<bool, Error> {336
+ Ok(self337
+ .database338
+ .query(339
+ Filter::new()340
+ .kind(Kind::from(30617))341
+ .author(author)342
+ .limit(1),343
+ )344
+ .await?345
+ .iter()346
+ .any(|event| !event.is_expired()))347
+ }348
+349
+ async fn deletion(&self, event: &Event) -> Result<WritePolicyResult, Error> {350
+ let mut targets = 0_usize;351
+ for tag in event.tags.iter() {352
+ let values = tag.as_slice();353
+ match values.first().map(String::as_str) {354
+ Some("e") => {355
+ let Some(id) = values356
+ .get(1)357
+ .filter(|id| id.len() == 64)358
+ .and_then(|id| id.parse::<EventId>().ok())359
+ else {360
+ return Ok(blocked("invalid deletion target"));361
+ };362
+ if let Some(target) = self.database.event_by_id(&id).await? {363
+ if target.pubkey != event.pubkey || target.kind == Kind::EventDeletion {364
+ return Ok(blocked("deletion target is not removable by this author"));365
+ }366
+ }367
+ targets += 1;368
+ }369
+ Some("a") => {370
+ let Some((kind, author, _)) = values.get(1).and_then(|value| coordinate(value))371
+ else {372
+ return Ok(blocked("invalid deletion address"));373
+ };374
+ if author != event.pubkey || !matches!(kind, 30617 | 30618 | 30382) {375
+ return Ok(blocked("deletion address is not owned by this author"));376
+ }377
+ targets += 1;378
+ }379
+ _ => {}380
+ }381
+ }382
+ Ok(if targets == 0 {383
+ blocked("deletion target is required")384
+ } else {385
+ WritePolicyResult::Accept386
+ })387
+ }388
+}389
+390
+const fn blocked(message: &'static str) -> WritePolicyResult {391
+ WritePolicyResult::reject(MachineReadablePrefix::Blocked, message)392
+}393
+394
+fn valid_identifier(identifier: &str) -> bool {395
+ !identifier.is_empty() && identifier.len() <= 512 && !identifier.chars().any(char::is_control)396
+}397
+398
+fn identifier(event: &Event) -> Option<&str> {399
+ let mut tags = event400
+ .tags401
+ .iter()402
+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "d"));403
+ let tag = tags.next()?.as_slice();404
+ if tags.next().is_some() || tag.len() != 2 || !valid_identifier(&tag[1]) {405
+ return None;406
+ }407
+ Some(&tag[1])408
+}409
+410
+pub(crate) fn coordinate(value: &str) -> Option<(u16, PublicKey, &str)> {411
+ let mut parts = value.splitn(3, ':');412
+ let kind = parts.next()?.parse().ok()?;413
+ let author_hex = parts.next()?;414
+ if author_hex.len() != 64 || !author_hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {415
+ return None;416
+ }417
+ let author = author_hex.parse().ok()?;418
+ let identifier = parts.next()?;419
+ valid_identifier(identifier).then_some((kind, author, identifier))420
+}421
+422
+fn valid_binding(event: &Event) -> bool {423
+ if event.content.len() > 192 * 1024 {424
+ return false;425
+ }426
+ let Ok(body) = serde_json::from_str::<Binding>(&event.content) else {427
+ return false;428
+ };429
+ if body.version != 1430
+ || !matches!(body.fingerprint.len(), 40 | 64)431
+ || !body432
+ .fingerprint433
+ .bytes()434
+ .all(|byte| byte.is_ascii_hexdigit())435
+ || identifier(event) != Some(body.fingerprint.as_str())436
+ {437
+ return false;438
+ }439
+ match body.action.as_str() {440
+ "bind" => {441
+ body.certificate442
+ .is_some_and(|certificate| !certificate.trim().is_empty())443
+ && body444
+ .signature445
+ .is_some_and(|signature| !signature.trim().is_empty())446
+ }447
+ "revoke" => body.certificate.is_none() && body.signature.is_none(),448
+ _ => false,449
+ }450
+}@@ -0,0 +1,131 @@
1
+use anyhow::{Context as _, Result};2
+use nostr::filter::Filter;3
+use rusqlite::types::Value;4
+5
+const DEFAULT_LIMIT: usize = 10_000;6
+7
+const SELECT_EVENTS: &str = "8
+SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.tags, e.sig9
+FROM events AS e10
+WHERE 1 = 111
+";12
+13
+const TAG_VALUES: &str = "14
+ AND EXISTS (15
+ SELECT 116
+ FROM event_tags AS tag17
+ WHERE tag.event_id = e.id18
+ AND tag.tag_name = ?19
+ AND tag.tag_value IN (20
+";21
+22
+pub struct EventQuery {23
+ pub(crate) sql: String,24
+ pub(crate) parameters: Vec<Value>,25
+ pub(crate) limit: usize,26
+}27
+28
+impl EventQuery {29
+ pub(crate) fn new(filter: &Filter) -> Result<Self> {30
+ if filter.search.is_some() {31
+ anyhow::bail!("search is not supported");32
+ }33
+34
+ let mut query = Self {35
+ sql: SELECT_EVENTS.to_owned(),36
+ parameters: Vec::new(),37
+ limit: filter.limit.unwrap_or(DEFAULT_LIMIT),38
+ };39
+40
+ if let Some(ids) = filter.ids.as_ref() {41
+ query.add_values(42
+ "e.id",43
+ ids.iter()44
+ .map(|id| Value::Blob(id.as_bytes().to_vec()))45
+ .collect(),46
+ );47
+ }48
+ if let Some(authors) = filter.authors.as_ref() {49
+ query.add_values(50
+ "e.pubkey",51
+ authors52
+ .iter()53
+ .map(|author| Value::Blob(author.as_bytes().to_vec()))54
+ .collect(),55
+ );56
+ }57
+ if let Some(kinds) = filter.kinds.as_ref() {58
+ query.add_values(59
+ "e.kind",60
+ kinds61
+ .iter()62
+ .map(|kind| Value::Integer(i64::from(kind.as_u16())))63
+ .collect(),64
+ );65
+ }66
+ if let Some(since) = filter.since {67
+ query.add_condition(68
+ "e.created_at >= ?",69
+ Value::Integer(timestamp(since.as_secs())?),70
+ );71
+ }72
+ if let Some(until) = filter.until {73
+ query.add_condition(74
+ "e.created_at <= ?",75
+ Value::Integer(timestamp(until.as_secs())?),76
+ );77
+ }78
+ for (tag, values) in &filter.generic_tags {79
+ if values.is_empty() {80
+ query.exclude_all();81
+ break;82
+ }83
+ query.sql.push_str(TAG_VALUES);84
+ query.parameters.push(Value::Text(tag.to_string()));85
+ query.add_placeholders(values.len());86
+ query.sql.push_str("))");87
+ query88
+ .parameters89
+ .extend(values.iter().cloned().map(Value::Text));90
+ }91
+92
+ query.sql.push_str(" ORDER BY e.created_at DESC, e.id");93
+ Ok(query)94
+ }95
+96
+ fn add_values(&mut self, column: &str, values: Vec<Value>) {97
+ if values.is_empty() {98
+ self.exclude_all();99
+ return;100
+ }101
+ self.sql.push_str(" AND ");102
+ self.sql.push_str(column);103
+ self.sql.push_str(" IN (");104
+ self.add_placeholders(values.len());105
+ self.sql.push(')');106
+ self.parameters.extend(values);107
+ }108
+109
+ fn add_condition(&mut self, condition: &str, value: Value) {110
+ self.sql.push_str(" AND ");111
+ self.sql.push_str(condition);112
+ self.parameters.push(value);113
+ }114
+115
+ fn add_placeholders(&mut self, count: usize) {116
+ for index in 0..count {117
+ if index > 0 {118
+ self.sql.push_str(", ");119
+ }120
+ self.sql.push('?');121
+ }122
+ }123
+124
+ fn exclude_all(&mut self) {125
+ self.sql.push_str(" AND 0");126
+ }127
+}128
+129
+fn timestamp(value: u64) -> Result<i64> {130
+ value.try_into().context("timestamp exceeds sqlite range")131
+}@@ -0,0 +1,319 @@
1
+use std::collections::{BTreeSet, HashMap};2
+use std::net::SocketAddr;3
+use std::sync::Arc;4
+use std::time::Duration;5
+6
+use anyhow::{Context as _, Result};7
+use futures_util::{Sink, SinkExt as _, StreamExt as _};8
+use nostr::event::Event;9
+use nostr::filter::{Filter, MatchEventOptions};10
+use nostr_database::{NostrDatabase as _, RejectedReason, SaveEventStatus};11
+use serde_json::{Value, json};12
+use tokio::io::{AsyncRead, AsyncWrite};13
+use tokio::sync::broadcast;14
+use tokio::time::timeout;15
+use tokio_tungstenite::WebSocketStream;16
+use tokio_tungstenite::tungstenite::{17
+ Error as WebSocketError, Message,18
+ protocol::{Role, WebSocketConfig},19
+};20
+use tokio_util::sync::CancellationToken;21
+22
+use crate::config::Limits;23
+use crate::policy::{Policy, QueryPolicyResult, WritePolicyResult};24
+use crate::store::Store;25
+26
+#[derive(Clone)]27
+pub struct Relay {28
+ store: Arc<Store>,29
+ policy: Policy,30
+ limits: Limits,31
+ events: broadcast::Sender<Event>,32
+ stop: CancellationToken,33
+}34
+35
+struct Subscription {36
+ filters: Vec<Filter>,37
+ bytes: usize,38
+}39
+40
+#[derive(Default)]41
+struct Session {42
+ subscriptions: HashMap<String, Subscription>,43
+}44
+45
+impl Relay {46
+ pub(crate) fn new(47
+ store: Arc<Store>,48
+ policy: Policy,49
+ limits: Limits,50
+ stop: CancellationToken,51
+ ) -> Self {52
+ let (events, _) = broadcast::channel(limits.max_connections.max(32));53
+ Self {54
+ store,55
+ policy,56
+ limits,57
+ events,58
+ stop,59
+ }60
+ }61
+62
+ pub(crate) async fn add_event(&self, event: Event) -> Result<SaveEventStatus> {63
+ event.verify().context("invalid event")?;64
+ let status = self.store.save_event(&event).await?;65
+ if status.is_success() {66
+ match self.events.send(event) {67
+ Ok(_) | Err(_) => {}68
+ }69
+ }70
+ Ok(status)71
+ }72
+73
+ pub(crate) async fn take_connection<S>(&self, stream: S, address: SocketAddr) -> Result<()>74
+ where75
+ S: AsyncRead + AsyncWrite + Unpin,76
+ {77
+ let websocket = WebSocketStream::from_raw_socket(78
+ stream,79
+ Role::Server,80
+ Some(81
+ WebSocketConfig::default()82
+ .max_message_size(Some(self.limits.max_message_bytes))83
+ .max_frame_size(Some(self.limits.max_message_bytes)),84
+ ),85
+ )86
+ .await;87
+ let (mut sink, mut source) = websocket.split();88
+ let mut events = self.events.subscribe();89
+ let mut session = Session::default();90
+91
+ loop {92
+ tokio::select! {93
+ biased;94
+ () = self.stop.cancelled() => return Ok(()),95
+ received = source.next() => {96
+ let Some(message) = received else { return Ok(()); };97
+ match message? {98
+ Message::Text(text) => {99
+ for response in self.handle_text(&mut session, address, &text).await {100
+ self.send(&mut sink, response).await?;101
+ }102
+ }103
+ Message::Ping(payload) => {104
+ self.send_message(&mut sink, Message::Pong(payload)).await?;105
+ }106
+ Message::Close(_) => return Ok(()),107
+ Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {}108
+ }109
+ }110
+ received = events.recv() => {111
+ let event = received.context("live event stream lagged")?;112
+ let ids = session.subscriptions.iter()113
+ .filter(|entry| entry.1.filters.iter().any(|filter| filter.match_event(&event, MatchEventOptions::new())))114
+ .map(|entry| entry.0.clone())115
+ .collect::<Vec<_>>();116
+ for id in ids {117
+ self.send(&mut sink, json!(["EVENT", id, event])).await?;118
+ }119
+ }120
+ }121
+ }122
+ }123
+124
+ async fn send<S>(&self, sink: &mut S, value: Value) -> Result<()>125
+ where126
+ S: Sink<Message, Error = WebSocketError> + Unpin,127
+ {128
+ self.send_message(sink, Message::Text(value.to_string().into()))129
+ .await130
+ }131
+132
+ async fn send_message<S>(&self, sink: &mut S, message: Message) -> Result<()>133
+ where134
+ S: Sink<Message, Error = WebSocketError> + Unpin,135
+ {136
+ timeout(137
+ Duration::from_secs(self.limits.write_timeout_seconds),138
+ sink.send(message),139
+ )140
+ .await141
+ .context("websocket write timed out")??;142
+ Ok(())143
+ }144
+145
+ async fn handle_text(146
+ &self,147
+ session: &mut Session,148
+ address: SocketAddr,149
+ text: &str,150
+ ) -> Vec<Value> {151
+ let Ok(value) = serde_json::from_str::<Value>(text) else {152
+ return vec![json!(["NOTICE", "invalid json"])];153
+ };154
+ let Some(frame) = value.as_array() else {155
+ return vec![json!(["NOTICE", "message must be an array"])];156
+ };157
+ match frame.first().and_then(Value::as_str) {158
+ Some("EVENT") => vec![self.handle_event(frame, address).await],159
+ Some("REQ") => self.handle_req(session, frame, address, text.len()).await,160
+ Some("CLOSE") => {161
+ if let Some(id) = frame.get(1).and_then(Value::as_str) {162
+ session.subscriptions.remove(id);163
+ }164
+ Vec::new()165
+ }166
+ _ => vec![json!(["NOTICE", "unsupported message"])],167
+ }168
+ }169
+170
+ async fn handle_event(&self, frame: &[Value], address: SocketAddr) -> Value {171
+ let Some(value) = frame.get(1) else {172
+ return json!(["NOTICE", "EVENT requires an event"]);173
+ };174
+ let id = value175
+ .get("id")176
+ .and_then(Value::as_str)177
+ .unwrap_or_default()178
+ .to_owned();179
+ let Ok(event) = serde_json::from_value::<Event>(value.clone()) else {180
+ return json!(["OK", id, false, "invalid: malformed event"]);181
+ };182
+ if event.verify().is_err() {183
+ return json!(["OK", event.id, false, "invalid: bad id or signature"]);184
+ }185
+ match self.policy.admit_event(&event, &address).await {186
+ WritePolicyResult::Accept => {}187
+ WritePolicyResult::Respond {188
+ prefix,189
+ message,190
+ status,191
+ } => return json!(["OK", event.id, status, format!("{prefix}: {message}")]),192
+ }193
+ let event_id = event.id;194
+ match self.add_event(event).await {195
+ Ok(SaveEventStatus::Success) => json!(["OK", event_id, true, ""]),196
+ Ok(SaveEventStatus::Rejected(RejectedReason::Duplicate)) => {197
+ json!(["OK", event_id, true, "duplicate: already stored"])198
+ }199
+ Ok(SaveEventStatus::Rejected(reason)) => {200
+ json!(["OK", event_id, false, format!("blocked: {reason:?}")])201
+ }202
+ Err(error) => {203
+ tracing::error!(event = %event_id, %error, "event storage failed");204
+ json!(["OK", event_id, false, "error: database unavailable"])205
+ }206
+ }207
+ }208
+209
+ async fn handle_req(210
+ &self,211
+ session: &mut Session,212
+ frame: &[Value],213
+ address: SocketAddr,214
+ request_bytes: usize,215
+ ) -> Vec<Value> {216
+ let Some(id) = frame.get(1).and_then(Value::as_str) else {217
+ return vec![json!(["NOTICE", "REQ requires a subscription id"])];218
+ };219
+ if id.len() > 64 {220
+ return vec![closed(id, "blocked: subscription id is too long")];221
+ }222
+ if frame.len() < 3 {223
+ return vec![closed(id, "blocked: REQ requires a filter")];224
+ }225
+ if frame.len() - 2 > self.limits.max_filters {226
+ return vec![closed(id, "rate-limited: too many filters")];227
+ }228
+ if session.subscriptions.len() >= self.limits.max_subscriptions229
+ && !session.subscriptions.contains_key(id)230
+ {231
+ return vec![closed(id, "rate-limited: too many subscriptions")];232
+ }233
+ let old_bytes = session.subscriptions.get(id).map_or(0, |entry| entry.bytes);234
+ let active_bytes = session235
+ .subscriptions236
+ .values()237
+ .map(|entry| entry.bytes)238
+ .sum::<usize>()239
+ .saturating_sub(old_bytes);240
+ let max_subscription_bytes = self241
+ .limits242
+ .max_filter_bytes243
+ .saturating_mul(self.limits.max_subscriptions);244
+ if active_bytes.saturating_add(request_bytes) > max_subscription_bytes {245
+ return vec![closed(id, "rate-limited: subscription byte limit exceeded")];246
+ }247
+248
+ let mut filters = Vec::with_capacity(frame.len() - 2);249
+ for value in &frame[2..] {250
+ let Ok(filter) = serde_json::from_value::<Filter>(value.clone()) else {251
+ return vec![closed(id, "blocked: invalid filter")];252
+ };253
+ match self.policy.admit_query(&filter, &address).await {254
+ QueryPolicyResult::Accept => filters.push(filter),255
+ QueryPolicyResult::Reject { prefix, message } => {256
+ return vec![closed(id, &format!("{prefix}: {message}"))];257
+ }258
+ }259
+ }260
+261
+ let mut events = BTreeSet::new();262
+ for filter in &filters {263
+ let implicit = filter.limit.is_none();264
+ let limit = filter.limit.unwrap_or(self.limits.max_query_results);265
+ let mut query = filter.clone();266
+ query.limit = Some(if implicit {267
+ limit.saturating_add(1)268
+ } else {269
+ limit270
+ });271
+ let found = match self.store.query(query).await {272
+ Ok(found) => found,273
+ Err(error) => {274
+ let message = if error.to_string().contains("history byte limit exceeded") {275
+ "rate-limited: history byte limit exceeded"276
+ } else {277
+ tracing::error!(%error, "history query failed");278
+ "error: database unavailable"279
+ };280
+ return vec![closed(id, message)];281
+ }282
+ };283
+ if implicit && found.len() > limit {284
+ return vec![closed(id, "rate-limited: history result limit exceeded")];285
+ }286
+ events.extend(found);287
+ if events.len() > self.limits.max_query_results {288
+ return vec![closed(id, "rate-limited: too many results")];289
+ }290
+ }291
+ let bytes = events292
+ .iter()293
+ .map(|event| event.as_json().len())294
+ .sum::<usize>();295
+ if bytes > self.limits.max_query_bytes {296
+ return vec![closed(id, "rate-limited: history byte limit exceeded")];297
+ }298
+299
+ let mut response = Vec::with_capacity(events.len() + 1);300
+ for event in events {301
+ if !event.is_expired() {302
+ response.push(json!(["EVENT", id, event]));303
+ }304
+ }305
+ response.push(json!(["EOSE", id]));306
+ session.subscriptions.insert(307
+ id.to_owned(),308
+ Subscription {309
+ filters,310
+ bytes: request_bytes,311
+ },312
+ );313
+ response314
+ }315
+}316
+317
+fn closed(id: &str, message: &str) -> Value {318
+ json!(["CLOSED", id, message])319
+}@@ -0,0 +1,299 @@
1
+use std::collections::HashMap;2
+use std::convert::Infallible;3
+use std::net::{IpAddr, SocketAddr};4
+use std::sync::{Arc, Mutex, PoisonError};5
+use std::time::Duration;6
+7
+use anyhow::Context as _;8
+use http_body_util::Full;9
+use hyper::body::{Bytes, Incoming};10
+use hyper::header::{ACCEPT, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, HeaderValue, UPGRADE};11
+use hyper::server::conn::http1::Builder as Http1Builder;12
+use hyper::service::service_fn;13
+use hyper::upgrade::on as upgrade;14
+use hyper::{Method, Request, Response, StatusCode};15
+use hyper_util::rt::{TokioIo, TokioTimer};16
+use nostr_database::NostrDatabase;17
+use serde_json::json;18
+use tokio::net::TcpListener;19
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};20
+use tokio::task::{JoinHandle, spawn as spawn_task};21
+use tokio::time::timeout;22
+use tokio_tungstenite::tungstenite::handshake::server::create_response;23
+use tokio_util::sync::CancellationToken;24
+use tokio_util::task::TaskTracker;25
+26
+use crate::config::Config;27
+use crate::federation::spawn as spawn_federation;28
+use crate::policy::Policy;29
+use crate::relay::Relay;30
+use crate::store::Store;31
+32
+type Body = Full<Bytes>;33
+34
+struct ConnectionLease {35
+ ip: IpAddr,36
+ peers: Arc<Mutex<HashMap<IpAddr, usize>>>,37
+ _permit: OwnedSemaphorePermit,38
+}39
+40
+impl Drop for ConnectionLease {41
+ fn drop(&mut self) {42
+ let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);43
+ if let Some(count) = peers.get_mut(&self.ip) {44
+ *count -= 1;45
+ if *count == 0 {46
+ peers.remove(&self.ip);47
+ }48
+ }49
+ }50
+}51
+52
+#[derive(Clone)]53
+struct HttpState {54
+ relay: Relay,55
+ store: Arc<Store>,56
+ config: Arc<Config>,57
+ websocket_path: String,58
+ tasks: TaskTracker,59
+ stop: CancellationToken,60
+}61
+62
+pub struct RunningRelay {63
+ address: SocketAddr,64
+ path: String,65
+ state: HttpState,66
+ listener: JoinHandle<anyhow::Result<()>>,67
+}68
+69
+impl RunningRelay {70
+ #[must_use]71
+ pub fn url(&self) -> String {72
+ format!("ws://{}{}", self.address, self.path)73
+ }74
+75
+ pub async fn shutdown(self) -> anyhow::Result<()> {76
+ self.state.stop.cancel();77
+ self.listener.await.context("joining listener")??;78
+ self.state.tasks.close();79
+ timeout(Duration::from_secs(30), self.state.tasks.wait())80
+ .await81
+ .context("relay connections did not drain")?;82
+ Ok(())83
+ }84
+}85
+86
+pub async fn spawn(config: Config) -> anyhow::Result<RunningRelay> {87
+ config.validate()?;88
+ let store = Store::open(&config).await?;89
+ let policy_database: Arc<dyn NostrDatabase> = Arc::<Store>::clone(&store);90
+ let policy = Policy::new(policy_database, &config)?;91
+ let listener = TcpListener::bind(config.listen)92
+ .await93
+ .context("binding relay listener")?;94
+ let address = listener.local_addr()?;95
+ let path = config.websocket_path()?;96
+ let stop = CancellationToken::new();97
+ let relay = Relay::new(98
+ Arc::clone(&store),99
+ policy.clone(),100
+ config.limits.clone(),101
+ stop.clone(),102
+ );103
+ let state = HttpState {104
+ relay,105
+ store,106
+ config: Arc::new(config),107
+ websocket_path: path.clone(),108
+ tasks: TaskTracker::new(),109
+ stop,110
+ };111
+ let task_state = state.clone();112
+ spawn_federation(113
+ Arc::clone(&state.config),114
+ Arc::clone(&state.store),115
+ state.relay.clone(),116
+ policy,117
+ &state.tasks,118
+ state.stop.clone(),119
+ );120
+ let listener_task = spawn_task(async move { accept_connections(listener, task_state).await });121
+ tracing::info!(%address, %path, "relay is listening");122
+ Ok(RunningRelay {123
+ address,124
+ path,125
+ state,126
+ listener: listener_task,127
+ })128
+}129
+130
+async fn accept_connections(listener: TcpListener, state: HttpState) -> anyhow::Result<()> {131
+ let slots = Arc::new(Semaphore::new(state.config.limits.max_connections));132
+ let peers = Arc::new(Mutex::new(HashMap::<IpAddr, usize>::new()));133
+ loop {134
+ let (stream, address) = tokio::select! {135
+ biased;136
+ () = state.stop.cancelled() => return Ok(()),137
+ accepted = listener.accept() => accepted?,138
+ };139
+ let Ok(permit) = Arc::clone(&slots).try_acquire_owned() else {140
+ continue;141
+ };142
+ let mut peer_counts = peers.lock().unwrap_or_else(PoisonError::into_inner);143
+ let count = peer_counts.entry(address.ip()).or_default();144
+ if *count >= state.config.limits.max_connections_per_ip {145
+ continue;146
+ }147
+ *count += 1;148
+ drop(peer_counts);149
+ let lease = Arc::new(ConnectionLease {150
+ ip: address.ip(),151
+ peers: Arc::clone(&peers),152
+ _permit: permit,153
+ });154
+ let handler_state = state.clone();155
+ let stop = state.stop.clone();156
+ state.tasks.spawn(async move {157
+ let handler = service_fn(move |request| {158
+ handle_request(159
+ request,160
+ address,161
+ handler_state.clone(),162
+ Arc::clone(&lease),163
+ )164
+ });165
+ let mut builder = Http1Builder::new();166
+ builder.timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(10));167
+ let connection = builder.serve_connection(TokioIo::new(stream), handler).with_upgrades();168
+ tokio::pin!(connection);169
+ tokio::select! {170
+ result = &mut connection => {171
+ if let Err(error) = result { tracing::debug!(%address, %error, "http connection ended"); }172
+ }173
+ () = stop.cancelled() => {}174
+ }175
+ });176
+ }177
+}178
+179
+async fn handle_request(180
+ mut request: Request<Incoming>,181
+ address: SocketAddr,182
+ state: HttpState,183
+ lease: Arc<ConnectionLease>,184
+) -> Result<Response<Body>, Infallible> {185
+ if state.stop.is_cancelled() {186
+ return Ok(response(187
+ StatusCode::SERVICE_UNAVAILABLE,188
+ "text/plain",189
+ "relay is stopping",190
+ ));191
+ }192
+ if request.method() != Method::GET {193
+ return Ok(response(194
+ StatusCode::METHOD_NOT_ALLOWED,195
+ "text/plain",196
+ "GET required",197
+ ));198
+ }199
+ if request.uri().path() == "/healthz" {200
+ let healthy = timeout(Duration::from_secs(2), state.store.healthy())201
+ .await202
+ .unwrap_or(false);203
+ return Ok(response(204
+ if healthy {205
+ StatusCode::OK206
+ } else {207
+ StatusCode::SERVICE_UNAVAILABLE208
+ },209
+ "text/plain",210
+ if healthy {211
+ "ok"212
+ } else {213
+ "database unavailable"214
+ },215
+ ));216
+ }217
+ if request.uri().path() != state.websocket_path {218
+ return Ok(response(StatusCode::NOT_FOUND, "text/plain", "not found"));219
+ }220
+ if !request.headers().contains_key(UPGRADE) {221
+ let wants_information = request222
+ .headers()223
+ .get(ACCEPT)224
+ .and_then(|value| value.to_str().ok())225
+ .is_some_and(|value| {226
+ value.split(',').any(|media_type| {227
+ media_type.trim().split(';').next() == Some("application/nostr+json")228
+ })229
+ });230
+ return Ok(if wants_information {231
+ response(232
+ StatusCode::OK,233
+ "application/nostr+json",234
+ &information(&state.config),235
+ )236
+ } else {237
+ response(238
+ StatusCode::OK,239
+ "text/plain",240
+ "vineyard\na persistent nostr relay for git forges\n",241
+ )242
+ });243
+ }244
+ let upgrade = upgrade(&mut request);245
+ let Ok(handshake) = create_response(&request.map(|_body| ())) else {246
+ return Ok(response(247
+ StatusCode::BAD_REQUEST,248
+ "text/plain",249
+ "invalid WebSocket handshake",250
+ ));251
+ };252
+ let relay = state.relay.clone();253
+ state.tasks.spawn(async move {254
+ let _lease = lease;255
+ if let Ok(Ok(stream)) = timeout(Duration::from_secs(10), upgrade).await {256
+ if let Err(error) = relay.take_connection(TokioIo::new(stream), address).await {257
+ tracing::debug!(%address, %error, "websocket connection ended");258
+ }259
+ } else {260
+ tracing::debug!(%address, "websocket upgrade did not complete");261
+ }262
+ });263
+ Ok(handshake.map(|()| Full::new(Bytes::new())))264
+}265
+266
+fn response(status: StatusCode, content_type: &str, body: &str) -> Response<Body> {267
+ let mut response = Response::new(Full::new(Bytes::copy_from_slice(body.as_bytes())));268
+ *response.status_mut() = status;269
+ if let Ok(value) = HeaderValue::from_str(content_type) {270
+ response.headers_mut().insert(CONTENT_TYPE, value);271
+ }272
+ response273
+ .headers_mut()274
+ .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));275
+ response276
+}277
+278
+fn information(config: &Config) -> String {279
+ json!({280
+ "name": "vineyard",281
+ "description": "a persistent nostr relay for git forges",282
+ "software": "vineyard",283
+ "version": env!("CARGO_PKG_VERSION"),284
+ "supported_nips": [1, 9, 11, 34, 65],285
+ "forge_pubkeys": config.forge_pubkeys,286
+ "limitation": {287
+ "max_message_length": config.limits.max_message_bytes,288
+ "max_event_tags": config.limits.max_tags,289
+ "max_subscriptions": config.limits.max_subscriptions,290
+ "max_filters": config.limits.max_filters,291
+ "max_limit": config.limits.max_query_results,292
+ "max_subid_length": 64,293
+ "auth_required": false,294
+ "payment_required": false,295
+ "restricted_writes": true296
+ }297
+ })298
+ .to_string()299
+}@@ -0,0 +1,244 @@
1
+use std::collections::{BTreeSet, HashSet};2
+use std::fs::{File, OpenOptions, create_dir_all};3
+use std::future::Future;4
+use std::io::Error as IoError;5
+use std::iter::once;6
+use std::path::PathBuf;7
+use std::pin::Pin;8
+use std::sync::Arc;9
+10
+use anyhow::Context as _;11
+use fs2::FileExt as _;12
+use nostr::event::{Event, EventId, Kind};13
+use nostr::filter::Filter;14
+use nostr::nips::nip65::extract_relay_list;15
+use nostr_database::error::Error;16
+use nostr_database::{DatabaseEventStatus, Features, NostrDatabase, SaveEventStatus};17
+use tokio::sync::Mutex;18
+19
+use crate::config::{Config, relay_url};20
+use crate::journal::Journal;21
+22
+type DatabaseFuture<'database, T> =23
+ Pin<Box<dyn Future<Output = Result<T, Error>> + Send + 'database>>;24
+25
+#[derive(Debug)]26
+pub struct Store {27
+ pub(crate) journal: Journal,28
+ path: PathBuf,29
+ min_free_bytes: u64,30
+ max_query_bytes: usize,31
+ max_repositories: usize,32
+ max_discovered_peers: usize,33
+ excluded_peers: HashSet<String>,34
+ writer: Arc<Mutex<()>>,35
+ _lock: File,36
+}37
+38
+impl Store {39
+ pub async fn open(config: &Config) -> anyhow::Result<Arc<Self>> {40
+ if let Some(parent) = config41
+ .database42
+ .parent()43
+ .filter(|parent| !parent.as_os_str().is_empty())44
+ {45
+ create_dir_all(parent).context("creating database directory")?;46
+ }47
+ let mut lock_name = config.database.as_os_str().to_os_string();48
+ lock_name.push(".lock");49
+ let mut lock_options = OpenOptions::new();50
+ lock_options51
+ .read(true)52
+ .write(true)53
+ .create(true)54
+ .truncate(false);55
+ #[cfg(unix)]56
+ {57
+ use std::os::unix::fs::OpenOptionsExt as _;58
+ lock_options.mode(0o600)59
+ };60
+ let lock = lock_options61
+ .open(PathBuf::from(lock_name))62
+ .context("opening database lock")?;63
+ lock.try_lock_exclusive()64
+ .context("database is already in use")?;65
+66
+ let excluded_peers = once(&config.public_url)67
+ .chain(config.peers.iter().map(|peer| &peer.url))68
+ .map(|url| relay_url(url).map(|parsed| parsed.to_string()))69
+ .collect::<Option<HashSet<_>>>()70
+ .context("validated relay url became invalid")?;71
+ let writer = Arc::new(Mutex::new(()));72
+ let journal = Journal::open(73
+ config.database.clone(),74
+ config.limits.max_database_bytes,75
+ config76
+ .limits77
+ .max_discovered_peers78
+ .saturating_add(excluded_peers.len()),79
+ excluded_peers.iter().cloned().collect(),80
+ Arc::clone(&writer),81
+ )82
+ .await?;83
+ journal84
+ .forget_discovered(excluded_peers.iter().cloned().collect())85
+ .await?;86
+ tracing::info!(path = %config.database.display(), "opened event database");87
+ Ok(Arc::new(Self {88
+ journal,89
+ path: config.database.clone(),90
+ min_free_bytes: config.limits.min_free_bytes,91
+ max_query_bytes: config.limits.max_query_bytes,92
+ max_repositories: config.limits.max_repositories,93
+ max_discovered_peers: config.limits.max_discovered_peers,94
+ excluded_peers,95
+ writer,96
+ _lock: lock,97
+ }))98
+ }99
+100
+ pub async fn healthy(&self) -> bool {101
+ self.journal.healthy().await102
+ }103
+104
+ async fn query_events(&self, filter: Filter) -> Result<BTreeSet<Event>, Error> {105
+ self.journal106
+ .query(filter, self.max_query_bytes)107
+ .await108
+ .map_err(storage_error)109
+ }110
+}111
+112
+fn storage_error(error: anyhow::Error) -> Error {113
+ Error::storage(IoError::other(error))114
+}115
+116
+impl NostrDatabase for Store {117
+ fn backend(&self) -> &'static str {118
+ "vineyard-sqlite"119
+ }120
+121
+ fn features(&self) -> Features {122
+ Features {123
+ persistent: true,124
+ event_expiration: false,125
+ full_text_search: false,126
+ request_to_vanish: false,127
+ }128
+ }129
+130
+ fn save_event<'database>(131
+ &'database self,132
+ event: &'database Event,133
+ ) -> DatabaseFuture<'database, SaveEventStatus> {134
+ Box::pin(async move {135
+ let writer = self.writer.lock().await;136
+ if event.kind.as_u16() == 30617 {137
+ if let Some(identifier) = event.tags.identifier() {138
+ let registered = self139
+ .query_events(140
+ Filter::new()141
+ .kind(event.kind)142
+ .author(event.pubkey)143
+ .identifier(identifier)144
+ .limit(1),145
+ )146
+ .await?147
+ .len();148
+ if registered == 0149
+ && self150
+ .query_events(151
+ Filter::new()152
+ .kind(event.kind)153
+ .limit(self.max_repositories + 1),154
+ )155
+ .await?156
+ .len()157
+ >= self.max_repositories158
+ {159
+ return Err(Error::storage(IoError::other(160
+ "repository directory is full",161
+ )));162
+ }163
+ }164
+ }165
+ if self.journal.status(event.id).await.map_err(storage_error)?166
+ != DatabaseEventStatus::Saved167
+ {168
+ let free = fs2::available_space(&self.path).map_err(Error::io)?;169
+ let event_bytes = u64::try_from(event.as_json().len())170
+ .map_err(|error| Error::storage(IoError::other(error.to_string())))?;171
+ if free < self.min_free_bytes.saturating_add(event_bytes) {172
+ return Err(Error::storage(IoError::other("storage reserve reached")));173
+ }174
+ }175
+ let status = self176
+ .journal177
+ .save_locked(event.clone(), &writer)178
+ .await179
+ .map_err(storage_error)?;180
+ if status.is_success() && event.kind == Kind::RelayList {181
+ let urls = extract_relay_list(event)182
+ .map(|(url, _)| url.to_string())183
+ .filter(|url| !self.excluded_peers.contains(url))184
+ .collect();185
+ if let Err(error) = self186
+ .journal187
+ .replace_discovered_locked(188
+ event.pubkey,189
+ urls,190
+ self.max_discovered_peers,191
+ &writer,192
+ )193
+ .await194
+ {195
+ tracing::error!(event = %event.id, %error, "saving discovered peers failed");196
+ }197
+ }198
+ drop(writer);199
+ Ok(status)200
+ })201
+ }202
+203
+ fn check_id<'database>(204
+ &'database self,205
+ event_id: &'database EventId,206
+ ) -> DatabaseFuture<'database, DatabaseEventStatus> {207
+ let requested_id = *event_id;208
+ Box::pin(async move {209
+ self.journal210
+ .status(requested_id)211
+ .await212
+ .map_err(storage_error)213
+ })214
+ }215
+216
+ fn event_by_id<'database>(217
+ &'database self,218
+ event_id: &'database EventId,219
+ ) -> DatabaseFuture<'database, Option<Event>> {220
+ let requested_id = *event_id;221
+ Box::pin(async move {222
+ self.journal223
+ .event(requested_id)224
+ .await225
+ .map_err(storage_error)226
+ })227
+ }228
+229
+ fn count(&self, filter: Filter) -> DatabaseFuture<'_, usize> {230
+ Box::pin(async move { self.journal.count(filter).await.map_err(storage_error) })231
+ }232
+233
+ fn query(&self, filter: Filter) -> DatabaseFuture<'_, BTreeSet<Event>> {234
+ Box::pin(self.query_events(filter))235
+ }236
+237
+ fn delete(&self, _filter: Filter) -> DatabaseFuture<'_, ()> {238
+ Box::pin(async { Err(Error::unsupported("use signed deletion requests")) })239
+ }240
+241
+ fn wipe(&self) -> DatabaseFuture<'_, ()> {242
+ Box::pin(async { Err(Error::unsupported("database wipe is disabled")) })243
+ }244
+}@@ -0,0 +1,25 @@
1
+public_url = "wss://relay.example.org/"2
+listen = "127.0.0.1:7777"3
+database = "vineyard.sqlite"4
+forge_pubkeys = ["79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"]5
+peers = []6
+allow_private_discovery = false7
+8
+[limits]9
+max_repositories = 409610
+max_discovered_peers = 3211
+max_connections = 3212
+max_connections_per_ip = 813
+max_subscriptions = 1614
+max_event_bytes = 209689615
+max_message_bytes = 209715216
+max_tags = 12817
+max_filters = 818
+max_filter_bytes = 3276819
+max_query_results = 50020
+max_query_bytes = 1677721621
+events_per_minute = 12022
+queries_per_minute = 12023
+max_database_bytes = 107374182424
+min_free_bytes = 6710886425
+write_timeout_seconds = 10