mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-24 19:24:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91999819c6 | ||
|
|
ffce1d5d05 | ||
|
|
8e6ace2fe2 | ||
|
|
3018a20843 | ||
|
|
37fc06fb39 | ||
|
|
728a8c4d3b | ||
|
|
3d49e99cc3 | ||
|
|
7e823673ca | ||
|
|
320933598c | ||
|
|
500dc2e0e6 | ||
|
|
a220611a8b | ||
|
|
ca7a4b4b73 | ||
|
|
8ff02610e4 | ||
|
|
22f82489d5 | ||
|
|
cee483e358 | ||
|
|
7f8e858137 | ||
|
|
44d73dac58 | ||
|
|
b8cae15c12 | ||
|
|
9b58961bba | ||
|
|
31ee12ecd3 | ||
|
|
ea106d5246 | ||
|
|
4015217a2e | ||
|
|
7e4c9e1dab | ||
|
|
e9dc57d6f4 | ||
|
|
4e2525b245 | ||
|
|
38508c2c6e | ||
|
|
7a54ccf6a3 | ||
|
|
e85394c314 | ||
|
|
411c6be108 | ||
|
|
28aa7ab7fc | ||
|
|
348b69d76b | ||
|
|
4b23ca2c36 | ||
|
|
a7052e878c | ||
|
|
fc6063dba8 | ||
|
|
44cb016762 | ||
|
|
e6eee18ace | ||
|
|
96c8375706 | ||
|
|
86c5995568 | ||
|
|
036c317fd6 | ||
|
|
4d2c7d4723 | ||
|
|
f24a9f8bc0 | ||
|
|
f6ef6ecbd9 | ||
|
|
a2567bdb1a | ||
|
|
56120d252d | ||
|
|
210b0f463c | ||
|
|
c80f504dc0 | ||
|
|
a3bd7c59ad | ||
|
|
9ebde27540 | ||
|
|
28a7834304 | ||
|
|
191da5b311 | ||
|
|
a5b4ba7d65 | ||
|
|
90dabd8840 | ||
|
|
19eaccea74 | ||
|
|
f620a6bc43 | ||
|
|
04f370aaa1 | ||
|
|
3fd8f7ebbf | ||
|
|
0afcb2ee28 | ||
|
|
8bd556f65b | ||
|
|
cc92594506 | ||
|
|
76f22b801b | ||
|
|
021b7dbec4 | ||
|
|
eb034cb8b3 | ||
|
|
bdaf510898 | ||
|
|
deb2502112 | ||
|
|
1815ddba9f | ||
|
|
a7517ed5c9 | ||
|
|
d07d702dd4 | ||
|
|
1901b0a630 | ||
|
|
58f8d327c1 | ||
|
|
a13343e1de | ||
|
|
60e10af4aa | ||
|
|
f176f55862 | ||
|
|
fac9520a16 | ||
|
|
eee6fb9ff4 | ||
|
|
2462d0ab3b | ||
|
|
85f87f7b28 | ||
|
|
b1d86caa78 | ||
|
|
9b2cfb3a7e | ||
|
|
efd499bb26 | ||
|
|
d4dfe838eb | ||
|
|
af3821514f | ||
|
|
8f7aad3756 | ||
|
|
75b9e3165f | ||
|
|
ccc9916109 | ||
|
|
bc8fd66a45 | ||
|
|
180de29984 | ||
|
|
0455dc20bd | ||
|
|
2770b9b14a | ||
|
|
d436597184 | ||
|
|
4cfca6d956 | ||
|
|
98b94fb170 | ||
|
|
4fe01cff72 | ||
|
|
00c9eb732f | ||
|
|
6d2d3b4be4 | ||
|
|
9d81e58803 | ||
|
|
2afd075496 | ||
|
|
c74bf967cf | ||
|
|
1285d5c675 | ||
|
|
c30d73cd4d | ||
|
|
0fab8f2eb9 | ||
|
|
57336fa124 | ||
|
|
ace105899f | ||
|
|
c80a525e0d | ||
|
|
7edb76507b | ||
|
|
a20e4e05e6 | ||
|
|
3e7a199736 | ||
|
|
7ed9c8ce59 | ||
|
|
b0c3402a82 | ||
|
|
b9574f3ec1 | ||
|
|
baef2be844 | ||
|
|
1f21b85069 | ||
|
|
09d437b3e3 | ||
|
|
7f2e83e92f | ||
|
|
ec273fa814 |
@@ -25,6 +25,28 @@ fail-fast = false
|
|||||||
test-threads = "num-cpus"
|
test-threads = "num-cpus"
|
||||||
slow-timeout = { period = "300s", terminate-after = 2 }
|
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||||
|
|
||||||
|
[profile.gauntlet-pr]
|
||||||
|
retries = 0
|
||||||
|
fail-fast = true
|
||||||
|
test-threads = "num-cpus"
|
||||||
|
slow-timeout = { period = "60s", terminate-after = 5 }
|
||||||
|
|
||||||
|
[[profile.gauntlet-pr.overrides]]
|
||||||
|
filter = "binary(gauntlet_smoke)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 2 }
|
||||||
|
|
||||||
|
[profile.gauntlet-nightly]
|
||||||
|
retries = 0
|
||||||
|
fail-fast = false
|
||||||
|
test-threads = "num-cpus"
|
||||||
|
slow-timeout = { period = "600s", terminate-after = 1 }
|
||||||
|
|
||||||
|
[profile.gauntlet-soak]
|
||||||
|
retries = 0
|
||||||
|
fail-fast = false
|
||||||
|
test-threads = 1
|
||||||
|
slow-timeout = { period = "5m", terminate-after = 1000 }
|
||||||
|
|
||||||
[test-groups]
|
[test-groups]
|
||||||
serial-env-tests = { max-threads = 1 }
|
serial-env-tests = { max-threads = 1 }
|
||||||
heavy-load-tests = { max-threads = 4 }
|
heavy-load-tests = { max-threads = 4 }
|
||||||
@@ -46,6 +68,10 @@ test-group = "serial-env-tests"
|
|||||||
filter = "package(tranquil-signal)"
|
filter = "package(tranquil-signal)"
|
||||||
test-group = "serial-env-tests"
|
test-group = "serial-env-tests"
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = "package(tranquil-config)"
|
||||||
|
test-group = "serial-env-tests"
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = "binary(whole_story)"
|
filter = "binary(whole_story)"
|
||||||
test-group = "heavy-load-tests"
|
test-group = "heavy-load-tests"
|
||||||
@@ -72,10 +98,24 @@ test-group = "io-heavy-sim"
|
|||||||
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
|
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
|
||||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = "binary(gauntlet_smoke)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 8 }
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
|
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
|
||||||
slow-timeout = { period = "120s", terminate-after = 4 }
|
slow-timeout = { period = "120s", terminate-after = 4 }
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||||
|
test-group = "io-heavy-sim"
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = "binary(fd_lifecycle)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||||
|
test-group = "io-heavy-sim"
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||||
test-group = "serial-env-tests"
|
test-group = "serial-env-tests"
|
||||||
@@ -92,6 +132,10 @@ test-group = "serial-env-tests"
|
|||||||
filter = "package(tranquil-signal)"
|
filter = "package(tranquil-signal)"
|
||||||
test-group = "serial-env-tests"
|
test-group = "serial-env-tests"
|
||||||
|
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = "package(tranquil-config)"
|
||||||
|
test-group = "serial-env-tests"
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = "binary(whole_story)"
|
filter = "binary(whole_story)"
|
||||||
test-group = "heavy-load-tests"
|
test-group = "heavy-load-tests"
|
||||||
@@ -103,3 +147,13 @@ test-group = "heavy-load-tests"
|
|||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = "binary(repo_lifecycle)"
|
filter = "binary(repo_lifecycle)"
|
||||||
test-group = "heavy-load-tests"
|
test-group = "heavy-load-tests"
|
||||||
|
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = "test(/retention_time_travel_survives_many_seeds/)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 24 }
|
||||||
|
test-group = "io-heavy-sim"
|
||||||
|
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = "binary(fd_lifecycle)"
|
||||||
|
slow-timeout = { period = "300s", terminate-after = 4 }
|
||||||
|
test-group = "io-heavy-sim"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.jj/
|
||||||
|
**/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/coverage/
|
||||||
|
frontend/.pnpm-store/
|
||||||
|
.direnv/
|
||||||
|
result
|
||||||
|
.env
|
||||||
|
*.output
|
||||||
|
reference-pds-bsky/
|
||||||
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
/target
|
target/
|
||||||
.env
|
.env
|
||||||
.direnv
|
.direnv
|
||||||
result
|
result
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/coverage/
|
||||||
|
frontend/.pnpm-store
|
||||||
|
frontend/.npmrc
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT nextval('firehose_seq') as \"seq!\"",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "seq!",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "09142b5a6b91fe4fd795fb6c85af5461e2621f6c3545b6d26e4618d813904b75"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE repo_seq r\n SET seq = $1::bigint + (o.ord - 1)\n FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)\n WHERE r.id = o.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "0b895d105363a60040323c5c47bc897905af3da980d0dc1b3b60bbdcdd90ea7a"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM repo_seq\n WHERE did = $1\n AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "122d79e006c0e11214e3cf11cfb98bdff6fbf156893b482572532ebcd8979e61"
|
||||||
|
}
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE did = $1",
|
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE handle = $1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -42,6 +42,11 @@
|
|||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "is_admin",
|
"name": "is_admin",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "inbound_migration",
|
||||||
|
"type_info": "Bool"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -57,8 +62,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "6b51995c40519a63f85c70f29ca8bd6ec1963c8562d78215d980785dc46a6384"
|
"hash": "18bbda5582db1b32d02ab8a3eee970c9508b9bd67239c2f936639a9f863b30ff"
|
||||||
}
|
}
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE users SET deactivated_at = NULL WHERE did = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "23201d4e26bc650939e30f69fb0bca00d351d057098afebc1017f70a84b4bd22"
|
|
||||||
}
|
|
||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING seq\n ",
|
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', false, 'deleted')\n RETURNING id\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "a0ea3630adb9e411d94d88084df6b05d7fc3ba8c306146fc932b736fe342eceb"
|
"hash": "2c3c0740793f29b9bd9e0f81c2372d427dee091e20da8f71ae1e5f2769aa985d"
|
||||||
}
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT pg_try_advisory_xact_lock($1) as \"locked!\"",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "locked!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3cc7247d2cd7e85a6f21d783dd956f8fecc6f63c994014bb4e91504d498b5fd5"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4f3ef746cb5fc0916d3df5f92494c538497d3b9c1cfcc6e44c9b80f2193094f6"
|
||||||
|
}
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, handle)\n VALUES ($1, 'identity', $2)\n RETURNING seq\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "seq",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "50b2178cd174af1dc132a22048e52a6af492f4d8025ec512dd9c0c9ba62eb202"
|
|
||||||
}
|
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "51e029e572777e6a103fd7fd5550494de9d4cac7e3ff84e27ddec1a6aaefc047"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE comms_queue\n SET status = 'failed'::comms_status,\n attempts = max_attempts,\n last_error = $2,\n updated_at = NOW()\n WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5bee4ed5296667e4ca7e1a97aec28d30a470b8aee7b378ec9ca4e34de4faf349"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id as \"id!\" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id!",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "714a1c9a73e2fed8182d4ab2fe4a2c33bc10a52e27ed95d3a6641477ae42395e"
|
||||||
|
}
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "seq!",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -108,5 +108,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772"
|
"hash": "7cdf3b8136782a5b2e4aff91907a680566b4610e6cbf9d9396d31f9bbd99c3b8"
|
||||||
}
|
}
|
||||||
+4
-12
@@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
|
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [],
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "seq",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
@@ -21,9 +15,7 @@
|
|||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": []
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d"
|
"hash": "800b93a38eef70bafdf0a1b221994f3c19ab562f84d18265376296a592b4c767"
|
||||||
}
|
}
|
||||||
+4
-12
@@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ",
|
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [],
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "seq",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
@@ -23,9 +17,7 @@
|
|||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": []
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e"
|
"hash": "82276a34f473ff8b9790b78915ae3f72873d4b748cdb47bd2b81edc2f7a6e5a8"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
"query": "DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "b6d6548acb89d6384cd226f6ed0d66de27fde3af24b4a7a3fce7e098812e38a5"
|
"hash": "856e458c1c78f030545dc147f76d1b91fa4cc6e178eb51133c52358855ba3f02"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
"query": "UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE attempts < max_attempts\n AND scheduled_for <= $1\n AND (\n status = 'pending'\n OR (status = 'processing'\n AND updated_at < $1 - INTERVAL '10 minutes')\n )\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -154,5 +154,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a"
|
"hash": "890aa92acdcb0fe2a3bf04d87e1f16a801d271da7cedc32fc42c2ef5b100faae"
|
||||||
}
|
}
|
||||||
+4
-12
@@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n RETURNING seq\n ",
|
"query": "\n INSERT INTO repo_seq (did, event_type, active, status)\n VALUES ($1, 'account', $2, $3)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [],
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "seq",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
@@ -16,9 +10,7 @@
|
|||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": []
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hash": "623042c0b901e17ff37c5bcc1a4eefe56fc8b996ca8326e75796897d13c9e9ed"
|
"hash": "8ccd42b5b1b75f45e094889ee26af8517de28ca687efa81fd9c4fce598338fe9"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY updated_at ASC\n OFFSET $2\n )\n ",
|
"query": "\n DELETE FROM oauth_token\n WHERE id IN (\n SELECT id FROM oauth_token\n WHERE did = $1\n ORDER BY created_at DESC\n OFFSET $2\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "56cd24903171eddc2ededd9079ffe10937c34e99b0305f25c980ca754da44625"
|
"hash": "8f4357f7a18ddcf6b686a4555f244d37c35917364b8f917ca6ee2d4030ace742"
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "seq!",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -108,5 +108,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426"
|
"hash": "957bc81c63510ab502d2f8cea75dafcbdf2c8ebb448c86990d5e404ee5280438"
|
||||||
}
|
}
|
||||||
+4
-12
@@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ",
|
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [],
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "seq",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Text",
|
"Text",
|
||||||
@@ -18,9 +12,7 @@
|
|||||||
"ByteaArray"
|
"ByteaArray"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": []
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b"
|
"hash": "9ba7869f29286cfd6b811ffa1e26bda1d637b98b44be66cdc265b1af4cec2d4f"
|
||||||
}
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT pg_advisory_xact_lock($1)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "pg_advisory_xact_lock",
|
||||||
|
"type_info": "Void"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT setval('firehose_seq', $1)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "setval",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bd0407818c8bdae1f1024777951b08cb9c386fe751722e1caf6ad243e271697a"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "NOTIFY repo_updates",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d4eacb9bca838dd18dcfd8ca39b83ef50968bfaaabc213536c049cc222ffe9c8"
|
||||||
|
}
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "seq!",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -109,5 +109,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a"
|
"hash": "da2770e44c0ec21756ce4b6b0c57abff4f38cd8c680c5ced7d3b35f3c1f58da7"
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "seq!",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -109,5 +109,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57"
|
"hash": "dfe011b2d2bafbbbbea508a4977fc34bb1be4cf2612296df23763e6e5841c33e"
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
|
"query": "SELECT seq as \"seq!\", did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "seq",
|
"name": "seq!",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -109,5 +109,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256"
|
"hash": "f16d41b41a5388997e6ed408da55056173340ed12b60b1a956de87f73f9d0824"
|
||||||
}
|
}
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin\n FROM users WHERE handle = $1",
|
"query": "SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration\n FROM users WHERE did = $1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -42,6 +42,11 @@
|
|||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"name": "is_admin",
|
"name": "is_admin",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "inbound_migration",
|
||||||
|
"type_info": "Bool"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -57,8 +62,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "584bceda60d8b6a02e7dc44d833e3fba13151f36ba9f75c64e33d6cb534cc939"
|
"hash": "f1c4ec28b02d09ffce35aa8249c1747a70c12a3ecfc1ff6ca1847840f770db2f"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
when:
|
||||||
|
- event: ["push", "manual"]
|
||||||
|
branch: ["main"]
|
||||||
|
|
||||||
|
engine: nixery
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
nixpkgs:
|
||||||
|
- nix
|
||||||
|
- cachix
|
||||||
|
- gnugrep
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Authenticate
|
||||||
|
command: |
|
||||||
|
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||||
|
|
||||||
|
- name: Build and push aarch64
|
||||||
|
command: |
|
||||||
|
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||||
|
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds-aarch64 $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
when:
|
||||||
|
- event: ["push", "manual"]
|
||||||
|
branch: ["main"]
|
||||||
|
|
||||||
|
engine: nixery
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
nixpkgs:
|
||||||
|
- nix
|
||||||
|
- cachix
|
||||||
|
- gnugrep
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Authenticate
|
||||||
|
command: |
|
||||||
|
cachix authtoken "$CACHIX_AUTH_TOKEN"
|
||||||
|
|
||||||
|
- name: Build and push x86_64
|
||||||
|
command: |
|
||||||
|
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||||
|
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-pds $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||||
|
nix-store -qR --include-outputs $(nix-store -qd $(nix build .#packages.x86_64-linux.tranquil-frontend $SUBS --print-out-paths --no-link)) | grep -v '\.drv$' | cachix push tranquil
|
||||||
|
|
||||||
|
- name: Build and push devShell
|
||||||
|
command: |
|
||||||
|
SUBS="--option extra-substituters https://tranquil.cachix.org --option extra-trusted-public-keys tranquil.cachix.org-1:PoO+mGL6a6LcJiPakMDHN4E218/ei/7v2sxeDtNkSRg="
|
||||||
|
nix develop $SUBS --profile dev-profile -c true
|
||||||
|
cachix push tranquil dev-profile
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
when:
|
when:
|
||||||
- event: []
|
- event: [ "manual" ]
|
||||||
branch: []
|
- event: [ "push" ]
|
||||||
|
branch: [ "main" ]
|
||||||
|
|
||||||
engine: nixery
|
engine: nixery
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
nixpkgs:
|
nixpkgs:
|
||||||
- podman
|
- kaniko
|
||||||
|
|
||||||
|
environment:
|
||||||
|
DOCKER_CONFIG: "/kaniko/.docker"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Create podman config
|
- name: Configure Kaniko
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/.config/containers
|
mkdir -p /kaniko/.docker/
|
||||||
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
|
echo "{
|
||||||
|
\"auths\": {
|
||||||
|
\"https://atcr.io/v1\":{
|
||||||
|
\"auth\": \"$ATCR_CREDENTIALS\"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" > /kaniko/.docker/config.json
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
command: |
|
command: |
|
||||||
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
|
executor \
|
||||||
|
--context=$(pwd) \
|
||||||
- name: Publish image
|
--ignore-path=$(pwd) \
|
||||||
command: |
|
--dockerfile=$(pwd)/Dockerfile \
|
||||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
|
--destination="atcr.io/tranquil.farm/tranquil-pds:latest" \
|
||||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
|
--destination="atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA" \
|
||||||
|
--push-retry=3 \
|
||||||
|
--skip-push-permission-check
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# Contributing to Tranquil PDS
|
||||||
|
|
||||||
|
## When PRing
|
||||||
|
|
||||||
|
In order of importance:
|
||||||
|
|
||||||
|
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
|
||||||
|
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
|
||||||
|
- Run cargo fmt :P
|
||||||
|
|
||||||
|
> 🦪 Lewis
|
||||||
|
>
|
||||||
|
> Good CI fixes some of these. We should really get around to that.
|
||||||
|
|
||||||
|
Things that would also be nice but aren't like, a pain in our side:
|
||||||
|
|
||||||
|
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||||
|
- Add `pds.test` to your hosts file (one-time setup):
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 pds.test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **macOS / Linux:** `/etc/hosts`
|
||||||
|
- **Windows:** `C:\Windows\System32\drivers\etc\hosts`
|
||||||
|
|
||||||
|
### Starting the dev environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just run-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the following services via `docker-compose`:
|
||||||
|
|
||||||
|
- **Traefik** — HTTPS reverse proxy at `https://pds.test`
|
||||||
|
- **Backend** — Rust server with `cargo-watch` (auto-rebuilds on file changes)
|
||||||
|
- **Frontend** — Vite dev server with hot module replacement
|
||||||
|
- **Postgres** — Database on port 5432
|
||||||
|
- **PLC Directory** — Local [did-method-plc](https://github.com/did-method-plc/did-method-plc) server for DID registration
|
||||||
|
- **Mailpit** — Local email server with web UI at [http://localhost:8025](http://localhost:8025)
|
||||||
|
|
||||||
|
Once all services are running, open **https://pds.test** in your browser.
|
||||||
|
|
||||||
|
### Trusting the self-signed certificate
|
||||||
|
|
||||||
|
Traefik generates a self-signed TLS certificate. Your browser will show a security warning on first visit. You can either click through it, or add the certificate to your system trust store for a seamless experience:
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract the cert from traefik and add it to the system keychain
|
||||||
|
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 > /tmp/pds-test.pem
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/pds-test.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Debian/Ubuntu):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /usr/local/share/ca-certificates/pds-test.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Fedora/RHEL):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect localhost:443 -servername pds.test 2>/dev/null | openssl x509 | sudo tee /etc/pki/ca-trust/source/anchors/pds-test.pem
|
||||||
|
sudo update-ca-trust
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows (PowerShell as Administrator):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||||
|
$cert.Import([System.Text.Encoding]::UTF8.GetBytes((echo | openssl s_client -connect localhost:443 -servername pds.test 2>$null | openssl x509)))
|
||||||
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine")
|
||||||
|
$store.Open("ReadWrite")
|
||||||
|
$store.Add($cert)
|
||||||
|
$store.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart your browser after adding the certificate.
|
||||||
|
|
||||||
|
### Stopping the dev environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop containers (preserves database + build cache)
|
||||||
|
docker compose --profile dev down
|
||||||
|
|
||||||
|
# Stop and wipe all data (fresh start)
|
||||||
|
docker compose --profile dev down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct database access
|
||||||
|
|
||||||
|
Postgres is exposed on port 5432:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql postgres://postgres:postgres@localhost:5432/pds
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- **Source code** is bind-mounted into the containers so that changes made on the host will be immediately reflected in the application
|
||||||
|
- **Backend** uses `cargo-watch` to recompile and restart when Rust files change
|
||||||
|
- **Frontend** uses Vite's HMR for instant browser updates when frontend files change
|
||||||
|
- **Build cache** (`target/` directory and cargo registry) are stored in Docker volumes, so incremental compilation persists across container restarts
|
||||||
|
- **Traefik** routes `/`, `/xrpc`, `/oauth`, `/.well-known`, `/u`, and `/health` to the backend; everything else goes to the Vite dev server
|
||||||
|
- **Mailpit** captures all outgoing email — open [http://localhost:8025](http://localhost:8025) to view verification emails during registration
|
||||||
|
- **PLC Directory** runs locally so DID registration doesn't hit the real `plc.directory`
|
||||||
|
|
||||||
|
### Running the backend natively
|
||||||
|
|
||||||
|
If you prefer running the Rust backend outside Docker (faster incremental builds on host), you need:
|
||||||
|
|
||||||
|
- Rust toolchain (see `rust-toolchain.toml`)
|
||||||
|
- `protoc` (`brew install protobuf` on macOS)
|
||||||
|
- PostgreSQL (start with `docker compose up db`)
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p tranquil-server -- --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
And start the frontend separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install && pnpm dev
|
||||||
|
```
|
||||||
Generated
+179
-33
@@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
|
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"abnf-core",
|
"abnf-core",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -18,7 +18,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
|
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -213,7 +213,7 @@ dependencies = [
|
|||||||
"asn1-rs-derive",
|
"asn1-rs-derive",
|
||||||
"asn1-rs-impl",
|
"asn1-rs-impl",
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1558,7 +1558,7 @@ checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"confique-macro",
|
"confique-macro",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1972,7 +1972,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs",
|
"asn1-rs",
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
@@ -2216,6 +2216,22 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-encoding"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -3780,6 +3796,37 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lettre"
|
||||||
|
version = "0.11.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"email-encoding",
|
||||||
|
"email_address",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"httpdate",
|
||||||
|
"idna",
|
||||||
|
"mime",
|
||||||
|
"nom 8.0.0",
|
||||||
|
"percent-encoding",
|
||||||
|
"quoted_printable",
|
||||||
|
"rsa",
|
||||||
|
"rustls 0.23.37",
|
||||||
|
"sha2",
|
||||||
|
"socket2 0.6.3",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls 0.26.4",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.183"
|
||||||
@@ -4409,6 +4456,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nonzero_ext"
|
name = "nonzero_ext"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -4839,7 +4895,7 @@ checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
"fnv",
|
"fnv",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"postcard",
|
"postcard",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
@@ -5050,7 +5106,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toml_edit",
|
"toml_edit 0.25.5+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5427,6 +5483,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
@@ -5833,7 +5895,7 @@ version = "4.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6050,6 +6112,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secrecy"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -6201,6 +6273,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -7144,6 +7225,18 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned 0.6.9",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.12+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
@@ -7152,13 +7245,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned 1.0.4",
|
||||||
"toml_datetime 0.7.5+spec-1.1.0",
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow 0.7.15",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.7.5+spec-1.1.0"
|
version = "0.7.5+spec-1.1.0"
|
||||||
@@ -7177,6 +7279,20 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned 0.6.9",
|
||||||
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.5+spec-1.1.0"
|
version = "0.25.5+spec-1.1.0"
|
||||||
@@ -7198,6 +7314,12 @@ dependencies = [
|
|||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.0.7+spec-1.1.0"
|
version = "1.0.7+spec-1.1.0"
|
||||||
@@ -7405,7 +7527,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-api"
|
name = "tranquil-api"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -7456,7 +7578,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-auth"
|
name = "tranquil-auth"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base32",
|
"base32",
|
||||||
@@ -7479,7 +7601,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-cache"
|
name = "tranquil-cache"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7493,11 +7615,19 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-comms"
|
name = "tranquil-comms"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
|
"ed25519-dalek",
|
||||||
|
"futures",
|
||||||
|
"hickory-resolver",
|
||||||
|
"lettre",
|
||||||
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rsa",
|
||||||
|
"secrecy",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -7511,7 +7641,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-config"
|
name = "tranquil-config"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"confique",
|
"confique",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7519,7 +7649,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-crypto"
|
name = "tranquil-crypto"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7535,7 +7665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-db"
|
name = "tranquil-db"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -7552,7 +7682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-db-traits"
|
name = "tranquil-db-traits"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7568,7 +7698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-infra"
|
name = "tranquil-infra"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7579,9 +7709,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-lexicon"
|
name = "tranquil-lexicon"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"futures",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -7597,7 +7728,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-oauth"
|
name = "tranquil-oauth"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -7620,7 +7751,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-oauth-server"
|
name = "tranquil-oauth-server"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7653,7 +7784,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-pds"
|
name = "tranquil-pds"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -7693,6 +7824,7 @@ dependencies = [
|
|||||||
"multibase",
|
"multibase",
|
||||||
"multihash",
|
"multihash",
|
||||||
"p256 0.13.2",
|
"p256 0.13.2",
|
||||||
|
"parking_lot",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"redis",
|
"redis",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -7745,7 +7877,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-repo"
|
name = "tranquil-repo"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cid",
|
"cid",
|
||||||
@@ -7757,7 +7889,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-ripple"
|
name = "tranquil-ripple"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"backon",
|
"backon",
|
||||||
@@ -7782,7 +7914,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-scopes"
|
name = "tranquil-scopes"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -7798,15 +7930,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-server"
|
name = "tranquil-server"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hyper 1.8.1",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls 0.23.37",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls 0.26.4",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tranquil-api",
|
"tranquil-api",
|
||||||
@@ -7819,7 +7960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-signal"
|
name = "tranquil-signal"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -7842,7 +7983,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-storage"
|
name = "tranquil-storage"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -7859,12 +8000,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-store"
|
name = "tranquil-store"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cid",
|
"cid",
|
||||||
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"fjall",
|
"fjall",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
@@ -7892,6 +8034,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tikv-jemallocator",
|
"tikv-jemallocator",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml 0.8.23",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tranquil-db",
|
"tranquil-db",
|
||||||
@@ -7906,7 +8049,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-sync"
|
name = "tranquil-sync"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -7928,7 +8071,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-types"
|
name = "tranquil-types"
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"cid",
|
"cid",
|
||||||
@@ -8443,7 +8586,7 @@ dependencies = [
|
|||||||
"base64urlsafedata",
|
"base64urlsafedata",
|
||||||
"der-parser",
|
"der-parser",
|
||||||
"hex",
|
"hex",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"openssl",
|
"openssl",
|
||||||
"openssl-sys",
|
"openssl-sys",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
@@ -8853,6 +8996,9 @@ name = "winnow"
|
|||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
@@ -9012,7 +9158,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"der-parser",
|
"der-parser",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"oid-registry",
|
"oid-registry",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|||||||
+11
-2
@@ -26,7 +26,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.4"
|
version = "0.6.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
||||||
@@ -59,6 +59,7 @@ presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844a
|
|||||||
unicode-segmentation = "1"
|
unicode-segmentation = "1"
|
||||||
|
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
|
arc-swap = "1"
|
||||||
backon = "1"
|
backon = "1"
|
||||||
bincode = { version = "2", features = ["serde"] }
|
bincode = { version = "2", features = ["serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
@@ -86,6 +87,8 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
|||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
http = "1.4"
|
http = "1.4"
|
||||||
|
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||||
qrcodegen = "1.8"
|
qrcodegen = "1.8"
|
||||||
infer = "0.19"
|
infer = "0.19"
|
||||||
@@ -93,6 +96,7 @@ ipld-core = "0.4"
|
|||||||
iroh-car = "0.5"
|
iroh-car = "0.5"
|
||||||
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
|
jacquard-common = { version = "0.9", features = ["crypto-k256"] }
|
||||||
jacquard-repo = "0.9"
|
jacquard-repo = "0.9"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "pool", "dkim", "tracing"] }
|
||||||
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
||||||
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
|
k256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] }
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
@@ -105,6 +109,10 @@ p384 = { version = "0.13", features = ["ecdsa"] }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
rsa = "0.9"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
|
rustls-pemfile = "2"
|
||||||
|
secrecy = { version = "0.10", features = ["serde"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "http2", "charset", "macos-system-configuration"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_bytes = "0.11"
|
serde_bytes = "0.11"
|
||||||
@@ -116,8 +124,9 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
|
|||||||
subtle = "2.5"
|
subtle = "2.5"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
|
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process", "io-util", "fs"] }
|
||||||
tokio-util = "0.7.18"
|
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||||
|
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||||
totp-rs = { version = "5", features = ["qr"] }
|
totp-rs = { version = "5", features = ["qr"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||||
|
|||||||
+5
-7
@@ -1,12 +1,12 @@
|
|||||||
FROM node:24-alpine AS frontend
|
FROM node:24-alpine AS frontend
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM rust:1.92-alpine AS builder
|
FROM rust:1.96-alpine AS builder
|
||||||
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
|
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
|
||||||
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -35,7 +35,7 @@ COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
|
|||||||
COPY crates/tranquil-store ./crates/tranquil-store
|
COPY crates/tranquil-store ./crates/tranquil-store
|
||||||
COPY crates/tranquil-signal ./crates/tranquil-signal
|
COPY crates/tranquil-signal ./crates/tranquil-signal
|
||||||
COPY crates/tranquil-server ./crates/tranquil-server
|
COPY crates/tranquil-server ./crates/tranquil-server
|
||||||
COPY migrations ./crates/tranquil-pds/migrations
|
COPY migrations ./migrations
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/app/target \
|
--mount=type=cache,target=/app/target \
|
||||||
if [ "$SLIM" = "true" ]; then \
|
if [ "$SLIM" = "true" ]; then \
|
||||||
@@ -46,13 +46,11 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
cp target/release/tranquil-server /tmp/tranquil-pds
|
cp target/release/tranquil-server /tmp/tranquil-pds
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
RUN apk add --no-cache msmtp ca-certificates \
|
RUN apk add --no-cache ca-certificates
|
||||||
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
|
|
||||||
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
|
||||||
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
|
||||||
COPY migrations /app/migrations
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV SERVER_HOST=0.0.0.0
|
ENV SERVER_HOST=[::]
|
||||||
ENV SERVER_PORT=3000
|
ENV SERVER_PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["tranquil-pds"]
|
CMD ["tranquil-pds"]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM rust:1.96-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
musl-dev \
|
||||||
|
pkgconfig \
|
||||||
|
openssl-dev \
|
||||||
|
openssl-libs-static \
|
||||||
|
mold \
|
||||||
|
clang \
|
||||||
|
protoc
|
||||||
|
|
||||||
|
RUN cargo install cargo-watch
|
||||||
|
|
||||||
|
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["cargo", "watch", "-x", "run -p tranquil-server -- --config /app/config.dev.toml"]
|
||||||
@@ -6,7 +6,7 @@ A Personal Data Server for the AT Protocol.
|
|||||||
|
|
||||||
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
|
We came together to make this PDS to enable and empower our users to better host their data on this shared protocol. All of our decisions as a project are guided by their usefulness to the community: PDS hosters and end-users both.
|
||||||
|
|
||||||
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselvess first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
|
Comparatively: Bluesky the company created a "reference PDS" that we can self-host quite easily, and that's great, but Bluesky has an incentive to make software for themselves first & foremost, then secondly their software can be useful for us self-hosters. In contrast, Tranquil is not from a company, and will never be.
|
||||||
|
|
||||||
## What's different about Tranquil PDS
|
## What's different about Tranquil PDS
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ It is a superset of the reference PDS, including:
|
|||||||
- account delegation: letting others manage an account with configurable permission levels
|
- account delegation: letting others manage an account with configurable permission levels
|
||||||
- a built-in web UI for account management, repo browsing, and admin
|
- a built-in web UI for account management, repo browsing, and admin
|
||||||
|
|
||||||
Unlike the ref PDS, Tranquil itself is compiled to a single binary with no nodeJS runtime. However, at time of writing, Tranquil requires postgres running separately.
|
Unlike the ref PDS, Tranquil is a single binary with no nodejs runtime. That said, at time of writing, Tranquil does require postgres running separately.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -46,22 +46,28 @@ just test
|
|||||||
just lint
|
just lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nix users can enter a devshell with `nix develop`, or `direnv allow` to auto-enter via the bundled `.envrc`. Pre-built artifacts including the devshell are available from our [binary cache](docs/2_INSTALL_NIX.md#binary-cache).
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### Quick Deploy (Docker/Podman Compose)
|
### Quick Deploy (Docker/Podman Compose)
|
||||||
|
|
||||||
Edit `config.toml` with your values. Generate secrets with `openssl rand -base64 48`.
|
`docker-compose.prod.yaml` pulls the prebuilt image `atcr.io/tranquil.farm/tranquil-pds:latest`. Sign in to the registry first with `podman login atcr.io`. The Containers guide covers building from source.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp example.toml config.toml
|
cp example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `config.toml` with your values and generate secrets with `openssl rand -base64 48`. Set the postgres password to match `docker-compose.prod.yaml`. nginx needs a TLS certificate before it starts, so follow the wildcard cert steps in the [Containers guide](docs/2_INSTALL_CONTAINERS.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
podman-compose -f docker-compose.prod.yaml up -d
|
podman-compose -f docker-compose.prod.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installation Guides
|
### Installation Guides
|
||||||
|
|
||||||
- [Debian](docs/install-debian.md)
|
- [Nix](docs/2_INSTALL_NIX.md)
|
||||||
- [Containers](docs/install-containers.md)
|
- [Containers](docs/2_INSTALL_CONTAINERS.md)
|
||||||
- [Kubernetes](docs/install-kubernetes.md)
|
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
@@ -74,7 +80,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
|||||||
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
|
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
|
||||||
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
|
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
|
||||||
|
|
||||||
### Amazing contributers
|
### Amazing contributors
|
||||||
|
|
||||||
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
|
- [@isabelroses.com](https://tangled.org/did:plc:qxichs7jsycphrsmbujwqbfb)
|
||||||
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
|
- [@quilling.dev](https://tangled.org/did:plc:jrtgsidnmxaen4offglr5lsh)
|
||||||
@@ -85,6 +91,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
|
|||||||
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
|
- [@a.starrysky.fyi](https://tangled.org/did:plc:uuyqs6y3pwtbteet4swt5i5y)
|
||||||
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
|
||||||
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
|
||||||
|
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
|
||||||
- Could be your name here too!
|
- Could be your name here too!
|
||||||
|
|
||||||
### Tranquil PDS instances in the wild!
|
### Tranquil PDS instances in the wild!
|
||||||
@@ -99,4 +106,3 @@ This project is very grateful to [@nonbinary.computer](https://tangled.org/did:p
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
|
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[server]
|
||||||
|
hostname = "pds.test"
|
||||||
|
allow_http_proxy = true
|
||||||
|
invite_code_required = false
|
||||||
|
disable_rate_limiting = true
|
||||||
|
|
||||||
|
[frontend]
|
||||||
|
enabled = true
|
||||||
|
dir = "/app/frontend/public"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
url = "postgres://postgres:postgres@db:5432/pds"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
path = "/var/lib/tranquil-pds/blobs"
|
||||||
|
|
||||||
|
[plc]
|
||||||
|
directory_url = "http://plc:2582"
|
||||||
|
|
||||||
|
[email]
|
||||||
|
from_address = "noreply@pds.test"
|
||||||
|
from_name = "Tranquil PDS (Dev)"
|
||||||
|
|
||||||
|
[email.smarthost]
|
||||||
|
host = "mailpit"
|
||||||
|
port = 1025
|
||||||
|
tls = "none"
|
||||||
|
|
||||||
|
[secrets]
|
||||||
|
allow_insecure = true
|
||||||
@@ -12,7 +12,6 @@ use tranquil_pds::auth::{Auth, NotTakendown, Permissive};
|
|||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
|
|
||||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||||
const MAX_PREFERENCES_COUNT: usize = 100;
|
|
||||||
const MAX_PREFERENCE_SIZE: usize = 10_000;
|
const MAX_PREFERENCE_SIZE: usize = 10_000;
|
||||||
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
|
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
|
||||||
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
|
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
|
||||||
@@ -92,6 +91,8 @@ pub async fn put_preferences(
|
|||||||
auth: Auth<NotTakendown>,
|
auth: Auth<NotTakendown>,
|
||||||
Json(input): Json<PutPreferencesInput>,
|
Json(input): Json<PutPreferencesInput>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let max_preferences_count: usize = tranquil_config::get().server.max_preferences_count;
|
||||||
|
|
||||||
let has_full_access = auth.permissions().has_full_access();
|
let has_full_access = auth.permissions().has_full_access();
|
||||||
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
|
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
|
||||||
Ok(Some(id)) => id,
|
Ok(Some(id)) => id,
|
||||||
@@ -99,11 +100,11 @@ pub async fn put_preferences(
|
|||||||
return ApiError::InternalError(Some("User not found".into())).into_response();
|
return ApiError::InternalError(Some("User not found".into())).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if input.preferences.len() > MAX_PREFERENCES_COUNT {
|
if input.preferences.len() > max_preferences_count {
|
||||||
return ApiError::InvalidRequest(format!(
|
return ApiError::InvalidRequest(format!(
|
||||||
"Too many preferences: {} exceeds limit of {}",
|
"Too many preferences: {} exceeds limit of {}",
|
||||||
input.preferences.len(),
|
input.preferences.len(),
|
||||||
MAX_PREFERENCES_COUNT
|
max_preferences_count
|
||||||
))
|
))
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use tranquil_pds::state::AppState;
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SignalStatusOutput {
|
pub struct SignalStatusOutput {
|
||||||
pub enabled: bool,
|
|
||||||
pub linked: bool,
|
pub linked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,13 +22,12 @@ pub async fn get_signal_status(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth: Auth<Admin>,
|
_auth: Auth<Admin>,
|
||||||
) -> Result<Json<SignalStatusOutput>, ApiError> {
|
) -> Result<Json<SignalStatusOutput>, ApiError> {
|
||||||
let enabled = tranquil_config::get().signal.enabled;
|
|
||||||
let linked = match &state.signal_sender {
|
let linked = match &state.signal_sender {
|
||||||
Some(slot) => slot.is_linked().await,
|
Some(slot) => slot.is_linked().await,
|
||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(SignalStatusOutput { enabled, linked }))
|
Ok(Json(SignalStatusOutput { linked }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn link_signal_device(
|
pub async fn link_signal_device(
|
||||||
@@ -76,7 +74,7 @@ pub async fn link_signal_device(
|
|||||||
let result = tokio::select! {
|
let result = tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
_ = shutdown.cancelled() => {
|
_ = shutdown.cancelled() => {
|
||||||
tracing::info!("Signal linking aborted due to server shutdown");
|
tracing::info!("server shutting down, aborting signal linking");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
r = link_result.completion => r,
|
r = link_result.completion => r,
|
||||||
@@ -84,10 +82,10 @@ pub async fn link_signal_device(
|
|||||||
match result {
|
match result {
|
||||||
Ok(Ok(client)) => {
|
Ok(Ok(client)) => {
|
||||||
if slot_for_task.complete_link(generation, client).await {
|
if slot_for_task.complete_link(generation, client).await {
|
||||||
tracing::info!("Signal device linked successfully");
|
tracing::info!("signal device linked");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Signal link completed but generation mismatch or already linked; discarding"
|
"discarding completed signal link, generation mismatch or already linked"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ pub async fn create_account(
|
|||||||
telegram_username: comms.telegram,
|
telegram_username: comms.telegram,
|
||||||
signal_username: comms.signal,
|
signal_username: comms.signal,
|
||||||
deactivated_at,
|
deactivated_at,
|
||||||
|
inbound_migration: is_migration || is_did_web_byod,
|
||||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||||
reserved_key_id,
|
reserved_key_id,
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ mod sign;
|
|||||||
mod submit;
|
mod submit;
|
||||||
|
|
||||||
pub use request::request_plc_operation_signature;
|
pub use request::request_plc_operation_signature;
|
||||||
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||||
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
|
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tranquil_pds::api::ApiError;
|
|||||||
use tranquil_pds::api::error::DbResultExt;
|
use tranquil_pds::api::error::DbResultExt;
|
||||||
use tranquil_pds::auth::{Auth, Permissive};
|
use tranquil_pds::auth::{Auth, Permissive};
|
||||||
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
use tranquil_pds::circuit_breaker::with_circuit_breaker;
|
||||||
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
|
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -19,14 +19,7 @@ pub struct SignPlcOperationInput {
|
|||||||
pub rotation_keys: Option<Vec<String>>,
|
pub rotation_keys: Option<Vec<String>>,
|
||||||
pub also_known_as: Option<Vec<String>>,
|
pub also_known_as: Option<Vec<String>>,
|
||||||
pub verification_methods: Option<HashMap<String, String>>,
|
pub verification_methods: Option<HashMap<String, String>>,
|
||||||
pub services: Option<HashMap<String, ServiceInput>>,
|
pub services: Option<HashMap<String, PlcService>>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct ServiceInput {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub service_type: ServiceType,
|
|
||||||
pub endpoint: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -106,25 +99,12 @@ pub async fn sign_plc_operation(
|
|||||||
if last_op.is_tombstone() {
|
if last_op.is_tombstone() {
|
||||||
return Err(ApiError::from(PlcError::Tombstoned));
|
return Err(ApiError::from(PlcError::Tombstoned));
|
||||||
}
|
}
|
||||||
let services = input.services.map(|s| {
|
|
||||||
s.into_iter()
|
|
||||||
.map(|(k, v)| {
|
|
||||||
(
|
|
||||||
k,
|
|
||||||
PlcService {
|
|
||||||
service_type: v.service_type,
|
|
||||||
endpoint: v.endpoint,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
});
|
|
||||||
let unsigned_op = create_update_op(
|
let unsigned_op = create_update_op(
|
||||||
&last_op,
|
&last_op,
|
||||||
input.rotation_keys,
|
input.rotation_keys,
|
||||||
input.verification_methods,
|
input.verification_methods,
|
||||||
input.also_known_as,
|
input.also_known_as,
|
||||||
services,
|
input.services,
|
||||||
)
|
)
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
|
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
|
||||||
|
|||||||
@@ -129,20 +129,13 @@ pub async fn submit_plc_operation(
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
match state
|
if let Err(e) = state
|
||||||
.repos
|
.repos
|
||||||
.repo
|
.repo
|
||||||
.insert_identity_event(did, Some(&user.handle))
|
.insert_identity_event(did, Some(&user.handle))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(seq) => {
|
warn!("Failed to sequence identity event: {:?}", e);
|
||||||
if let Err(e) = state.repos.repo.notify_update(seq).await {
|
|
||||||
warn!("Failed to notify identity event: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to sequence identity event: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _ = state
|
let _ = state
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ async fn process_messaging_channel_update(
|
|||||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||||
),
|
),
|
||||||
CommsChannel::Signal => ApiError::InvalidRequest(
|
CommsChannel::Signal => ApiError::InvalidRequest(
|
||||||
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)".into(),
|
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
|
||||||
),
|
),
|
||||||
CommsChannel::Email => ApiError::InvalidEmail,
|
CommsChannel::Email => ApiError::InvalidEmail,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ pub async fn import_repo(
|
|||||||
.map(|c| c.import.skip_verification)
|
.map(|c| c.import.skip_verification)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
});
|
||||||
let is_migration = user.deactivated_at.is_some();
|
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
|
||||||
if skip_verification {
|
if skip_verification {
|
||||||
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
|
||||||
} else if is_migration {
|
} else if is_migration {
|
||||||
@@ -417,7 +417,6 @@ async fn sequence_import_event(
|
|||||||
rev: None,
|
rev: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let seq = state.repos.repo.insert_commit_event(&data).await?;
|
state.repos.repo.insert_commit_event(&data).await?;
|
||||||
state.repos.repo.notify_update(seq).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::validation::validate_record_with_status;
|
use super::validation::validate_record_with_status;
|
||||||
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
|
||||||
use crate::repo::record::write::CommitInfo;
|
use crate::repo::record::write::{CommitInfo, ensure_record_type};
|
||||||
use axum::{Json, extract::State};
|
use axum::{Json, extract::State};
|
||||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -14,8 +14,8 @@ use tranquil_pds::auth::{
|
|||||||
};
|
};
|
||||||
use tranquil_pds::repo::TrackingBlockStore;
|
use tranquil_pds::repo::TrackingBlockStore;
|
||||||
use tranquil_pds::repo_ops::{
|
use tranquil_pds::repo_ops::{
|
||||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
CommitResult, FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||||
finalize_repo_write,
|
finalize_repo_write, with_repair_retry,
|
||||||
};
|
};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||||
@@ -27,7 +27,6 @@ struct WriteAccumulator {
|
|||||||
mst: Mst<TrackingBlockStore>,
|
mst: Mst<TrackingBlockStore>,
|
||||||
results: Vec<WriteResult>,
|
results: Vec<WriteResult>,
|
||||||
ops: Vec<RecordOp>,
|
ops: Vec<RecordOp>,
|
||||||
modified_keys: Vec<String>,
|
|
||||||
all_blob_cids: Vec<String>,
|
all_blob_cids: Vec<String>,
|
||||||
backlinks_to_add: Vec<Backlink>,
|
backlinks_to_add: Vec<Backlink>,
|
||||||
backlinks_to_remove: Vec<AtUri>,
|
backlinks_to_remove: Vec<AtUri>,
|
||||||
@@ -44,7 +43,6 @@ async fn process_single_write(
|
|||||||
mst,
|
mst,
|
||||||
mut results,
|
mut results,
|
||||||
mut ops,
|
mut ops,
|
||||||
mut modified_keys,
|
|
||||||
mut all_blob_cids,
|
mut all_blob_cids,
|
||||||
mut backlinks_to_add,
|
mut backlinks_to_add,
|
||||||
mut backlinks_to_remove,
|
mut backlinks_to_remove,
|
||||||
@@ -56,6 +54,8 @@ async fn process_single_write(
|
|||||||
rkey,
|
rkey,
|
||||||
value,
|
value,
|
||||||
} => {
|
} => {
|
||||||
|
let value = ensure_record_type(value, collection);
|
||||||
|
let value = &*value;
|
||||||
let validation_status = if validate.should_skip() {
|
let validation_status = if validate.should_skip() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -69,8 +69,19 @@ async fn process_single_write(
|
|||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
all_blob_cids.extend(extract_blob_cids(value));
|
|
||||||
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
|
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
|
||||||
|
let key = format!("{}/{}", collection, rkey);
|
||||||
|
if mst
|
||||||
|
.get(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(ApiError::InvalidRequest(format!(
|
||||||
|
"Record already exists at {key}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
all_blob_cids.extend(extract_blob_cids(value));
|
||||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||||
@@ -78,12 +89,10 @@ async fn process_single_write(
|
|||||||
.put(&record_bytes)
|
.put(&record_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||||
let key = format!("{}/{}", collection, rkey);
|
|
||||||
modified_keys.push(key.clone());
|
|
||||||
let new_mst = mst
|
let new_mst = mst
|
||||||
.add(&key, record_cid)
|
.add(&key, record_cid)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||||
let uri = AtUri::from_parts(did, collection, &rkey);
|
let uri = AtUri::from_parts(did, collection, &rkey);
|
||||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||||
results.push(WriteResult::CreateResult {
|
results.push(WriteResult::CreateResult {
|
||||||
@@ -100,7 +109,6 @@ async fn process_single_write(
|
|||||||
mst: new_mst,
|
mst: new_mst,
|
||||||
results,
|
results,
|
||||||
ops,
|
ops,
|
||||||
modified_keys,
|
|
||||||
all_blob_cids,
|
all_blob_cids,
|
||||||
backlinks_to_add,
|
backlinks_to_add,
|
||||||
backlinks_to_remove,
|
backlinks_to_remove,
|
||||||
@@ -111,6 +119,8 @@ async fn process_single_write(
|
|||||||
rkey,
|
rkey,
|
||||||
value,
|
value,
|
||||||
} => {
|
} => {
|
||||||
|
let value = ensure_record_type(value, collection);
|
||||||
|
let value = &*value;
|
||||||
let validation_status = if validate.should_skip() {
|
let validation_status = if validate.should_skip() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -124,6 +134,14 @@ async fn process_single_write(
|
|||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let key = format!("{}/{}", collection, rkey);
|
||||||
|
let prev_record_cid = mst
|
||||||
|
.get(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::from_mst_error("read update target from MST", &e))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::InvalidRequest("Update target record does not exist".into())
|
||||||
|
})?;
|
||||||
all_blob_cids.extend(extract_blob_cids(value));
|
all_blob_cids.extend(extract_blob_cids(value));
|
||||||
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
let record_ipld = tranquil_pds::util::json_to_ipld(value);
|
||||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||||
@@ -132,21 +150,10 @@ async fn process_single_write(
|
|||||||
.put(&record_bytes)
|
.put(&record_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
|
||||||
let key = format!("{}/{}", collection, rkey);
|
|
||||||
modified_keys.push(key.clone());
|
|
||||||
let prev_record_cid = mst
|
|
||||||
.get(&key)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
|
||||||
})?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError::InvalidRequest("Update target record does not exist".into())
|
|
||||||
})?;
|
|
||||||
let new_mst = mst
|
let new_mst = mst
|
||||||
.update(&key, record_cid)
|
.update(&key, record_cid)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||||
let uri = AtUri::from_parts(did, collection, rkey);
|
let uri = AtUri::from_parts(did, collection, rkey);
|
||||||
backlinks_to_remove.push(uri.clone());
|
backlinks_to_remove.push(uri.clone());
|
||||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||||
@@ -165,7 +172,6 @@ async fn process_single_write(
|
|||||||
mst: new_mst,
|
mst: new_mst,
|
||||||
results,
|
results,
|
||||||
ops,
|
ops,
|
||||||
modified_keys,
|
|
||||||
all_blob_cids,
|
all_blob_cids,
|
||||||
backlinks_to_add,
|
backlinks_to_add,
|
||||||
backlinks_to_remove,
|
backlinks_to_remove,
|
||||||
@@ -173,20 +179,17 @@ async fn process_single_write(
|
|||||||
}
|
}
|
||||||
WriteOp::Delete { collection, rkey } => {
|
WriteOp::Delete { collection, rkey } => {
|
||||||
let key = format!("{}/{}", collection, rkey);
|
let key = format!("{}/{}", collection, rkey);
|
||||||
modified_keys.push(key.clone());
|
|
||||||
let prev_record_cid = mst
|
let prev_record_cid = mst
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::from_mst_error("read delete target from MST", &e))?
|
||||||
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
|
|
||||||
})?
|
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ApiError::InvalidRequest("Delete target record does not exist".into())
|
ApiError::InvalidRequest("Delete target record does not exist".into())
|
||||||
})?;
|
})?;
|
||||||
let new_mst = mst
|
let new_mst = mst
|
||||||
.delete(&key)
|
.delete(&key)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||||
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
|
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
|
||||||
results.push(WriteResult::DeleteResult {});
|
results.push(WriteResult::DeleteResult {});
|
||||||
ops.push(RecordOp::Delete {
|
ops.push(RecordOp::Delete {
|
||||||
@@ -198,7 +201,6 @@ async fn process_single_write(
|
|||||||
mst: new_mst,
|
mst: new_mst,
|
||||||
results,
|
results,
|
||||||
ops,
|
ops,
|
||||||
modified_keys,
|
|
||||||
all_blob_cids,
|
all_blob_cids,
|
||||||
backlinks_to_add,
|
backlinks_to_add,
|
||||||
backlinks_to_remove,
|
backlinks_to_remove,
|
||||||
@@ -219,7 +221,6 @@ async fn process_writes(
|
|||||||
mst: initial_mst,
|
mst: initial_mst,
|
||||||
results: Vec::new(),
|
results: Vec::new(),
|
||||||
ops: Vec::new(),
|
ops: Vec::new(),
|
||||||
modified_keys: Vec::new(),
|
|
||||||
all_blob_cids: Vec::new(),
|
all_blob_cids: Vec::new(),
|
||||||
backlinks_to_add: Vec::new(),
|
backlinks_to_add: Vec::new(),
|
||||||
backlinks_to_remove: Vec::new(),
|
backlinks_to_remove: Vec::new(),
|
||||||
@@ -231,6 +232,45 @@ async fn process_writes(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn execute_apply_writes(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
did: &Did,
|
||||||
|
input: &ApplyWritesInput,
|
||||||
|
controller_did: Option<&Did>,
|
||||||
|
write_summary: Option<serde_json::Value>,
|
||||||
|
) -> Result<(CommitResult, Vec<WriteResult>), ApiError> {
|
||||||
|
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||||
|
|
||||||
|
let WriteAccumulator {
|
||||||
|
mst: final_mst,
|
||||||
|
results,
|
||||||
|
ops,
|
||||||
|
all_blob_cids,
|
||||||
|
backlinks_to_add,
|
||||||
|
backlinks_to_remove,
|
||||||
|
} = process_writes(&input.writes, mst, did, input.validate, &ctx.tracking_store).await?;
|
||||||
|
|
||||||
|
let commit_result = finalize_repo_write(
|
||||||
|
state,
|
||||||
|
ctx,
|
||||||
|
final_mst,
|
||||||
|
FinalizeParams {
|
||||||
|
did,
|
||||||
|
user_id,
|
||||||
|
controller_did,
|
||||||
|
delegation_detail: write_summary,
|
||||||
|
ops,
|
||||||
|
blob_cids: &all_blob_cids,
|
||||||
|
backlinks_to_add,
|
||||||
|
backlinks_to_remove,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((commit_result, results))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "$type")]
|
#[serde(tag = "$type")]
|
||||||
pub enum WriteOp {
|
pub enum WriteOp {
|
||||||
@@ -345,25 +385,6 @@ pub async fn apply_writes(
|
|||||||
.log_db_err("fetching user for batch write")?
|
.log_db_err("fetching user for batch write")?
|
||||||
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
|
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
|
||||||
|
|
||||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
|
||||||
|
|
||||||
let WriteAccumulator {
|
|
||||||
mst: final_mst,
|
|
||||||
results,
|
|
||||||
ops,
|
|
||||||
modified_keys,
|
|
||||||
all_blob_cids,
|
|
||||||
backlinks_to_add,
|
|
||||||
backlinks_to_remove,
|
|
||||||
} = process_writes(
|
|
||||||
&input.writes,
|
|
||||||
mst,
|
|
||||||
&did,
|
|
||||||
input.validate,
|
|
||||||
&ctx.tracking_store,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
|
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
|
||||||
let writes: Vec<serde_json::Value> = input
|
let writes: Vec<serde_json::Value> = input
|
||||||
.writes
|
.writes
|
||||||
@@ -397,22 +418,16 @@ pub async fn apply_writes(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
let commit_result = finalize_repo_write(
|
let (commit_result, results) = with_repair_retry(&state, user_id, || {
|
||||||
&state,
|
execute_apply_writes(
|
||||||
ctx,
|
&state,
|
||||||
final_mst,
|
|
||||||
FinalizeParams {
|
|
||||||
did: &did,
|
|
||||||
user_id,
|
user_id,
|
||||||
controller_did: controller_did.as_ref(),
|
&did,
|
||||||
delegation_detail: write_summary,
|
&input,
|
||||||
ops,
|
controller_did.as_ref(),
|
||||||
modified_keys: &modified_keys,
|
write_summary.clone(),
|
||||||
blob_cids: &all_blob_cids,
|
)
|
||||||
backlinks_to_add,
|
})
|
||||||
backlinks_to_remove,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(ApplyWritesOutput {
|
Ok(Json(ApplyWritesOutput {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ use cid::Cid;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tracing::error;
|
|
||||||
use tranquil_pds::api::error::ApiError;
|
use tranquil_pds::api::error::ApiError;
|
||||||
use tranquil_pds::auth::{Active, Auth, VerifyScope};
|
use tranquil_pds::auth::{Active, Auth, VerifyScope};
|
||||||
use tranquil_pds::cid_types::RecordCid;
|
use tranquil_pds::cid_types::RecordCid;
|
||||||
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
|
use tranquil_pds::repo_ops::{
|
||||||
|
FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, with_repair_retry,
|
||||||
|
};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
|
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DeleteRecordInput {
|
pub struct DeleteRecordInput {
|
||||||
@@ -41,13 +43,30 @@ pub async fn delete_record(
|
|||||||
let user_id = repo_auth.user_id;
|
let user_id = repo_auth.user_id;
|
||||||
let controller_did = repo_auth.controller_did;
|
let controller_did = repo_auth.controller_did;
|
||||||
|
|
||||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
let out = with_repair_retry(&state, user_id, || {
|
||||||
|
delete_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_record_inner(
|
||||||
|
state: &AppState,
|
||||||
|
did: &Did,
|
||||||
|
user_id: Uuid,
|
||||||
|
controller_did: Option<&Did>,
|
||||||
|
input: &DeleteRecordInput,
|
||||||
|
) -> Result<DeleteRecordOutput, ApiError> {
|
||||||
|
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||||
|
|
||||||
let key = format!("{}/{}", input.collection, input.rkey);
|
let key = format!("{}/{}", input.collection, input.rkey);
|
||||||
|
|
||||||
if let Some(swap_record_str) = &input.swap_record {
|
if let Some(swap_record_str) = &input.swap_record {
|
||||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
let actual_cid = mst
|
||||||
|
.get(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::from_mst_error("read swap target from MST", &e))?;
|
||||||
if expected_cid != actual_cid {
|
if expected_cid != actual_cid {
|
||||||
return Err(ApiError::InvalidSwap(Some(
|
return Err(ApiError::InvalidSwap(Some(
|
||||||
"Record has been modified or does not exist".into(),
|
"Record has been modified or does not exist".into(),
|
||||||
@@ -55,18 +74,18 @@ pub async fn delete_record(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let prev_record_cid = mst.get(&key).await.map_err(|e| {
|
let prev_record_cid = mst
|
||||||
error!("Failed to read prev record from MST: {}", e);
|
.get(&key)
|
||||||
ApiError::InternalError(Some("Failed to read MST".into()))
|
.await
|
||||||
})?;
|
.map_err(|e| ApiError::from_mst_error("read prev record from MST", &e))?;
|
||||||
let Some(prev_record_cid) = prev_record_cid else {
|
let Some(prev_record_cid) = prev_record_cid else {
|
||||||
return Ok(Json(DeleteRecordOutput { commit: None }));
|
return Ok(DeleteRecordOutput { commit: None });
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_mst = mst.delete(&key).await.map_err(|e| {
|
let new_mst = mst
|
||||||
error!("Failed to delete from MST: {}", e);
|
.delete(&key)
|
||||||
ApiError::InternalError(Some("Failed to delete from MST".into()))
|
.await
|
||||||
})?;
|
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
|
||||||
|
|
||||||
let op = RecordOp::Delete {
|
let op = RecordOp::Delete {
|
||||||
collection: input.collection.clone(),
|
collection: input.collection.clone(),
|
||||||
@@ -74,18 +93,17 @@ pub async fn delete_record(
|
|||||||
prev: RecordCid::from(prev_record_cid),
|
prev: RecordCid::from(prev_record_cid),
|
||||||
};
|
};
|
||||||
|
|
||||||
let modified_keys = [key];
|
let deleted_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
|
||||||
|
|
||||||
let commit_result = finalize_repo_write(
|
let commit_result = finalize_repo_write(
|
||||||
&state,
|
state,
|
||||||
ctx,
|
ctx,
|
||||||
new_mst,
|
new_mst,
|
||||||
FinalizeParams {
|
FinalizeParams {
|
||||||
did: &did,
|
did,
|
||||||
user_id,
|
user_id,
|
||||||
controller_did: controller_did.as_ref(),
|
controller_did,
|
||||||
delegation_detail: controller_did.as_ref().map(|_| {
|
delegation_detail: controller_did.map(|_| {
|
||||||
json!({
|
json!({
|
||||||
"action": "delete",
|
"action": "delete",
|
||||||
"collection": input.collection,
|
"collection": input.collection,
|
||||||
@@ -93,7 +111,6 @@ pub async fn delete_record(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
ops: vec![op],
|
ops: vec![op],
|
||||||
modified_keys: &modified_keys,
|
|
||||||
blob_cids: &[],
|
blob_cids: &[],
|
||||||
backlinks_to_add: vec![],
|
backlinks_to_add: vec![],
|
||||||
backlinks_to_remove: vec![deleted_uri],
|
backlinks_to_remove: vec![deleted_uri],
|
||||||
@@ -101,10 +118,10 @@ pub async fn delete_record(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(DeleteRecordOutput {
|
Ok(DeleteRecordOutput {
|
||||||
commit: Some(CommitInfo {
|
commit: Some(CommitInfo {
|
||||||
cid: commit_result.commit_cid.to_string(),
|
cid: commit_result.commit_cid.to_string(),
|
||||||
rev: commit_result.rev,
|
rev: commit_result.rev,
|
||||||
}),
|
}),
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use cid::Cid;
|
|||||||
use jacquard_repo::storage::BlockStore;
|
use jacquard_repo::storage::BlockStore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tracing::error;
|
|
||||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||||
use tranquil_pds::auth::{
|
use tranquil_pds::auth::{
|
||||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||||
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
|
|||||||
};
|
};
|
||||||
use tranquil_pds::repo_ops::{
|
use tranquil_pds::repo_ops::{
|
||||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||||
finalize_repo_write,
|
finalize_repo_write, with_repair_retry,
|
||||||
};
|
};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||||
@@ -62,6 +62,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ensure_record_type<'a>(
|
||||||
|
record: &'a serde_json::Value,
|
||||||
|
collection: &Nsid,
|
||||||
|
) -> Cow<'a, serde_json::Value> {
|
||||||
|
let serde_json::Value::Object(map) = record else {
|
||||||
|
return Cow::Borrowed(record);
|
||||||
|
};
|
||||||
|
let needs_fill = match map.get("$type") {
|
||||||
|
None | Some(serde_json::Value::Null) => true,
|
||||||
|
Some(serde_json::Value::String(existing)) => existing.is_empty(),
|
||||||
|
Some(_) => false,
|
||||||
|
};
|
||||||
|
if !needs_fill {
|
||||||
|
return Cow::Borrowed(record);
|
||||||
|
}
|
||||||
|
let mut map = map.clone();
|
||||||
|
map.insert(
|
||||||
|
"$type".to_string(),
|
||||||
|
serde_json::Value::String(collection.to_string()),
|
||||||
|
);
|
||||||
|
Cow::Owned(serde_json::Value::Object(map))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct CreateRecordInput {
|
pub struct CreateRecordInput {
|
||||||
@@ -95,15 +118,32 @@ pub struct CreateRecordOutput {
|
|||||||
pub async fn create_record(
|
pub async fn create_record(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: Auth<Active>,
|
auth: Auth<Active>,
|
||||||
Json(input): Json<CreateRecordInput>,
|
Json(mut input): Json<CreateRecordInput>,
|
||||||
) -> Result<Json<CreateRecordOutput>, ApiError> {
|
) -> Result<Json<CreateRecordOutput>, ApiError> {
|
||||||
|
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||||
|
input.record = record;
|
||||||
|
}
|
||||||
let scope_proof = auth.verify_repo_create(&input.collection)?;
|
let scope_proof = auth.verify_repo_create(&input.collection)?;
|
||||||
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
|
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
|
||||||
let did = repo_auth.did;
|
let did = repo_auth.did;
|
||||||
let user_id = repo_auth.user_id;
|
let user_id = repo_auth.user_id;
|
||||||
let controller_did = repo_auth.controller_did;
|
let controller_did = repo_auth.controller_did;
|
||||||
|
|
||||||
let (ctx, mut mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
let out = with_repair_retry(&state, user_id, || {
|
||||||
|
create_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_record_inner(
|
||||||
|
state: &AppState,
|
||||||
|
did: &Did,
|
||||||
|
user_id: Uuid,
|
||||||
|
controller_did: Option<&Did>,
|
||||||
|
input: &CreateRecordInput,
|
||||||
|
) -> Result<CreateRecordOutput, ApiError> {
|
||||||
|
let (ctx, mut mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||||
|
|
||||||
let validation_status = if input.validate.should_skip() {
|
let validation_status = if input.validate.should_skip() {
|
||||||
None
|
None
|
||||||
@@ -119,12 +159,12 @@ pub async fn create_record(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
|
let rkey = input.rkey.clone().unwrap_or_else(Rkey::generate);
|
||||||
let mut ops: Vec<RecordOp> = Vec::new();
|
let mut ops: Vec<RecordOp> = Vec::new();
|
||||||
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
|
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
|
||||||
|
|
||||||
if !input.validate.should_skip() {
|
if !input.validate.should_skip() {
|
||||||
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
let record_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||||
let backlinks = extract_backlinks(&record_uri, &input.record);
|
let backlinks = extract_backlinks(&record_uri, &input.record);
|
||||||
|
|
||||||
if !backlinks.is_empty() {
|
if !backlinks.is_empty() {
|
||||||
@@ -149,24 +189,18 @@ pub async fn create_record(
|
|||||||
Ok(Some(cid)) => cid,
|
Ok(Some(cid)) => cid,
|
||||||
Ok(None) => continue,
|
Ok(None) => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
return Err(ApiError::from_mst_error(
|
||||||
"Failed to read conflict record from MST {}: {:?}",
|
&format!("read conflict record from MST {conflict_uri}"),
|
||||||
conflict_uri, e
|
&e,
|
||||||
);
|
));
|
||||||
return Err(ApiError::InternalError(Some(
|
|
||||||
"Failed to read conflicting record from MST".into(),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mst = mst.delete(&conflict_key).await.map_err(|e| {
|
mst = mst.delete(&conflict_key).await.map_err(|e| {
|
||||||
error!(
|
ApiError::from_mst_error(
|
||||||
"Failed to delete conflict from MST {}: {:?}",
|
&format!("delete conflict from MST {conflict_uri}"),
|
||||||
conflict_uri, e
|
&e,
|
||||||
);
|
)
|
||||||
ApiError::InternalError(Some(
|
|
||||||
"Failed to delete conflicting record from MST".into(),
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
ops.push(RecordOp::Delete {
|
ops.push(RecordOp::Delete {
|
||||||
@@ -179,6 +213,18 @@ pub async fn create_record(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = format!("{}/{}", input.collection, rkey);
|
||||||
|
if mst
|
||||||
|
.get(&key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(ApiError::InvalidRequest(format!(
|
||||||
|
"Record already exists at {key}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||||
@@ -187,12 +233,10 @@ pub async fn create_record(
|
|||||||
.put(&record_bytes)
|
.put(&record_bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||||
|
|
||||||
let key = format!("{}/{}", input.collection, rkey);
|
|
||||||
mst = mst
|
mst = mst
|
||||||
.add(&key, record_cid)
|
.add(&key, record_cid)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||||
|
|
||||||
ops.push(RecordOp::Create {
|
ops.push(RecordOp::Create {
|
||||||
collection: input.collection.clone(),
|
collection: input.collection.clone(),
|
||||||
@@ -200,34 +244,20 @@ pub async fn create_record(
|
|||||||
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
|
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
|
||||||
});
|
});
|
||||||
|
|
||||||
let modified_keys: Vec<String> = ops
|
|
||||||
.iter()
|
|
||||||
.map(|op| match op {
|
|
||||||
RecordOp::Create {
|
|
||||||
collection, rkey, ..
|
|
||||||
}
|
|
||||||
| RecordOp::Update {
|
|
||||||
collection, rkey, ..
|
|
||||||
}
|
|
||||||
| RecordOp::Delete {
|
|
||||||
collection, rkey, ..
|
|
||||||
} => format!("{}/{}", collection, rkey),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let blob_cids = extract_blob_cids(&input.record);
|
let blob_cids = extract_blob_cids(&input.record);
|
||||||
|
|
||||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
let created_uri = AtUri::from_parts(did, &input.collection, &rkey);
|
||||||
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
|
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
|
||||||
|
|
||||||
let commit_result = finalize_repo_write(
|
let commit_result = finalize_repo_write(
|
||||||
&state,
|
state,
|
||||||
ctx,
|
ctx,
|
||||||
mst,
|
mst,
|
||||||
FinalizeParams {
|
FinalizeParams {
|
||||||
did: &did,
|
did,
|
||||||
user_id,
|
user_id,
|
||||||
controller_did: controller_did.as_ref(),
|
controller_did,
|
||||||
delegation_detail: controller_did.as_ref().map(|_| {
|
delegation_detail: controller_did.map(|_| {
|
||||||
json!({
|
json!({
|
||||||
"action": "create",
|
"action": "create",
|
||||||
"collection": input.collection,
|
"collection": input.collection,
|
||||||
@@ -235,7 +265,6 @@ pub async fn create_record(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
ops,
|
ops,
|
||||||
modified_keys: &modified_keys,
|
|
||||||
blob_cids: &blob_cids,
|
blob_cids: &blob_cids,
|
||||||
backlinks_to_add,
|
backlinks_to_add,
|
||||||
backlinks_to_remove: conflict_uris_to_cleanup,
|
backlinks_to_remove: conflict_uris_to_cleanup,
|
||||||
@@ -243,7 +272,7 @@ pub async fn create_record(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(CreateRecordOutput {
|
Ok(CreateRecordOutput {
|
||||||
uri: created_uri,
|
uri: created_uri,
|
||||||
cid: record_cid.to_string(),
|
cid: record_cid.to_string(),
|
||||||
commit: CommitInfo {
|
commit: CommitInfo {
|
||||||
@@ -251,7 +280,7 @@ pub async fn create_record(
|
|||||||
rev: commit_result.rev,
|
rev: commit_result.rev,
|
||||||
},
|
},
|
||||||
validation_status,
|
validation_status,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -283,15 +312,32 @@ pub struct PutRecordOutput {
|
|||||||
pub async fn put_record(
|
pub async fn put_record(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: Auth<Active>,
|
auth: Auth<Active>,
|
||||||
Json(input): Json<PutRecordInput>,
|
Json(mut input): Json<PutRecordInput>,
|
||||||
) -> Result<Json<PutRecordOutput>, ApiError> {
|
) -> Result<Json<PutRecordOutput>, ApiError> {
|
||||||
|
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
|
||||||
|
input.record = record;
|
||||||
|
}
|
||||||
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
|
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
|
||||||
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
|
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
|
||||||
let did = repo_auth.did;
|
let did = repo_auth.did;
|
||||||
let user_id = repo_auth.user_id;
|
let user_id = repo_auth.user_id;
|
||||||
let controller_did = repo_auth.controller_did;
|
let controller_did = repo_auth.controller_did;
|
||||||
|
|
||||||
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
|
let out = with_repair_retry(&state, user_id, || {
|
||||||
|
put_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_record_inner(
|
||||||
|
state: &AppState,
|
||||||
|
did: &Did,
|
||||||
|
user_id: Uuid,
|
||||||
|
controller_did: Option<&Did>,
|
||||||
|
input: &PutRecordInput,
|
||||||
|
) -> Result<PutRecordOutput, ApiError> {
|
||||||
|
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
|
||||||
|
|
||||||
let validation_status = if input.validate.should_skip() {
|
let validation_status = if input.validate.should_skip() {
|
||||||
None
|
None
|
||||||
@@ -309,9 +355,13 @@ pub async fn put_record(
|
|||||||
|
|
||||||
let key = format!("{}/{}", input.collection, input.rkey);
|
let key = format!("{}/{}", input.collection, input.rkey);
|
||||||
|
|
||||||
|
let read_cid = |r: Result<Option<Cid>, jacquard_repo::error::RepoError>| {
|
||||||
|
r.map_err(|e| ApiError::from_mst_error("read MST for put", &e))
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(swap_record_str) = &input.swap_record {
|
if let Some(swap_record_str) = &input.swap_record {
|
||||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
let actual_cid = read_cid(mst.get(&key).await)?;
|
||||||
if expected_cid != actual_cid {
|
if expected_cid != actual_cid {
|
||||||
return Err(ApiError::InvalidSwap(Some(
|
return Err(ApiError::InvalidSwap(Some(
|
||||||
"Record has been modified or does not exist".into(),
|
"Record has been modified or does not exist".into(),
|
||||||
@@ -319,7 +369,7 @@ pub async fn put_record(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
let existing_cid = read_cid(mst.get(&key).await)?;
|
||||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||||
@@ -330,21 +380,21 @@ pub async fn put_record(
|
|||||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||||
|
|
||||||
if existing_cid == Some(record_cid) {
|
if existing_cid == Some(record_cid) {
|
||||||
return Ok(Json(PutRecordOutput {
|
return Ok(PutRecordOutput {
|
||||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
uri: AtUri::from_parts(did, &input.collection, &input.rkey),
|
||||||
cid: record_cid.to_string(),
|
cid: record_cid.to_string(),
|
||||||
commit: None,
|
commit: None,
|
||||||
validation_status,
|
validation_status,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||||
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
|
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
|
||||||
Some(prev_cid) => {
|
Some(prev_cid) => {
|
||||||
let new_mst = mst
|
let new_mst = mst
|
||||||
.update(&key, record_cid)
|
.update(&key, record_cid)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
|
||||||
let op = RecordOp::Update {
|
let op = RecordOp::Update {
|
||||||
collection: input.collection.clone(),
|
collection: input.collection.clone(),
|
||||||
rkey: input.rkey.clone(),
|
rkey: input.rkey.clone(),
|
||||||
@@ -357,7 +407,7 @@ pub async fn put_record(
|
|||||||
let new_mst = mst
|
let new_mst = mst
|
||||||
.add(&key, record_cid)
|
.add(&key, record_cid)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
|
||||||
let op = RecordOp::Create {
|
let op = RecordOp::Create {
|
||||||
collection: input.collection.clone(),
|
collection: input.collection.clone(),
|
||||||
rkey: input.rkey.clone(),
|
rkey: input.rkey.clone(),
|
||||||
@@ -367,19 +417,18 @@ pub async fn put_record(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let modified_keys = [key];
|
|
||||||
let blob_cids = extract_blob_cids(&input.record);
|
let blob_cids = extract_blob_cids(&input.record);
|
||||||
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
||||||
|
|
||||||
let commit_result = finalize_repo_write(
|
let commit_result = finalize_repo_write(
|
||||||
&state,
|
state,
|
||||||
ctx,
|
ctx,
|
||||||
new_mst,
|
new_mst,
|
||||||
FinalizeParams {
|
FinalizeParams {
|
||||||
did: &did,
|
did,
|
||||||
user_id,
|
user_id,
|
||||||
controller_did: controller_did.as_ref(),
|
controller_did,
|
||||||
delegation_detail: controller_did.as_ref().map(|_| {
|
delegation_detail: controller_did.map(|_| {
|
||||||
json!({
|
json!({
|
||||||
"action": if is_update { "update" } else { "create" },
|
"action": if is_update { "update" } else { "create" },
|
||||||
"collection": input.collection,
|
"collection": input.collection,
|
||||||
@@ -387,7 +436,6 @@ pub async fn put_record(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
ops: vec![op],
|
ops: vec![op],
|
||||||
modified_keys: &modified_keys,
|
|
||||||
blob_cids: &blob_cids,
|
blob_cids: &blob_cids,
|
||||||
backlinks_to_add,
|
backlinks_to_add,
|
||||||
backlinks_to_remove,
|
backlinks_to_remove,
|
||||||
@@ -395,7 +443,7 @@ pub async fn put_record(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(PutRecordOutput {
|
Ok(PutRecordOutput {
|
||||||
uri: record_uri,
|
uri: record_uri,
|
||||||
cid: record_cid.to_string(),
|
cid: record_cid.to_string(),
|
||||||
commit: Some(CommitInfo {
|
commit: Some(CommitInfo {
|
||||||
@@ -403,5 +451,5 @@ pub async fn put_record(
|
|||||||
rev: commit_result.rev,
|
rev: commit_result.rev,
|
||||||
}),
|
}),
|
||||||
validation_status,
|
validation_status,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -665,27 +665,22 @@ pub async fn delete_account(
|
|||||||
error!("DB error deleting account: {:?}", e);
|
error!("DB error deleting account: {:?}", e);
|
||||||
ApiError::InternalError(None)
|
ApiError::InternalError(None)
|
||||||
})?;
|
})?;
|
||||||
let account_seq = tranquil_pds::repo_ops::sequence_account_event(
|
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||||
&state,
|
&state,
|
||||||
did,
|
did,
|
||||||
tranquil_db_traits::AccountStatus::Deleted,
|
tranquil_db_traits::AccountStatus::Deleted,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
match account_seq {
|
{
|
||||||
Ok(seq) => {
|
warn!(
|
||||||
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
|
"Failed to sequence account deletion event for {}: {}",
|
||||||
warn!(
|
did, e
|
||||||
"Failed to cleanup sequences for deleted account {}: {}",
|
);
|
||||||
did, e
|
} else if let Err(e) = state.repos.repo.purge_did_events_keeping_latest(did).await {
|
||||||
);
|
warn!(
|
||||||
}
|
"Failed to cleanup sequences for deleted account {}: {}",
|
||||||
}
|
did, e
|
||||||
Err(e) => {
|
);
|
||||||
warn!(
|
|
||||||
"Failed to sequence account deletion event for {}: {}",
|
|
||||||
did, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _ = state
|
let _ = state
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use tranquil_pds::BUILD_VERSION;
|
|||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
|
use tranquil_pds::util::{discord_app_id, discord_bot_username, telegram_bot_username};
|
||||||
|
|
||||||
fn get_available_comms_channels() -> Vec<CommsChannel> {
|
async fn get_available_comms_channels(state: &AppState) -> Vec<CommsChannel> {
|
||||||
let cfg = tranquil_config::get();
|
let cfg = tranquil_config::get();
|
||||||
let mut channels = vec![CommsChannel::Email];
|
let mut channels = vec![CommsChannel::Email];
|
||||||
if cfg.discord.bot_token.is_some() {
|
if cfg.discord.bot_token.is_some() {
|
||||||
@@ -14,7 +14,9 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
|
|||||||
if cfg.telegram.bot_token.is_some() {
|
if cfg.telegram.bot_token.is_some() {
|
||||||
channels.push(CommsChannel::Telegram);
|
channels.push(CommsChannel::Telegram);
|
||||||
}
|
}
|
||||||
if cfg.signal.enabled {
|
if let Some(slot) = &state.signal_sender
|
||||||
|
&& slot.is_linked().await
|
||||||
|
{
|
||||||
channels.push(CommsChannel::Signal);
|
channels.push(CommsChannel::Signal);
|
||||||
}
|
}
|
||||||
channels
|
channels
|
||||||
@@ -66,7 +68,7 @@ pub struct DescribeServerOutput {
|
|||||||
pub telegram_bot_username: Option<String>,
|
pub telegram_bot_username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn describe_server() -> Json<DescribeServerOutput> {
|
pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerOutput> {
|
||||||
let cfg = tranquil_config::get();
|
let cfg = tranquil_config::get();
|
||||||
let pds_hostname = &cfg.server.hostname;
|
let pds_hostname = &cfg.server.hostname;
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ pub async fn describe_server() -> Json<DescribeServerOutput> {
|
|||||||
email: cfg.server.contact_email.clone(),
|
email: cfg.server.contact_email.clone(),
|
||||||
},
|
},
|
||||||
version: BUILD_VERSION,
|
version: BUILD_VERSION,
|
||||||
available_comms_channels: get_available_comms_channels(),
|
available_comms_channels: get_available_comms_channels(&state).await,
|
||||||
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
|
self_hosted_did_web_enabled: is_self_hosted_did_web_enabled(),
|
||||||
discord_bot_username: discord_bot_username().map(String::from),
|
discord_bot_username: discord_bot_username().map(String::from),
|
||||||
discord_app_id: discord_app_id().map(String::from),
|
discord_app_id: discord_app_id().map(String::from),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ pub use session::{
|
|||||||
auto_resend_verification, confirm_signup, create_session, delete_session,
|
auto_resend_verification, confirm_signup, create_session, delete_session,
|
||||||
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
|
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
|
||||||
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
|
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
|
||||||
|
verification_blocks_login,
|
||||||
};
|
};
|
||||||
pub use signing_key::reserve_signing_key;
|
pub use signing_key::reserve_signing_key;
|
||||||
pub use totp::{
|
pub use totp::{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use bcrypt::verify;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use tranquil_db_traits::{SessionId, TokenFamilyId};
|
use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId};
|
||||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||||
use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse};
|
use tranquil_pds::api::{EmptyResponse, PreferredLocaleOutput, SuccessResponse};
|
||||||
use tranquil_pds::auth::{
|
use tranquil_pds::auth::{
|
||||||
@@ -20,6 +20,13 @@ use tranquil_pds::state::AppState;
|
|||||||
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
|
||||||
use tranquil_types::TokenId;
|
use tranquil_types::TokenId;
|
||||||
|
|
||||||
|
pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool {
|
||||||
|
!tranquil_config::get()
|
||||||
|
.server
|
||||||
|
.disable_account_verification_gate
|
||||||
|
&& !channel_verification.has_any_verified()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CreateSessionInput {
|
pub struct CreateSessionInput {
|
||||||
@@ -129,14 +136,13 @@ pub async fn create_session(
|
|||||||
warn!("Login attempt for takendown account: {}", row.did);
|
warn!("Login attempt for takendown account: {}", row.did);
|
||||||
return Err(ApiError::AccountTakedown);
|
return Err(ApiError::AccountTakedown);
|
||||||
}
|
}
|
||||||
let is_verified = row.channel_verification.has_any_verified();
|
|
||||||
let is_delegated = state
|
let is_delegated = state
|
||||||
.repos
|
.repos
|
||||||
.delegation
|
.delegation
|
||||||
.is_delegated_account(&row.did)
|
.is_delegated_account(&row.did)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !is_verified && !is_delegated {
|
if verification_blocks_login(&row.channel_verification) && !is_delegated {
|
||||||
warn!("Login attempt for unverified account: {}", row.did);
|
warn!("Login attempt for unverified account: {}", row.did);
|
||||||
let resend_info = auto_resend_verification(&state, &row.did).await;
|
let resend_info = auto_resend_verification(&state, &row.did).await;
|
||||||
let handle = resend_info
|
let handle = resend_info
|
||||||
@@ -163,6 +169,7 @@ pub async fn create_session(
|
|||||||
let email_2fa_enabled = row.email_2fa_enabled;
|
let email_2fa_enabled = row.email_2fa_enabled;
|
||||||
let is_legacy_login = has_totp || email_2fa_enabled;
|
let is_legacy_login = has_totp || email_2fa_enabled;
|
||||||
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
|
||||||
|
is_app_password: app_password_name.is_some(),
|
||||||
email_2fa_enabled,
|
email_2fa_enabled,
|
||||||
has_totp,
|
has_totp,
|
||||||
allow_legacy_login: row.allow_legacy_login,
|
allow_legacy_login: row.allow_legacy_login,
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ tranquil-signal = { workspace = true }
|
|||||||
|
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
hickory-resolver = { workspace = true }
|
||||||
|
lettre = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
rsa = { workspace = true }
|
||||||
|
secrecy = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
@@ -18,3 +25,7 @@ tokio = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tranquil-db-traits = { workspace = true }
|
tranquil-db-traits = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
chrono = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "io-util", "net"] }
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
|
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
|
||||||
|
use lettre::Message;
|
||||||
|
use lettre::message::dkim::{
|
||||||
|
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
|
||||||
|
DkimSigningAlgorithm, DkimSigningKey,
|
||||||
|
};
|
||||||
|
use lettre::message::header::HeaderName;
|
||||||
|
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||||
|
use rsa::pkcs8::LineEnding;
|
||||||
|
|
||||||
|
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
|
||||||
|
use crate::sender::SendError;
|
||||||
|
|
||||||
|
const SIGNED_HEADERS: &[&str] = &[
|
||||||
|
"From",
|
||||||
|
"Sender",
|
||||||
|
"Reply-To",
|
||||||
|
"To",
|
||||||
|
"Cc",
|
||||||
|
"Subject",
|
||||||
|
"Date",
|
||||||
|
"In-Reply-To",
|
||||||
|
"References",
|
||||||
|
"MIME-Version",
|
||||||
|
"Content-Type",
|
||||||
|
"Content-Transfer-Encoding",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct DkimSigner {
|
||||||
|
config: LettreDkimConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DkimSigner {
|
||||||
|
pub fn load(
|
||||||
|
selector: DkimSelector,
|
||||||
|
domain: EmailDomain,
|
||||||
|
path: DkimKeyPath,
|
||||||
|
) -> Result<Self, SendError> {
|
||||||
|
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
|
||||||
|
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
|
||||||
|
})?;
|
||||||
|
Self::from_pem(selector, domain, &pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_pem(
|
||||||
|
selector: DkimSelector,
|
||||||
|
domain: EmailDomain,
|
||||||
|
pem: &str,
|
||||||
|
) -> Result<Self, SendError> {
|
||||||
|
let key = parse_key(pem)?;
|
||||||
|
let canonicalization = DkimCanonicalization {
|
||||||
|
header: DkimCanonicalizationType::Relaxed,
|
||||||
|
body: DkimCanonicalizationType::Relaxed,
|
||||||
|
};
|
||||||
|
let headers = SIGNED_HEADERS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(HeaderName::new_from_ascii_str)
|
||||||
|
.collect();
|
||||||
|
let config = LettreDkimConfig::new(
|
||||||
|
selector.into_inner(),
|
||||||
|
domain.into_inner(),
|
||||||
|
key,
|
||||||
|
headers,
|
||||||
|
canonicalization,
|
||||||
|
);
|
||||||
|
Ok(Self { config })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(&self, message: &mut Message) {
|
||||||
|
message.sign(&self.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for DkimSigner {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("DkimSigner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
|
||||||
|
let trimmed = input.trim_start();
|
||||||
|
match trimmed {
|
||||||
|
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
|
||||||
|
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
|
||||||
|
}
|
||||||
|
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
|
||||||
|
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
|
||||||
|
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
|
||||||
|
)),
|
||||||
|
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
|
||||||
|
SendError::DkimSign(format!(
|
||||||
|
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
|
||||||
|
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
|
||||||
|
Ok(key) => {
|
||||||
|
let seed = BASE64_STANDARD.encode(key.to_bytes());
|
||||||
|
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
|
||||||
|
}
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
|
||||||
|
Ok(key) => {
|
||||||
|
let pkcs1 = key
|
||||||
|
.to_pkcs1_pem(LineEnding::LF)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
|
||||||
|
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
|
||||||
|
}
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(SendError::DkimSign(format!(
|
||||||
|
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::message::header::ContentType;
|
||||||
|
use rsa::pkcs1::DecodeRsaPrivateKey as _;
|
||||||
|
|
||||||
|
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
|
|
||||||
|
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
|
||||||
|
|
||||||
|
fn ed25519_pkcs8_pem() -> String {
|
||||||
|
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||||
|
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rsa_pkcs8_pem() -> String {
|
||||||
|
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
|
||||||
|
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signer(pem: &str) -> DkimSigner {
|
||||||
|
DkimSigner::from_pem(
|
||||||
|
DkimSelector::parse("default").unwrap(),
|
||||||
|
EmailDomain::parse("nel.pet").unwrap(),
|
||||||
|
pem,
|
||||||
|
)
|
||||||
|
.expect("key should load")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_headers(signer: &DkimSigner) -> String {
|
||||||
|
let from: Mailbox = "sender@nel.pet".parse().unwrap();
|
||||||
|
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
|
||||||
|
let mut message = Message::builder()
|
||||||
|
.from(from)
|
||||||
|
.to(to)
|
||||||
|
.subject("Roundtrip")
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body("Body".to_string())
|
||||||
|
.unwrap();
|
||||||
|
signer.sign(&mut message);
|
||||||
|
String::from_utf8(message.formatted()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_garbage() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_key("not a key"),
|
||||||
|
Err(SendError::DkimSign(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_pem_type() {
|
||||||
|
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||||
|
match parse_key(pem) {
|
||||||
|
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
|
||||||
|
other => panic!("expected unrecognized PEM error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ed25519_raw_seed_signs() {
|
||||||
|
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
|
||||||
|
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ed25519_pkcs8_pem_signs() {
|
||||||
|
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
|
||||||
|
assert_signed_with(&raw, "a=ed25519-sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsa_pkcs1_pem_signs() {
|
||||||
|
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
|
||||||
|
assert_signed_with(&raw, "a=rsa-sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsa_pkcs8_pem_signs() {
|
||||||
|
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
|
||||||
|
assert_signed_with(&raw, "a=rsa-sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_signed_with(raw: &str, algorithm: &str) {
|
||||||
|
assert!(
|
||||||
|
raw.contains("DKIM-Signature:"),
|
||||||
|
"no signature header: {raw}"
|
||||||
|
);
|
||||||
|
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
|
||||||
|
assert!(
|
||||||
|
raw.contains("c=relaxed/relaxed"),
|
||||||
|
"expected relaxed/relaxed canonicalization: {raw}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
use lettre::Message;
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::message::header::ContentType;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::types::EmailDomain;
|
||||||
|
use crate::sender::SendError;
|
||||||
|
use crate::types::QueuedComms;
|
||||||
|
|
||||||
|
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
|
||||||
|
let to: Mailbox = qc
|
||||||
|
.recipient
|
||||||
|
.parse()
|
||||||
|
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
||||||
|
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
||||||
|
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
||||||
|
Message::builder()
|
||||||
|
.from(from.clone())
|
||||||
|
.to(to)
|
||||||
|
.subject(subject)
|
||||||
|
.message_id(Some(message_id))
|
||||||
|
.header(ContentType::TEXT_PLAIN)
|
||||||
|
.body(qc.body.clone())
|
||||||
|
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendError> {
|
||||||
|
let envelope = message.envelope();
|
||||||
|
let first = envelope
|
||||||
|
.to()
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| SendError::MessageBuild("envelope has no recipients".to_string()))?;
|
||||||
|
EmailDomain::parse(first.domain())
|
||||||
|
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::{CommsChannel, CommsStatus, CommsType};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn from_mailbox() -> Mailbox {
|
||||||
|
"Test Sender <noreply@nel.pet>".parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
|
||||||
|
QueuedComms {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
channel: CommsChannel::Email,
|
||||||
|
comms_type: CommsType::Welcome,
|
||||||
|
status: CommsStatus::Pending,
|
||||||
|
recipient: recipient.to_string(),
|
||||||
|
subject: subject.map(String::from),
|
||||||
|
body: body.to_string(),
|
||||||
|
metadata: None,
|
||||||
|
attempts: 0,
|
||||||
|
max_attempts: 3,
|
||||||
|
last_error: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
scheduled_for: Utc::now(),
|
||||||
|
processed_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_basic_message() {
|
||||||
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
|
||||||
|
assert!(raw.contains("To: user@nel.pet"));
|
||||||
|
assert!(raw.contains("Subject: Welcome"));
|
||||||
|
assert!(lower.contains("content-type: text/plain"));
|
||||||
|
assert!(raw.contains("Hello world."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_subject_is_encoded() {
|
||||||
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(raw.contains("=?utf-8?"));
|
||||||
|
assert!(!raw.contains("héllo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_injection_rejected() {
|
||||||
|
let result = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subject_crlf_does_not_inject_headers() {
|
||||||
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
||||||
|
)
|
||||||
|
.expect("subject CRLF should be encoded, not rejected");
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!raw.contains("Bcc:"),
|
||||||
|
"CRLF in subject must not produce a Bcc header: {raw}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
raw.contains("Subject: ="),
|
||||||
|
"subject with non-printable chars should be RFC 2047 encoded: {raw}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_id_uses_from_domain() {
|
||||||
|
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
let line = raw
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("Message-ID:") || l.starts_with("Message-Id:"))
|
||||||
|
.expect("message-id header present");
|
||||||
|
assert!(
|
||||||
|
line.contains("@nel.pet>"),
|
||||||
|
"message-id should use From domain: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_subject_uses_default() {
|
||||||
|
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(raw.contains("Subject: Notification"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recipient_domain_extracted() {
|
||||||
|
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
|
||||||
|
let d = recipient_domain(&msg).unwrap();
|
||||||
|
assert_eq!(d.as_str(), "nel.pet");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
pub mod dkim;
|
||||||
|
pub mod message;
|
||||||
|
mod mx;
|
||||||
|
pub mod transport;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use hickory_resolver::TokioAsyncResolver;
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||||
|
use lettre::transport::smtp::PoolConfig;
|
||||||
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
|
use lettre::transport::smtp::extension::ClientId;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
pub use self::dkim::DkimSigner;
|
||||||
|
pub use self::transport::SendMode;
|
||||||
|
use self::types::{
|
||||||
|
DkimKeyPath, DkimSelector, EmailDomain, HeloName, SmtpHost, SmtpPassword, SmtpPort,
|
||||||
|
SmtpUsername, TlsMode,
|
||||||
|
};
|
||||||
|
use crate::sender::{CommsSender, SendError};
|
||||||
|
use crate::types::{CommsChannel, QueuedComms};
|
||||||
|
|
||||||
|
pub struct EmailSender {
|
||||||
|
from: Mailbox,
|
||||||
|
mode: SendMode,
|
||||||
|
dkim: Option<DkimSigner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailSender {
|
||||||
|
pub fn new(from: Mailbox, mode: SendMode, dkim: Option<DkimSigner>) -> Self {
|
||||||
|
Self { from, mode, dkim }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Result<Option<Self>, SendError> {
|
||||||
|
let Some(from_address) = cfg.email.from_address.as_deref().filter(|s| !s.is_empty()) else {
|
||||||
|
info!("Email sender disabled: MAIL_FROM_ADDRESS unset");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let from = build_from(&cfg.email.from_name, from_address)?;
|
||||||
|
let dkim = build_dkim(&cfg.email.dkim)?;
|
||||||
|
let mode = match cfg
|
||||||
|
.email
|
||||||
|
.smarthost
|
||||||
|
.host
|
||||||
|
.as_deref()
|
||||||
|
.filter(|h| !h.is_empty())
|
||||||
|
{
|
||||||
|
Some(host) => build_smarthost(cfg, host)?,
|
||||||
|
None => build_direct_mx(cfg)?,
|
||||||
|
};
|
||||||
|
info!(?mode, dkim = dkim.is_some(), "Email sender initialized");
|
||||||
|
Ok(Some(Self { from, mode, dkim }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_invalid(field: &str, error: impl std::fmt::Display) -> SendError {
|
||||||
|
SendError::ConfigInvalid(format!("{field}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_from(from_name: &str, from_address: &str) -> Result<Mailbox, SendError> {
|
||||||
|
let raw = match from_name.is_empty() {
|
||||||
|
true => from_address.to_string(),
|
||||||
|
false => format!("\"{}\" <{}>", from_name.replace('"', "'"), from_address),
|
||||||
|
};
|
||||||
|
raw.parse::<Mailbox>()
|
||||||
|
.map_err(|e| config_invalid("MAIL_FROM_ADDRESS / MAIL_FROM_NAME", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_smarthost(
|
||||||
|
cfg: &tranquil_config::TranquilConfig,
|
||||||
|
host_raw: &str,
|
||||||
|
) -> Result<SendMode, SendError> {
|
||||||
|
let host = SmtpHost::parse(host_raw).map_err(|e| config_invalid("MAIL_SMARTHOST_HOST", e))?;
|
||||||
|
let port = SmtpPort::parse(cfg.email.smarthost.port)
|
||||||
|
.map_err(|e| config_invalid("MAIL_SMARTHOST_PORT", e))?;
|
||||||
|
let tls = TlsMode::parse(&cfg.email.smarthost.tls)
|
||||||
|
.map_err(|e| config_invalid("MAIL_SMARTHOST_TLS", e))?;
|
||||||
|
let helo = resolve_helo(cfg)?;
|
||||||
|
let pool = PoolConfig::new()
|
||||||
|
.max_size(cfg.email.smarthost.pool_size)
|
||||||
|
.idle_timeout(Duration::from_secs(60));
|
||||||
|
let command_timeout = Duration::from_secs(cfg.email.smarthost.command_timeout_secs);
|
||||||
|
let total_timeout = Duration::from_secs(cfg.email.smarthost.total_timeout_secs);
|
||||||
|
|
||||||
|
let builder = match tls {
|
||||||
|
TlsMode::Implicit => AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(host.as_str())
|
||||||
|
.map_err(|e| config_invalid("smarthost TLS setup", e))?,
|
||||||
|
TlsMode::Starttls => {
|
||||||
|
AsyncSmtpTransport::<lettre::Tokio1Executor>::starttls_relay(host.as_str())
|
||||||
|
.map_err(|e| config_invalid("smarthost TLS setup", e))?
|
||||||
|
}
|
||||||
|
TlsMode::None => {
|
||||||
|
AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host.as_str())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let builder = builder
|
||||||
|
.port(port.as_u16())
|
||||||
|
.hello_name(ClientId::Domain(helo.into_inner()))
|
||||||
|
.timeout(Some(command_timeout))
|
||||||
|
.pool_config(pool);
|
||||||
|
let builder = match (
|
||||||
|
cfg.email.smarthost.username.as_deref(),
|
||||||
|
cfg.email.smarthost.password.as_deref(),
|
||||||
|
) {
|
||||||
|
(Some(u), Some(p)) => {
|
||||||
|
let username =
|
||||||
|
SmtpUsername::parse(u).map_err(|e| config_invalid("MAIL_SMARTHOST_USERNAME", e))?;
|
||||||
|
let password =
|
||||||
|
SmtpPassword::parse(p).map_err(|e| config_invalid("MAIL_SMARTHOST_PASSWORD", e))?;
|
||||||
|
builder.credentials(Credentials::new(
|
||||||
|
username.into_inner(),
|
||||||
|
password.expose().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => builder,
|
||||||
|
};
|
||||||
|
Ok(SendMode::Smarthost {
|
||||||
|
transport: Box::new(builder.build()),
|
||||||
|
total_timeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
|
||||||
|
let helo = resolve_helo(cfg)?;
|
||||||
|
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||||||
|
.map(Arc::new)
|
||||||
|
.map_err(|e| config_invalid("system DNS configuration", e))?;
|
||||||
|
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
|
||||||
|
Ok(SendMode::DirectMx {
|
||||||
|
resolver,
|
||||||
|
helo,
|
||||||
|
command_timeout: Duration::from_secs(cfg.email.direct_mx.command_timeout_secs),
|
||||||
|
total_timeout: Duration::from_secs(cfg.email.direct_mx.total_timeout_secs),
|
||||||
|
require_tls: cfg.email.direct_mx.require_tls,
|
||||||
|
inflight: Arc::new(Semaphore::new(max_concurrent)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_helo(cfg: &tranquil_config::TranquilConfig) -> Result<HeloName, SendError> {
|
||||||
|
let raw = cfg
|
||||||
|
.email
|
||||||
|
.helo_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cfg.server.hostname_without_port().to_string());
|
||||||
|
HeloName::parse(&raw).map_err(|e| config_invalid(&format!("HELO name {raw:?}"), e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, SendError> {
|
||||||
|
let selector = match cfg.selector.as_deref() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
let domain = cfg
|
||||||
|
.domain
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| SendError::DkimSign("MAIL_DKIM_DOMAIN required when selector set".into()))?;
|
||||||
|
let key_path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||||
|
SendError::DkimSign("MAIL_DKIM_KEY_PATH required when selector set".into())
|
||||||
|
})?;
|
||||||
|
let selector = DkimSelector::parse(selector)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("invalid DKIM selector: {e}")))?;
|
||||||
|
let domain = EmailDomain::parse(domain)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("invalid DKIM domain: {e}")))?;
|
||||||
|
let path = DkimKeyPath::parse(key_path)
|
||||||
|
.map_err(|e| SendError::DkimSign(format!("DKIM key path invalid: {e}")))?;
|
||||||
|
DkimSigner::load(selector, domain, path).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CommsSender for EmailSender {
|
||||||
|
fn channel(&self) -> CommsChannel {
|
||||||
|
CommsChannel::Email
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||||
|
let mut message = message::build(&self.from, notification)?;
|
||||||
|
if let Some(signer) = &self.dkim {
|
||||||
|
signer.sign(&mut message);
|
||||||
|
}
|
||||||
|
match transport::dispatch(&self.mode, message).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(comms_id = %notification.id, error = %e, "SMTP send failed");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
use hickory_resolver::TokioAsyncResolver;
|
||||||
|
use hickory_resolver::error::{ResolveError, ResolveErrorKind};
|
||||||
|
use hickory_resolver::proto::op::ResponseCode;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
|
||||||
|
use super::types::{EmailDomain, MxHost, MxPriority, MxRecord};
|
||||||
|
use crate::sender::SendError;
|
||||||
|
|
||||||
|
pub async fn resolve(
|
||||||
|
resolver: &TokioAsyncResolver,
|
||||||
|
domain: &EmailDomain,
|
||||||
|
) -> Result<Vec<MxRecord>, SendError> {
|
||||||
|
match resolver.mx_lookup(domain.as_str()).await {
|
||||||
|
Ok(lookup) => interpret_lookup(
|
||||||
|
lookup
|
||||||
|
.iter()
|
||||||
|
.map(|mx| (mx.preference(), mx.exchange().clone())),
|
||||||
|
domain,
|
||||||
|
),
|
||||||
|
Err(e) => classify_lookup_error(e, domain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interpret_lookup(
|
||||||
|
items: impl IntoIterator<Item = (u16, hickory_resolver::Name)>,
|
||||||
|
domain: &EmailDomain,
|
||||||
|
) -> Result<Vec<MxRecord>, SendError> {
|
||||||
|
let entries: Vec<_> = items.into_iter().collect();
|
||||||
|
match entries.iter().any(|(_, name)| name.is_root()) {
|
||||||
|
true => Err(SendError::DnsPermanent(format!(
|
||||||
|
"null MX record at {}: domain refuses mail",
|
||||||
|
domain.as_str()
|
||||||
|
))),
|
||||||
|
false => {
|
||||||
|
let records: Vec<MxRecord> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(prio, name)| {
|
||||||
|
MxHost::parse(&name.to_utf8()).ok().map(|host| MxRecord {
|
||||||
|
priority: MxPriority::new(prio),
|
||||||
|
host,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match records.is_empty() {
|
||||||
|
true => implicit_mx(domain),
|
||||||
|
false => Ok(prioritize(records)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prioritize(mut records: Vec<MxRecord>) -> Vec<MxRecord> {
|
||||||
|
records.shuffle(&mut rand::thread_rng());
|
||||||
|
records.sort_by_key(|r| r.priority);
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_lookup_error(
|
||||||
|
e: ResolveError,
|
||||||
|
domain: &EmailDomain,
|
||||||
|
) -> Result<Vec<MxRecord>, SendError> {
|
||||||
|
match e.kind() {
|
||||||
|
ResolveErrorKind::NoRecordsFound { response_code, .. } => match *response_code {
|
||||||
|
ResponseCode::NoError => implicit_mx(domain),
|
||||||
|
ResponseCode::NXDomain => Err(SendError::DnsPermanent(format!(
|
||||||
|
"domain {} does not exist",
|
||||||
|
domain.as_str()
|
||||||
|
))),
|
||||||
|
other => Err(SendError::DnsTransient(format!(
|
||||||
|
"MX lookup for {} failed with {other}",
|
||||||
|
domain.as_str()
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
_ => Err(SendError::DnsTransient(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn implicit_mx(domain: &EmailDomain) -> Result<Vec<MxRecord>, SendError> {
|
||||||
|
MxHost::parse(domain.as_str())
|
||||||
|
.map(|host| {
|
||||||
|
vec![MxRecord {
|
||||||
|
priority: MxPriority::new(0),
|
||||||
|
host,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.map_err(|e| SendError::DnsPermanent(format!("invalid recipient domain: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn record(prio: u16, host: &str) -> MxRecord {
|
||||||
|
MxRecord {
|
||||||
|
priority: MxPriority::new(prio),
|
||||||
|
host: MxHost::parse(host).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prioritize_sorts_by_priority_ascending() {
|
||||||
|
let result = prioritize(vec![
|
||||||
|
record(20, "mx2.nel.pet"),
|
||||||
|
record(10, "mx1.nel.pet"),
|
||||||
|
record(10, "mx1b.nel.pet"),
|
||||||
|
]);
|
||||||
|
assert_eq!(result[0].priority.as_u16(), 10);
|
||||||
|
assert_eq!(result[1].priority.as_u16(), 10);
|
||||||
|
assert_eq!(result[2].priority.as_u16(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prioritize_randomizes_equal_priority_order() {
|
||||||
|
let attempts: Vec<Vec<String>> = (0..200)
|
||||||
|
.map(|_| {
|
||||||
|
prioritize(vec![
|
||||||
|
record(10, "a.nel.pet"),
|
||||||
|
record(10, "b.nel.pet"),
|
||||||
|
record(10, "c.nel.pet"),
|
||||||
|
record(10, "d.nel.pet"),
|
||||||
|
])
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.host.as_str().to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let distinct: std::collections::HashSet<_> = attempts.iter().cloned().collect();
|
||||||
|
assert!(
|
||||||
|
distinct.len() > 1,
|
||||||
|
"equal-priority MX order should vary across calls; got only {}",
|
||||||
|
distinct.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_mx_uses_domain_as_host() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let result = implicit_mx(&d).unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].priority.as_u16(), 0);
|
||||||
|
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_error_response_yields_implicit_mx() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||||
|
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||||
|
soa: None,
|
||||||
|
negative_ttl: None,
|
||||||
|
response_code: ResponseCode::NoError,
|
||||||
|
trusted: false,
|
||||||
|
});
|
||||||
|
let result = classify_lookup_error(err, &d).unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nxdomain_response_is_permanent() {
|
||||||
|
let d = EmailDomain::parse("does-not-exist.invalid").unwrap();
|
||||||
|
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||||
|
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||||
|
soa: None,
|
||||||
|
negative_ttl: None,
|
||||||
|
response_code: ResponseCode::NXDomain,
|
||||||
|
trusted: true,
|
||||||
|
});
|
||||||
|
match classify_lookup_error(err, &d) {
|
||||||
|
Err(SendError::DnsPermanent(_)) => {}
|
||||||
|
other => panic!("expected DnsPermanent, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn servfail_response_is_transient() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let err = ResolveError::from(ResolveErrorKind::NoRecordsFound {
|
||||||
|
query: Box::new(hickory_resolver::proto::op::Query::default()),
|
||||||
|
soa: None,
|
||||||
|
negative_ttl: None,
|
||||||
|
response_code: ResponseCode::ServFail,
|
||||||
|
trusted: false,
|
||||||
|
});
|
||||||
|
match classify_lookup_error(err, &d) {
|
||||||
|
Err(SendError::DnsTransient(_)) => {}
|
||||||
|
other => panic!("expected DnsTransient, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeout_is_transient() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let err = ResolveError::from(ResolveErrorKind::Timeout);
|
||||||
|
match classify_lookup_error(err, &d) {
|
||||||
|
Err(SendError::DnsTransient(_)) => {}
|
||||||
|
other => panic!("expected DnsTransient, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_variant_is_transient() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let err = ResolveError::from(ResolveErrorKind::Message("transient resolver glitch"));
|
||||||
|
match classify_lookup_error(err, &d) {
|
||||||
|
Err(SendError::DnsTransient(_)) => {}
|
||||||
|
other => panic!("expected DnsTransient default, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_mx_is_permanent() {
|
||||||
|
let d = EmailDomain::parse("nomail.nel.pet").unwrap();
|
||||||
|
let result = interpret_lookup(vec![(0, hickory_resolver::Name::root())], &d);
|
||||||
|
match result {
|
||||||
|
Err(SendError::DnsPermanent(msg)) => {
|
||||||
|
assert!(msg.contains("null MX"), "msg: {msg}")
|
||||||
|
}
|
||||||
|
other => panic!("expected DnsPermanent, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_mx_alongside_real_records_still_permanent() {
|
||||||
|
let d = EmailDomain::parse("mixed.nel.pet").unwrap();
|
||||||
|
let real = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
|
||||||
|
let result = interpret_lookup(vec![(10, real), (0, hickory_resolver::Name::root())], &d);
|
||||||
|
assert!(matches!(result, Err(SendError::DnsPermanent(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_lookup_uses_implicit_mx() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let result = interpret_lookup(Vec::<(u16, hickory_resolver::Name)>::new(), &d).unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].host.as_str(), "nel.pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_records_pass_through_with_priority_sort() {
|
||||||
|
let d = EmailDomain::parse("nel.pet").unwrap();
|
||||||
|
let mx1 = hickory_resolver::Name::from_ascii("mx1.nel.pet.").unwrap();
|
||||||
|
let mx2 = hickory_resolver::Name::from_ascii("mx2.nel.pet.").unwrap();
|
||||||
|
let result = interpret_lookup(vec![(20, mx2), (10, mx1)], &d).unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0].priority.as_u16(), 10);
|
||||||
|
assert_eq!(result[0].host.as_str(), "mx1.nel.pet");
|
||||||
|
assert_eq!(result[1].priority.as_u16(), 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
|
||||||
|
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
|
||||||
|
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
|
||||||
|
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
|
||||||
|
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
|
||||||
|
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
|
||||||
|
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
|
||||||
|
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
|
||||||
|
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
|
||||||
|
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
|
||||||
|
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
|
||||||
|
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
|
||||||
|
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
|
||||||
|
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
|
||||||
|
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
|
||||||
|
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
|
||||||
|
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
|
||||||
|
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
|
||||||
|
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
|
||||||
|
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
|
||||||
|
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
|
||||||
|
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
|
||||||
|
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
|
||||||
|
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
|
||||||
|
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use hickory_resolver::TokioAsyncResolver;
|
||||||
|
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||||
|
use lettre::transport::smtp::Error as SmtpError;
|
||||||
|
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||||
|
use lettre::transport::smtp::extension::ClientId;
|
||||||
|
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
use super::message::recipient_domain;
|
||||||
|
use super::mx;
|
||||||
|
use super::types::{HeloName, MxRecord};
|
||||||
|
use crate::sender::SendError;
|
||||||
|
|
||||||
|
pub enum SendMode {
|
||||||
|
Smarthost {
|
||||||
|
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
||||||
|
total_timeout: Duration,
|
||||||
|
},
|
||||||
|
DirectMx {
|
||||||
|
resolver: Arc<TokioAsyncResolver>,
|
||||||
|
helo: HeloName,
|
||||||
|
command_timeout: Duration,
|
||||||
|
total_timeout: Duration,
|
||||||
|
require_tls: bool,
|
||||||
|
inflight: Arc<Semaphore>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SendMode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Smarthost { total_timeout, .. } => {
|
||||||
|
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
|
||||||
|
}
|
||||||
|
Self::DirectMx {
|
||||||
|
helo, require_tls, ..
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"SendMode::DirectMx({}, require_tls={require_tls})",
|
||||||
|
helo.as_str()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
|
||||||
|
match mode {
|
||||||
|
SendMode::Smarthost {
|
||||||
|
transport,
|
||||||
|
total_timeout,
|
||||||
|
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
||||||
|
SendMode::DirectMx {
|
||||||
|
resolver,
|
||||||
|
helo,
|
||||||
|
command_timeout,
|
||||||
|
total_timeout,
|
||||||
|
require_tls,
|
||||||
|
inflight,
|
||||||
|
} => {
|
||||||
|
with_total_timeout(*total_timeout, async {
|
||||||
|
let _permit =
|
||||||
|
inflight.clone().acquire_owned().await.map_err(|_| {
|
||||||
|
SendError::SmtpTransient("send semaphore closed".to_string())
|
||||||
|
})?;
|
||||||
|
send_direct(
|
||||||
|
resolver.as_ref(),
|
||||||
|
helo,
|
||||||
|
*command_timeout,
|
||||||
|
*require_tls,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
|
||||||
|
total: Duration,
|
||||||
|
fut: F,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
tokio::time::timeout(total, fut)
|
||||||
|
.await
|
||||||
|
.unwrap_or(Err(SendError::Timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_send(
|
||||||
|
transport: &AsyncSmtpTransport<Tokio1Executor>,
|
||||||
|
message: Message,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
transport
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(classify_smtp_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_direct(
|
||||||
|
resolver: &TokioAsyncResolver,
|
||||||
|
helo: &HeloName,
|
||||||
|
command_timeout: Duration,
|
||||||
|
require_tls: bool,
|
||||||
|
message: Message,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let domain = recipient_domain(&message)?;
|
||||||
|
let mxs = mx::resolve(resolver, &domain).await?;
|
||||||
|
let outcome = futures::stream::iter(mxs)
|
||||||
|
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
|
||||||
|
let message = message.clone();
|
||||||
|
async move {
|
||||||
|
match &acc {
|
||||||
|
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
|
||||||
|
_ => Some(
|
||||||
|
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
|
||||||
|
.await,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
outcome.unwrap_or_else(|| {
|
||||||
|
Err(SendError::SmtpTransient(format!(
|
||||||
|
"no MX records returned for {}",
|
||||||
|
domain.as_str()
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attempt_one_host(
|
||||||
|
mx_record: MxRecord,
|
||||||
|
helo: &HeloName,
|
||||||
|
command_timeout: Duration,
|
||||||
|
require_tls: bool,
|
||||||
|
message: Message,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let host = mx_record.host.as_str().to_string();
|
||||||
|
let tls_params = TlsParameters::new(host.clone())
|
||||||
|
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
|
||||||
|
let tls = match require_tls {
|
||||||
|
true => Tls::Required(tls_params),
|
||||||
|
false => Tls::Opportunistic(tls_params),
|
||||||
|
};
|
||||||
|
let transport: AsyncSmtpTransport<Tokio1Executor> =
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
|
||||||
|
.port(25)
|
||||||
|
.tls(tls)
|
||||||
|
.hello_name(ClientId::Domain(helo.as_str().to_string()))
|
||||||
|
.timeout(Some(command_timeout))
|
||||||
|
.build();
|
||||||
|
run_send(&transport, message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_smtp_error(e: SmtpError) -> SendError {
|
||||||
|
match () {
|
||||||
|
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
|
||||||
|
_ if e.is_timeout() => SendError::Timeout,
|
||||||
|
_ => SendError::SmtpTransient(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ParseError {
|
||||||
|
#[error("empty value")]
|
||||||
|
Empty,
|
||||||
|
#[error("invalid character {0:?}")]
|
||||||
|
InvalidChar(char),
|
||||||
|
#[error("zero {0}")]
|
||||||
|
Zero(&'static str),
|
||||||
|
#[error("invalid TLS mode {0:?}")]
|
||||||
|
InvalidTlsMode(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_token(raw: &str, lowercase: bool, strip_trailing_dot: bool) -> Result<String, ParseError> {
|
||||||
|
let mut s = raw.trim();
|
||||||
|
if strip_trailing_dot {
|
||||||
|
s = s.trim_end_matches('.');
|
||||||
|
}
|
||||||
|
match s {
|
||||||
|
"" => Err(ParseError::Empty),
|
||||||
|
_ if s.chars().any(char::is_whitespace) => Err(ParseError::InvalidChar(' ')),
|
||||||
|
_ => Ok(match lowercase {
|
||||||
|
true => s.to_lowercase(),
|
||||||
|
false => s.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SmtpHost(String);
|
||||||
|
|
||||||
|
impl SmtpHost {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
parse_token(raw, true, false).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SmtpPort(u16);
|
||||||
|
|
||||||
|
impl SmtpPort {
|
||||||
|
pub fn parse(raw: u16) -> Result<Self, ParseError> {
|
||||||
|
match raw {
|
||||||
|
0 => Err(ParseError::Zero("smtp port")),
|
||||||
|
n => Ok(Self(n)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_u16(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HeloName(String);
|
||||||
|
|
||||||
|
impl HeloName {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
parse_token(raw, false, false).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct EmailDomain(String);
|
||||||
|
|
||||||
|
impl EmailDomain {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
parse_token(raw, true, true).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MxHost(String);
|
||||||
|
|
||||||
|
impl MxHost {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
parse_token(raw, true, true).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct MxPriority(u16);
|
||||||
|
|
||||||
|
impl MxPriority {
|
||||||
|
pub fn new(value: u16) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_u16(self) -> u16 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MxRecord {
|
||||||
|
pub priority: MxPriority,
|
||||||
|
pub host: MxHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DkimSelector(String);
|
||||||
|
|
||||||
|
impl DkimSelector {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
let valid = !trimmed.is_empty() && trimmed.split('.').all(valid_subdomain);
|
||||||
|
match valid {
|
||||||
|
true => Ok(Self(trimmed.to_string())),
|
||||||
|
false => Err(ParseError::InvalidChar('?')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_subdomain(seg: &str) -> bool {
|
||||||
|
let starts_alnum = seg
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric());
|
||||||
|
let ends_alnum = seg
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric());
|
||||||
|
let body_ok = seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
|
||||||
|
starts_alnum && ends_alnum && body_ok
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DkimKeyPath(PathBuf);
|
||||||
|
|
||||||
|
impl DkimKeyPath {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
match trimmed.is_empty() {
|
||||||
|
true => Err(ParseError::Empty),
|
||||||
|
false => Ok(Self(PathBuf::from(trimmed))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_path(&self) -> &std::path::Path {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SmtpUsername(String);
|
||||||
|
|
||||||
|
impl SmtpUsername {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
match raw.is_empty() {
|
||||||
|
true => Err(ParseError::Empty),
|
||||||
|
false => Ok(Self(raw.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SmtpPassword(secrecy::SecretString);
|
||||||
|
|
||||||
|
impl SmtpPassword {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
match raw.is_empty() {
|
||||||
|
true => Err(ParseError::Empty),
|
||||||
|
false => Ok(Self(secrecy::SecretString::from(raw.to_string()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expose(&self) -> &str {
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
self.0.expose_secret()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SmtpPassword {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("SmtpPassword(***)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TlsMode {
|
||||||
|
Implicit,
|
||||||
|
Starttls,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsMode {
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, ParseError> {
|
||||||
|
match raw.to_ascii_lowercase().as_str() {
|
||||||
|
"implicit" => Ok(Self::Implicit),
|
||||||
|
"starttls" => Ok(Self::Starttls),
|
||||||
|
"none" => Ok(Self::None),
|
||||||
|
other => Err(ParseError::InvalidTlsMode(other.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_host_lowercases_and_trims() {
|
||||||
|
let h = SmtpHost::parse(" SMTP.NEL.PET ").unwrap();
|
||||||
|
assert_eq!(h.as_str(), "smtp.nel.pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_host_rejects_whitespace() {
|
||||||
|
assert!(SmtpHost::parse("a b").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_host_rejects_empty() {
|
||||||
|
assert!(SmtpHost::parse("").is_err());
|
||||||
|
assert!(SmtpHost::parse(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_port_rejects_zero() {
|
||||||
|
assert!(SmtpPort::parse(0).is_err());
|
||||||
|
assert_eq!(SmtpPort::parse(587).unwrap().as_u16(), 587);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_domain_strips_trailing_dot() {
|
||||||
|
assert_eq!(EmailDomain::parse("Nel.pet.").unwrap().as_str(), "nel.pet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dkim_selector_validates() {
|
||||||
|
assert!(DkimSelector::parse("default").is_ok());
|
||||||
|
assert!(DkimSelector::parse("s1.nel.pet").is_ok());
|
||||||
|
assert!(DkimSelector::parse("s2024-q1").is_ok());
|
||||||
|
assert!(DkimSelector::parse("mailo-2024.nel.pet").is_ok());
|
||||||
|
assert!(DkimSelector::parse("a-b").is_ok());
|
||||||
|
assert!(DkimSelector::parse("").is_err());
|
||||||
|
assert!(DkimSelector::parse("a..b").is_err());
|
||||||
|
assert!(DkimSelector::parse("-leading").is_err());
|
||||||
|
assert!(DkimSelector::parse("trailing-").is_err());
|
||||||
|
assert!(DkimSelector::parse("s_under").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_mode_parses_known_modes() {
|
||||||
|
assert_eq!(TlsMode::parse("STARTTLS").unwrap(), TlsMode::Starttls);
|
||||||
|
assert_eq!(TlsMode::parse("implicit").unwrap(), TlsMode::Implicit);
|
||||||
|
assert_eq!(TlsMode::parse("none").unwrap(), TlsMode::None);
|
||||||
|
assert!(TlsMode::parse("garbage").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smtp_password_redacts_in_debug() {
|
||||||
|
let p = SmtpPassword::parse("hunter2").unwrap();
|
||||||
|
let dbg = format!("{:?}", p);
|
||||||
|
assert_eq!(dbg, "SmtpPassword(***)");
|
||||||
|
assert!(!dbg.contains("hunter2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
|
pub mod email;
|
||||||
mod locale;
|
mod locale;
|
||||||
mod sender;
|
mod sender;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
|
pub use email::EmailSender;
|
||||||
pub use locale::{
|
pub use locale::{
|
||||||
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
|
DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings,
|
||||||
validate_locale,
|
validate_locale,
|
||||||
};
|
};
|
||||||
pub use sender::{
|
pub use sender::{
|
||||||
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
|
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
|
||||||
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
|
is_valid_signal_username,
|
||||||
};
|
};
|
||||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pub const DEFAULT_LOCALE: &str = "en";
|
pub const DEFAULT_LOCALE: &str = "en";
|
||||||
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi"];
|
pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi", "fr"];
|
||||||
|
|
||||||
pub fn validate_locale(locale: &str) -> &str {
|
pub fn validate_locale(locale: &str) -> &str {
|
||||||
if VALID_LOCALES.contains(&locale) {
|
if VALID_LOCALES.contains(&locale) {
|
||||||
@@ -44,6 +44,7 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings {
|
|||||||
"ko" => &STRINGS_KO,
|
"ko" => &STRINGS_KO,
|
||||||
"sv" => &STRINGS_SV,
|
"sv" => &STRINGS_SV,
|
||||||
"fi" => &STRINGS_FI,
|
"fi" => &STRINGS_FI,
|
||||||
|
"fr" => &STRINGS_FR,
|
||||||
_ => &STRINGS_EN,
|
_ => &STRINGS_EN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +217,34 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
|
|||||||
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
|
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static STRINGS_FR: NotificationStrings = NotificationStrings {
|
||||||
|
welcome_subject: "Bienvenue sur {hostname}",
|
||||||
|
welcome_body: "Bienvenue sur {hostname} !\n\nVotre identifiant est : @{handle}\n\nMerci de nous avoir rejoint.",
|
||||||
|
password_reset_subject: "Réinitialisation du mot de passe - {hostname}",
|
||||||
|
password_reset_body: "Bonjour @{handle},\n\nVotre code de réinitialisation du mot de passe est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message.",
|
||||||
|
email_update_subject: "Confirmer votre nouvelle adresse e-mail - {hostname}",
|
||||||
|
email_update_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 10 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
|
||||||
|
short_token_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCe code expirera dans 15 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
|
||||||
|
account_deletion_subject: "Demande de suppression de compte - {hostname}",
|
||||||
|
account_deletion_body: "Bonjour @{handle},\n\nVotre code de confirmation de suppression de compte est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
|
||||||
|
plc_operation_subject: "{hostname} - Jeton d'opération PLC",
|
||||||
|
plc_operation_body: "Bonjour @{handle},\n\nVous avez demandé à signer une opération PLC pour votre compte.\n\nVotre jeton de vérification est : {token}\n\nCe jeton expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, vous pouvez ignorer ce message en toute sécurité.",
|
||||||
|
two_factor_code_subject: "Vérification de connexion - {hostname}",
|
||||||
|
two_factor_code_body: "Bonjour @{handle},\n\nVotre code de vérification de connexion est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.",
|
||||||
|
passkey_recovery_subject: "Récupération de compte - {hostname}",
|
||||||
|
passkey_recovery_body: "Bonjour @{handle},\n\nVous avez demandé la récupération de votre compte à clé d'accès uniquement.\n\nCliquez sur le lien ci-dessous pour définir un mot de passe temporaire et retrouver l'accès :\n{url}\n\nCe lien expirera dans 1 heure.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message. Votre compte reste sécurisé.",
|
||||||
|
signup_verification_subject: "Vérifier votre compte - {hostname}",
|
||||||
|
signup_verification_body: "Bienvenue ! Votre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 30 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas créé de compte sur {hostname}, veuillez ignorer ce message.",
|
||||||
|
legacy_login_subject: "Alerte de sécurité : Connexion classique détectée - {hostname}",
|
||||||
|
legacy_login_body: "Bonjour @{handle},\n\nUne connexion à votre compte a été détectée via une application classique (comme Bluesky) qui ne prend pas en charge la vérification TOTP.\n\nDétails :\n- Date : {timestamp}\n- Adresse IP : {ip}\n\nVotre protection TOTP a été contournée pour cette connexion. La session dispose de permissions limitées pour les opérations sensibles.\n\nSi ce n'était pas vous :\n1. Changez votre mot de passe immédiatement\n2. Vérifiez vos sessions actives\n3. Envisagez de désactiver les connexions d'applications classiques dans vos paramètres de sécurité\n\nRestez vigilant,\n{hostname}",
|
||||||
|
migration_verification_subject: "Vérifier votre adresse e-mail - {hostname}",
|
||||||
|
migration_verification_body: "Bienvenue sur {hostname} !\n\nVotre compte a été migré avec succès. Pour finaliser la configuration, veuillez vérifier votre adresse e-mail.\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 48 heures.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas migré votre compte, veuillez ignorer cet e-mail.",
|
||||||
|
channel_verified_subject: "Canal de notification vérifié - {hostname}",
|
||||||
|
channel_verified_body: "Bonjour {handle},\n\n{channel} a été vérifié comme canal de notification pour votre compte sur {hostname}.",
|
||||||
|
channel_verification_subject: "Vérifier votre canal - {hostname}",
|
||||||
|
channel_verification_body: "Votre code de vérification est :\n{code}\n\nOu vérifiez directement :\n{verify_link}",
|
||||||
|
};
|
||||||
|
|
||||||
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
|
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
|
||||||
vars.iter()
|
vars.iter()
|
||||||
.fold(template.to_string(), |result, (key, value)| {
|
.fold(template.to_string(), |result, (key, value)| {
|
||||||
@@ -233,6 +262,9 @@ mod tests {
|
|||||||
assert_eq!(validate_locale("zh"), "zh");
|
assert_eq!(validate_locale("zh"), "zh");
|
||||||
assert_eq!(validate_locale("ja"), "ja");
|
assert_eq!(validate_locale("ja"), "ja");
|
||||||
assert_eq!(validate_locale("ko"), "ko");
|
assert_eq!(validate_locale("ko"), "ko");
|
||||||
|
assert_eq!(validate_locale("sv"), "sv");
|
||||||
|
assert_eq!(validate_locale("fi"), "fi");
|
||||||
|
assert_eq!(validate_locale("fr"), "fr");
|
||||||
assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE);
|
assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE);
|
||||||
assert_eq!(validate_locale(""), DEFAULT_LOCALE);
|
assert_eq!(validate_locale(""), DEFAULT_LOCALE);
|
||||||
}
|
}
|
||||||
@@ -252,5 +284,9 @@ mod tests {
|
|||||||
let zh = get_strings("zh");
|
let zh = get_strings("zh");
|
||||||
assert!(zh.welcome_subject.contains("{hostname}"));
|
assert!(zh.welcome_subject.contains("{hostname}"));
|
||||||
assert!(zh.welcome_body.contains("欢迎"));
|
assert!(zh.welcome_body.contains("欢迎"));
|
||||||
|
|
||||||
|
let fr = get_strings("fr");
|
||||||
|
assert!(fr.welcome_subject.contains("{hostname}"));
|
||||||
|
assert!(fr.welcome_body.contains("Bienvenue"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::process::Stdio;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::process::Command;
|
|
||||||
|
|
||||||
use super::types::{CommsChannel, QueuedComms};
|
use super::types::{CommsChannel, QueuedComms};
|
||||||
|
|
||||||
@@ -21,25 +17,51 @@ pub trait CommsSender: Send + Sync {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SendError {
|
pub enum SendError {
|
||||||
#[error("Failed to spawn {command}: {source}")]
|
|
||||||
ProcessSpawn {
|
|
||||||
command: String,
|
|
||||||
source: std::io::Error,
|
|
||||||
},
|
|
||||||
#[error("{command} exited with non-zero status: {detail}")]
|
|
||||||
ProcessFailed { command: String, detail: String },
|
|
||||||
#[error("Channel not configured: {0:?}")]
|
#[error("Channel not configured: {0:?}")]
|
||||||
NotConfigured(CommsChannel),
|
NotConfigured(CommsChannel),
|
||||||
#[error("External service error: {0}")]
|
#[error("Email configuration invalid: {0}")]
|
||||||
ExternalService(String),
|
ConfigInvalid(String),
|
||||||
#[error("Invalid recipient format: {0}")]
|
#[error("Invalid recipient format: {0}")]
|
||||||
InvalidRecipient(String),
|
InvalidRecipient(String),
|
||||||
|
#[error("Message construction failed: {0}")]
|
||||||
|
MessageBuild(String),
|
||||||
|
#[error("transient DNS lookup failure: {0}")]
|
||||||
|
DnsTransient(String),
|
||||||
|
#[error("permanent DNS lookup failure: {0}")]
|
||||||
|
DnsPermanent(String),
|
||||||
|
#[error("SMTP transient error: {0}")]
|
||||||
|
SmtpTransient(String),
|
||||||
|
#[error("SMTP permanent error: {0}")]
|
||||||
|
SmtpPermanent(String),
|
||||||
|
#[error("DKIM signing failed: {0}")]
|
||||||
|
DkimSign(String),
|
||||||
|
#[error("External service error: {0}")]
|
||||||
|
ExternalService(String),
|
||||||
#[error("Request timeout")]
|
#[error("Request timeout")]
|
||||||
Timeout,
|
Timeout,
|
||||||
#[error("Max retries exceeded: {0}")]
|
#[error("Max retries exceeded: {0}")]
|
||||||
MaxRetriesExceeded(String),
|
MaxRetriesExceeded(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SendError {
|
||||||
|
pub fn is_permanent(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::SmtpPermanent(_)
|
||||||
|
| Self::DnsPermanent(_)
|
||||||
|
| Self::InvalidRecipient(_)
|
||||||
|
| Self::MessageBuild(_)
|
||||||
|
| Self::DkimSign(_)
|
||||||
|
| Self::ConfigInvalid(_) => true,
|
||||||
|
Self::SmtpTransient(_)
|
||||||
|
| Self::DnsTransient(_)
|
||||||
|
| Self::Timeout
|
||||||
|
| Self::ExternalService(_)
|
||||||
|
| Self::MaxRetriesExceeded(_)
|
||||||
|
| Self::NotConfigured(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_http_client() -> Client {
|
fn create_http_client() -> Client {
|
||||||
Client::builder()
|
Client::builder()
|
||||||
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||||
@@ -100,19 +122,6 @@ where
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sanitize_header_value(value: &str) -> String {
|
|
||||||
value.replace(['\r', '\n'], " ").trim().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mime_encode_header(value: &str) -> String {
|
|
||||||
if value.is_ascii() {
|
|
||||||
sanitize_header_value(value)
|
|
||||||
} else {
|
|
||||||
let sanitized = sanitize_header_value(value);
|
|
||||||
format!("=?UTF-8?B?{}?=", BASE64.encode(sanitized.as_bytes()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn escape_html(text: &str) -> String {
|
pub fn escape_html(text: &str) -> String {
|
||||||
text.replace('&', "&")
|
text.replace('&', "&")
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
@@ -135,93 +144,6 @@ pub fn is_valid_signal_username(username: &str) -> bool {
|
|||||||
tranquil_signal::SignalUsername::parse(username).is_ok()
|
tranquil_signal::SignalUsername::parse(username).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EmailSender {
|
|
||||||
from_address: String,
|
|
||||||
from_name: String,
|
|
||||||
sendmail_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmailSender {
|
|
||||||
pub fn new(from_address: String, from_name: String, sendmail_path: String) -> Self {
|
|
||||||
Self {
|
|
||||||
from_address,
|
|
||||||
from_name,
|
|
||||||
sendmail_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
|
|
||||||
let from_address = cfg.email.from_address.clone()?;
|
|
||||||
let from_name = cfg.email.from_name.clone();
|
|
||||||
let sendmail_path = cfg.email.sendmail_path.clone();
|
|
||||||
Some(Self::new(from_address, from_name, sendmail_path))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_email(&self, notification: &QueuedComms) -> String {
|
|
||||||
let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification"));
|
|
||||||
let recipient = sanitize_header_value(¬ification.recipient);
|
|
||||||
let from_header = if self.from_name.is_empty() {
|
|
||||||
self.from_address.clone()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{} <{}>",
|
|
||||||
sanitize_header_value(&self.from_name),
|
|
||||||
self.from_address
|
|
||||||
)
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
|
|
||||||
from_header, recipient, subject, notification.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl CommsSender for EmailSender {
|
|
||||||
fn channel(&self) -> CommsChannel {
|
|
||||||
CommsChannel::Email
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
|
||||||
let email_content = self.format_email(notification);
|
|
||||||
let mut child = Command::new(&self.sendmail_path)
|
|
||||||
.arg("-t")
|
|
||||||
.arg("-oi")
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| SendError::ProcessSpawn {
|
|
||||||
command: self.sendmail_path.clone(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
stdin
|
|
||||||
.write_all(email_content.as_bytes())
|
|
||||||
.await
|
|
||||||
.map_err(|e| SendError::ProcessSpawn {
|
|
||||||
command: self.sendmail_path.clone(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let output = child
|
|
||||||
.wait_with_output()
|
|
||||||
.await
|
|
||||||
.map_err(|e| SendError::ProcessSpawn {
|
|
||||||
command: self.sendmail_path.clone(),
|
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(SendError::ProcessFailed {
|
|
||||||
command: self.sendmail_path.clone(),
|
|
||||||
detail: stderr.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -299,7 +221,7 @@ impl DiscordSender {
|
|||||||
"type": 1,
|
"type": 1,
|
||||||
"options": [{
|
"options": [{
|
||||||
"name": "handle",
|
"name": "handle",
|
||||||
"description": "Your PDS handle (e.g. alice.example.com)",
|
"description": "Your PDS handle",
|
||||||
"type": 3,
|
"type": 3,
|
||||||
"required": false
|
"required": false
|
||||||
}]
|
}]
|
||||||
@@ -610,3 +532,28 @@ impl CommsSender for SignalSender {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod is_permanent_matrix {
|
||||||
|
use super::{CommsChannel, SendError};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permanent_variants_are_permanent() {
|
||||||
|
assert!(SendError::SmtpPermanent("x".into()).is_permanent());
|
||||||
|
assert!(SendError::DnsPermanent("x".into()).is_permanent());
|
||||||
|
assert!(SendError::InvalidRecipient("x".into()).is_permanent());
|
||||||
|
assert!(SendError::MessageBuild("x".into()).is_permanent());
|
||||||
|
assert!(SendError::DkimSign("x".into()).is_permanent());
|
||||||
|
assert!(SendError::ConfigInvalid("x".into()).is_permanent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_variants_are_not_permanent() {
|
||||||
|
assert!(!SendError::SmtpTransient("x".into()).is_permanent());
|
||||||
|
assert!(!SendError::DnsTransient("x".into()).is_permanent());
|
||||||
|
assert!(!SendError::Timeout.is_permanent());
|
||||||
|
assert!(!SendError::ExternalService("x".into()).is_permanent());
|
||||||
|
assert!(!SendError::MaxRetriesExceeded("x".into()).is_permanent());
|
||||||
|
assert!(!SendError::NotConfigured(CommsChannel::Email).is_permanent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||||
|
use lettre::transport::smtp::extension::ClientId;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tranquil_comms::email::transport::SendMode;
|
||||||
|
use tranquil_comms::email::{EmailSender, types::HeloName};
|
||||||
|
use tranquil_comms::{CommsChannel, CommsSender, CommsStatus, CommsType, QueuedComms, SendError};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms {
|
||||||
|
QueuedComms {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
channel: CommsChannel::Email,
|
||||||
|
comms_type: CommsType::Welcome,
|
||||||
|
status: CommsStatus::Pending,
|
||||||
|
recipient: recipient.to_string(),
|
||||||
|
subject: Some(subject.to_string()),
|
||||||
|
body: body.to_string(),
|
||||||
|
metadata: None,
|
||||||
|
attempts: 0,
|
||||||
|
max_attempts: 3,
|
||||||
|
last_error: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
scheduled_for: Utc::now(),
|
||||||
|
processed_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
|
||||||
|
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_smarthost_sender_with_total_timeout(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
total_timeout: Duration,
|
||||||
|
) -> EmailSender {
|
||||||
|
let from: Mailbox = "Tranquil Test <noreply@nel.pet>".parse().unwrap();
|
||||||
|
let helo = HeloName::parse("mta.nel.pet").unwrap();
|
||||||
|
let transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(host)
|
||||||
|
.port(port)
|
||||||
|
.hello_name(ClientId::Domain(helo.into_inner()))
|
||||||
|
.timeout(Some(Duration::from_secs(5)))
|
||||||
|
.build();
|
||||||
|
EmailSender::new(
|
||||||
|
from,
|
||||||
|
SendMode::Smarthost {
|
||||||
|
transport: Box::new(transport),
|
||||||
|
total_timeout,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drive_stub(stream: TcpStream, rcpt_response: &'static [u8]) -> std::io::Result<()> {
|
||||||
|
let (read, mut write) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(read);
|
||||||
|
write.write_all(b"220 stub ESMTP\r\n").await?;
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
let n = reader.read_line(&mut line).await?;
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let upper = line.to_ascii_uppercase();
|
||||||
|
let response: &[u8] = match upper.split_whitespace().next() {
|
||||||
|
Some("EHLO") | Some("HELO") => b"250-stub\r\n250 SIZE 10240000\r\n",
|
||||||
|
Some("MAIL") => b"250 OK\r\n",
|
||||||
|
Some("RCPT") => rcpt_response,
|
||||||
|
Some("DATA") => b"354 end with .\r\n",
|
||||||
|
Some("RSET") => b"250 OK\r\n",
|
||||||
|
Some("QUIT") => b"221 bye\r\n",
|
||||||
|
_ => b"500 unknown\r\n",
|
||||||
|
};
|
||||||
|
write.write_all(response).await?;
|
||||||
|
if upper.starts_with("QUIT") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let _ = drive_stub(stream, rcpt_response).await;
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rcpt_550_classifies_as_smtp_permanent() {
|
||||||
|
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await;
|
||||||
|
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
|
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||||
|
match result {
|
||||||
|
Err(SendError::SmtpPermanent(_)) => {}
|
||||||
|
other => panic!("expected SmtpPermanent, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rcpt_421_classifies_as_smtp_transient() {
|
||||||
|
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await;
|
||||||
|
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
|
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||||
|
match result {
|
||||||
|
Err(SendError::SmtpTransient(_)) => {}
|
||||||
|
other => panic!("expected SmtpTransient, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_recipient_classifies_as_invalid_recipient() {
|
||||||
|
let port = spawn_stub(b"250 OK\r\n").await;
|
||||||
|
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
|
let result = sender.send(&fixture("not-an-address", "x", "x")).await;
|
||||||
|
match result {
|
||||||
|
Err(SendError::InvalidRecipient(_)) => {}
|
||||||
|
other => panic!("expected InvalidRecipient, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_silent_stub() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (_stream, _) = listener.accept().await.unwrap();
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn smarthost_silent_relay_hits_total_timeout() {
|
||||||
|
let port = spawn_silent_stub().await;
|
||||||
|
let sender =
|
||||||
|
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
match result {
|
||||||
|
Err(SendError::Timeout) => {}
|
||||||
|
other => panic!("expected Timeout, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(2),
|
||||||
|
"send returned in {elapsed:?}, expected close to 500ms total_timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::DbError;
|
use crate::DbError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum BacklinkPath {
|
pub enum BacklinkPath {
|
||||||
Subject,
|
Subject,
|
||||||
SubjectUri,
|
SubjectUri,
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ pub trait InfraRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>;
|
async fn mark_comms_failed(&self, id: Uuid, error: &str) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn create_invite_code(
|
async fn create_invite_code(
|
||||||
&self,
|
&self,
|
||||||
code: &str,
|
code: &str,
|
||||||
|
|||||||
@@ -319,7 +319,6 @@ pub struct ApplyCommitInput {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApplyCommitResult {
|
pub struct ApplyCommitResult {
|
||||||
pub seq: i64,
|
|
||||||
pub is_account_active: bool,
|
pub is_account_active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,19 +444,15 @@ pub trait RepoRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||||
|
|
||||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
|
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn insert_identity_event(
|
async fn insert_identity_event(
|
||||||
&self,
|
&self,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<SequenceNumber, DbError>;
|
) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn insert_account_event(
|
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError>;
|
||||||
&self,
|
|
||||||
did: &Did,
|
|
||||||
status: AccountStatus,
|
|
||||||
) -> Result<SequenceNumber, DbError>;
|
|
||||||
|
|
||||||
async fn insert_sync_event(
|
async fn insert_sync_event(
|
||||||
&self,
|
&self,
|
||||||
@@ -465,7 +460,7 @@ pub trait RepoRepository: Send + Sync {
|
|||||||
commit_cid: &CidLink,
|
commit_cid: &CidLink,
|
||||||
rev: Option<&str>,
|
rev: Option<&str>,
|
||||||
commit_bytes: &[u8],
|
commit_bytes: &[u8],
|
||||||
) -> Result<SequenceNumber, DbError>;
|
) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn insert_genesis_commit_event(
|
async fn insert_genesis_commit_event(
|
||||||
&self,
|
&self,
|
||||||
@@ -475,14 +470,18 @@ pub trait RepoRepository: Send + Sync {
|
|||||||
rev: &str,
|
rev: &str,
|
||||||
commit_bytes: &[u8],
|
commit_bytes: &[u8],
|
||||||
mst_root_bytes: &[u8],
|
mst_root_bytes: &[u8],
|
||||||
) -> Result<SequenceNumber, DbError>;
|
|
||||||
|
|
||||||
async fn delete_sequences_except(
|
|
||||||
&self,
|
|
||||||
did: &Did,
|
|
||||||
keep_seq: SequenceNumber,
|
|
||||||
) -> Result<(), DbError>;
|
) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError>;
|
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError>;
|
||||||
|
|
||||||
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
|
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
|
||||||
@@ -526,8 +525,6 @@ pub trait RepoRepository: Send + Sync {
|
|||||||
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
|
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
|
||||||
-> Result<Option<CidLink>, DbError>;
|
-> Result<Option<CidLink>, DbError>;
|
||||||
|
|
||||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>;
|
|
||||||
|
|
||||||
async fn import_repo_data(
|
async fn import_repo_data(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -563,5 +560,5 @@ pub trait RepoEventNotifier: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RepoEventReceiver: Send {
|
pub trait RepoEventReceiver: Send {
|
||||||
async fn recv(&mut self) -> Option<i64>;
|
async fn recv(&mut self) -> Option<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub struct UserRow {
|
|||||||
pub deactivated_at: Option<DateTime<Utc>>,
|
pub deactivated_at: Option<DateTime<Utc>>,
|
||||||
pub takedown_ref: Option<String>,
|
pub takedown_ref: Option<String>,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
pub inbound_migration: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -550,7 +551,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<ScheduledDeletionAccount>, DbError>;
|
) -> Result<Vec<ScheduledDeletionAccount>, DbError>;
|
||||||
|
|
||||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError>;
|
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn create_password_account(
|
async fn create_password_account(
|
||||||
&self,
|
&self,
|
||||||
@@ -995,6 +996,7 @@ pub struct CreatePasswordAccountInput {
|
|||||||
pub telegram_username: Option<String>,
|
pub telegram_username: Option<String>,
|
||||||
pub signal_username: Option<String>,
|
pub signal_username: Option<String>,
|
||||||
pub deactivated_at: Option<DateTime<Utc>>,
|
pub deactivated_at: Option<DateTime<Utc>>,
|
||||||
|
pub inbound_migration: bool,
|
||||||
pub encrypted_key_bytes: Vec<u8>,
|
pub encrypted_key_bytes: Vec<u8>,
|
||||||
pub encryption_version: i32,
|
pub encryption_version: i32,
|
||||||
pub reserved_key_id: Option<Uuid>,
|
pub reserved_key_id: Option<Uuid>,
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ pub struct PostgresRepoEventReceiver {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RepoEventReceiver for PostgresRepoEventReceiver {
|
impl RepoEventReceiver for PostgresRepoEventReceiver {
|
||||||
async fn recv(&mut self) -> Option<i64> {
|
async fn recv(&mut self) -> Option<()> {
|
||||||
match self.listener.recv().await {
|
match self.listener.recv().await {
|
||||||
Ok(notification) => notification.payload().parse().ok(),
|
Ok(_) => Some(()),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,13 @@ impl InfraRepository for PostgresInfraRepository {
|
|||||||
SET status = 'processing', updated_at = NOW()
|
SET status = 'processing', updated_at = NOW()
|
||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT id FROM comms_queue
|
SELECT id FROM comms_queue
|
||||||
WHERE status = 'pending'
|
WHERE attempts < max_attempts
|
||||||
AND scheduled_for <= $1
|
AND scheduled_for <= $1
|
||||||
AND attempts < max_attempts
|
AND (
|
||||||
|
status = 'pending'
|
||||||
|
OR (status = 'processing'
|
||||||
|
AND updated_at < $1 - INTERVAL '10 minutes')
|
||||||
|
)
|
||||||
ORDER BY scheduled_for ASC
|
ORDER BY scheduled_for ASC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
@@ -127,6 +131,24 @@ impl InfraRepository for PostgresInfraRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_comms_failed_permanent(&self, id: Uuid, error: &str) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"UPDATE comms_queue
|
||||||
|
SET status = 'failed'::comms_status,
|
||||||
|
attempts = max_attempts,
|
||||||
|
last_error = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1"#,
|
||||||
|
id,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_invite_code(
|
async fn create_invite_code(
|
||||||
&self,
|
&self,
|
||||||
code: &str,
|
code: &str,
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
|||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT id FROM oauth_token
|
SELECT id FROM oauth_token
|
||||||
WHERE did = $1
|
WHERE did = $1
|
||||||
ORDER BY updated_at ASC
|
ORDER BY created_at DESC
|
||||||
OFFSET $2
|
OFFSET $2
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -111,6 +111,64 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
|
||||||
|
const SEQUENCER_BATCH_SIZE: i64 = 1000;
|
||||||
|
|
||||||
|
async fn notify_repo_pending(pool: &PgPool) {
|
||||||
|
if let Err(e) = sqlx::query!("NOTIFY repo_updates").execute(pool).await {
|
||||||
|
tracing::warn!(error = %e, "failed to notify repo_updates listeners");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assign_one_batch(
|
||||||
|
mut tx: sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
pool: &PgPool,
|
||||||
|
) -> Result<i64, DbError> {
|
||||||
|
let pending_ids: Vec<i64> = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id as "id!" FROM repo_seq WHERE seq IS NULL ORDER BY id LIMIT $1"#,
|
||||||
|
SEQUENCER_BATCH_SIZE
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
let count = pending_ids.len() as i64;
|
||||||
|
if count == 0 {
|
||||||
|
tx.commit().await.map_err(map_sqlx_error)?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = sqlx::query_scalar!(r#"SELECT nextval('firehose_seq') as "seq!""#)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
if count > 1 {
|
||||||
|
sqlx::query!("SELECT setval('firehose_seq', $1)", start + count - 1)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let assigned = sqlx::query!(
|
||||||
|
r#"UPDATE repo_seq r
|
||||||
|
SET seq = $1::bigint + (o.ord - 1)
|
||||||
|
FROM unnest($2::bigint[]) WITH ORDINALITY AS o(id, ord)
|
||||||
|
WHERE r.id = o.id"#,
|
||||||
|
start,
|
||||||
|
&pending_ids
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
tx.commit().await.map_err(map_sqlx_error)?;
|
||||||
|
if assigned > 0 {
|
||||||
|
notify_repo_pending(pool).await;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PostgresRepoRepository {
|
pub struct PostgresRepoRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -716,13 +774,12 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> {
|
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
|
||||||
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
|
let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref());
|
||||||
let seq = sqlx::query_scalar!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
data.did.as_str(),
|
data.did.as_str(),
|
||||||
data.event_type.as_str(),
|
data.event_type.as_str(),
|
||||||
@@ -735,67 +792,54 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
data.prev_data_cid.as_ref().map(|c| c.as_str()),
|
data.prev_data_cid.as_ref().map(|c| c.as_str()),
|
||||||
data.rev
|
data.rev
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(seq.into())
|
notify_repo_pending(&self.pool).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_identity_event(
|
async fn insert_identity_event(
|
||||||
&self,
|
&self,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<SequenceNumber, DbError> {
|
) -> Result<(), DbError> {
|
||||||
let handle_str = handle.map(|h| h.as_str());
|
let handle_str = handle.map(|h| h.as_str());
|
||||||
let seq = sqlx::query_scalar!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, handle)
|
INSERT INTO repo_seq (did, event_type, handle)
|
||||||
VALUES ($1, 'identity', $2)
|
VALUES ($1, 'identity', $2)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
did.as_str(),
|
did.as_str(),
|
||||||
handle_str
|
handle_str
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
notify_repo_pending(&self.pool).await;
|
||||||
.execute(&self.pool)
|
Ok(())
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(seq.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_account_event(
|
async fn insert_account_event(&self, did: &Did, status: AccountStatus) -> Result<(), DbError> {
|
||||||
&self,
|
|
||||||
did: &Did,
|
|
||||||
status: AccountStatus,
|
|
||||||
) -> Result<SequenceNumber, DbError> {
|
|
||||||
let active = status.is_active();
|
let active = status.is_active();
|
||||||
let status_str = status.for_firehose();
|
let status_str = status.for_firehose();
|
||||||
let seq = sqlx::query_scalar!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, active, status)
|
INSERT INTO repo_seq (did, event_type, active, status)
|
||||||
VALUES ($1, 'account', $2, $3)
|
VALUES ($1, 'account', $2, $3)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
did.as_str(),
|
did.as_str(),
|
||||||
active,
|
active,
|
||||||
status_str
|
status_str
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
notify_repo_pending(&self.pool).await;
|
||||||
.execute(&self.pool)
|
Ok(())
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(seq.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_sync_event(
|
async fn insert_sync_event(
|
||||||
@@ -804,18 +848,17 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
commit_cid: &CidLink,
|
commit_cid: &CidLink,
|
||||||
rev: Option<&str>,
|
rev: Option<&str>,
|
||||||
commit_bytes: &[u8],
|
commit_bytes: &[u8],
|
||||||
) -> Result<SequenceNumber, DbError> {
|
) -> Result<(), DbError> {
|
||||||
let cid_bytes = commit_cid
|
let cid_bytes = commit_cid
|
||||||
.to_cid()
|
.to_cid()
|
||||||
.map(|c| c.to_bytes())
|
.map(|c| c.to_bytes())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let block_cids: Vec<Vec<u8>> = vec![cid_bytes];
|
let block_cids: Vec<Vec<u8>> = vec![cid_bytes];
|
||||||
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec()];
|
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec()];
|
||||||
let seq = sqlx::query_scalar!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)
|
INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)
|
||||||
VALUES ($1, 'sync', $2, $3, $4, $5)
|
VALUES ($1, 'sync', $2, $3, $4, $5)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
did.as_str(),
|
did.as_str(),
|
||||||
commit_cid.as_str(),
|
commit_cid.as_str(),
|
||||||
@@ -823,16 +866,12 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
&block_cids as &[Vec<u8>],
|
&block_cids as &[Vec<u8>],
|
||||||
&block_data as &[Vec<u8>]
|
&block_data as &[Vec<u8>]
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
notify_repo_pending(&self.pool).await;
|
||||||
.execute(&self.pool)
|
Ok(())
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(seq.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn insert_genesis_commit_event(
|
async fn insert_genesis_commit_event(
|
||||||
@@ -843,7 +882,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
rev: &str,
|
rev: &str,
|
||||||
commit_bytes: &[u8],
|
commit_bytes: &[u8],
|
||||||
mst_root_bytes: &[u8],
|
mst_root_bytes: &[u8],
|
||||||
) -> Result<SequenceNumber, DbError> {
|
) -> Result<(), DbError> {
|
||||||
let ops = serde_json::json!([]);
|
let ops = serde_json::json!([]);
|
||||||
let blobs: Vec<String> = vec![];
|
let blobs: Vec<String> = vec![];
|
||||||
let commit_cid_bytes = commit_cid
|
let commit_cid_bytes = commit_cid
|
||||||
@@ -858,11 +897,10 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec(), mst_root_bytes.to_vec()];
|
let block_data: Vec<Vec<u8>> = vec![commit_bytes.to_vec(), mst_root_bytes.to_vec()];
|
||||||
let prev_cid: Option<&str> = None;
|
let prev_cid: Option<&str> = None;
|
||||||
|
|
||||||
let seq = sqlx::query_scalar!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)
|
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)
|
||||||
VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)
|
VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
did.as_str(),
|
did.as_str(),
|
||||||
commit_cid.as_str(),
|
commit_cid.as_str(),
|
||||||
@@ -873,27 +911,22 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
&block_data as &[Vec<u8>],
|
&block_data as &[Vec<u8>],
|
||||||
rev
|
rev
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
notify_repo_pending(&self.pool).await;
|
||||||
.execute(&self.pool)
|
Ok(())
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(seq.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_sequences_except(
|
async fn purge_did_events_keeping_latest(&self, did: &Did) -> Result<(), DbError> {
|
||||||
&self,
|
|
||||||
did: &Did,
|
|
||||||
keep_seq: SequenceNumber,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
r#"
|
||||||
did.as_str(),
|
DELETE FROM repo_seq
|
||||||
keep_seq.as_i64()
|
WHERE did = $1
|
||||||
|
AND id <> (SELECT id FROM repo_seq WHERE did = $1 ORDER BY id DESC LIMIT 1)
|
||||||
|
"#,
|
||||||
|
did.as_str()
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -902,6 +935,42 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn assign_pending_sequences(&self) -> Result<u64, DbError> {
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
loop {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
let acquired = sqlx::query_scalar!(
|
||||||
|
r#"SELECT pg_try_advisory_xact_lock($1) as "locked!""#,
|
||||||
|
SEQUENCER_LOCK_KEY
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
if !acquired {
|
||||||
|
return Ok(total);
|
||||||
|
}
|
||||||
|
let count = assign_one_batch(tx, &self.pool).await?;
|
||||||
|
total += count as u64;
|
||||||
|
if count < SEQUENCER_BATCH_SIZE {
|
||||||
|
return Ok(total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush_pending_sequences(&self) -> Result<(), DbError> {
|
||||||
|
loop {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
sqlx::query!("SELECT pg_advisory_xact_lock($1)", SEQUENCER_LOCK_KEY)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
let count = assign_one_batch(tx, &self.pool).await?;
|
||||||
|
if count < SEQUENCER_BATCH_SIZE {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError> {
|
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError> {
|
||||||
let result = sqlx::query!("DELETE FROM repo_seq WHERE created_at < $1", cutoff)
|
let result = sqlx::query!("DELETE FROM repo_seq WHERE created_at < $1", cutoff)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -965,7 +1034,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
Some(lim) => {
|
Some(lim) => {
|
||||||
let rows = sqlx::query_as!(
|
let rows = sqlx::query_as!(
|
||||||
SequencedEventRow,
|
SequencedEventRow,
|
||||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||||
FROM repo_seq
|
FROM repo_seq
|
||||||
WHERE seq > $1
|
WHERE seq > $1
|
||||||
@@ -982,7 +1051,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
None => {
|
None => {
|
||||||
let rows = sqlx::query_as!(
|
let rows = sqlx::query_as!(
|
||||||
SequencedEventRow,
|
SequencedEventRow,
|
||||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||||
FROM repo_seq
|
FROM repo_seq
|
||||||
WHERE seq > $1
|
WHERE seq > $1
|
||||||
@@ -1004,7 +1073,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||||
let rows = sqlx::query_as!(
|
let rows = sqlx::query_as!(
|
||||||
SequencedEventRow,
|
SequencedEventRow,
|
||||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||||
FROM repo_seq
|
FROM repo_seq
|
||||||
WHERE seq > $1 AND seq < $2
|
WHERE seq > $1 AND seq < $2
|
||||||
@@ -1024,7 +1093,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
) -> Result<Option<SequencedEvent>, DbError> {
|
) -> Result<Option<SequencedEvent>, DbError> {
|
||||||
let row = sqlx::query_as!(
|
let row = sqlx::query_as!(
|
||||||
SequencedEventRow,
|
SequencedEventRow,
|
||||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||||
FROM repo_seq
|
FROM repo_seq
|
||||||
WHERE seq = $1"#,
|
WHERE seq = $1"#,
|
||||||
@@ -1043,7 +1112,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||||
let rows = sqlx::query_as!(
|
let rows = sqlx::query_as!(
|
||||||
SequencedEventRow,
|
SequencedEventRow,
|
||||||
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
r#"SELECT seq as "seq!", did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
|
||||||
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev
|
||||||
FROM repo_seq
|
FROM repo_seq
|
||||||
WHERE seq > $1
|
WHERE seq > $1
|
||||||
@@ -1104,14 +1173,6 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
Ok(cid.map(CidLink::from))
|
Ok(cid.map(CidLink::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> {
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64()))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn import_repo_data(
|
async fn import_repo_data(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -1402,28 +1463,27 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
|
|
||||||
let event = input.commit_event;
|
let event = input.commit_event;
|
||||||
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
|
let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks);
|
||||||
let seq: i64 = sqlx::query_scalar(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING seq
|
|
||||||
"#,
|
"#,
|
||||||
|
event.did.as_str(),
|
||||||
|
event.event_type.as_str(),
|
||||||
|
event.commit_cid.as_ref().map(|c| c.as_str()),
|
||||||
|
event.prev_cid.as_ref().map(|c| c.as_str()),
|
||||||
|
event.ops,
|
||||||
|
event.blobs.as_deref(),
|
||||||
|
&event_block_cids as &[Vec<u8>],
|
||||||
|
&event_block_data as &[Vec<u8>],
|
||||||
|
event.prev_data_cid.as_ref().map(|c| c.as_str()),
|
||||||
|
event.rev
|
||||||
)
|
)
|
||||||
.bind(event.did.as_str())
|
.execute(&mut *tx)
|
||||||
.bind(event.event_type.as_str())
|
|
||||||
.bind(event.commit_cid.as_ref().map(|c| c.as_str()))
|
|
||||||
.bind(event.prev_cid.as_ref().map(|c| c.as_str()))
|
|
||||||
.bind(&event.ops)
|
|
||||||
.bind(&event.blobs)
|
|
||||||
.bind(&event_block_cids)
|
|
||||||
.bind(&event_block_data)
|
|
||||||
.bind(event.prev_data_cid.as_ref().map(|c| c.as_str()))
|
|
||||||
.bind(&event.rev)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
|
sqlx::query!("NOTIFY repo_updates")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||||
@@ -1432,10 +1492,7 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(tranquil_db_traits::ApplyCommitResult {
|
Ok(tranquil_db_traits::ApplyCommitResult { is_account_active })
|
||||||
seq,
|
|
||||||
is_account_active,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError> {
|
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError> {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError {
|
|||||||
impl UserRepository for PostgresUserRepository {
|
impl UserRepository for PostgresUserRepository {
|
||||||
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
|
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
|
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
|
||||||
FROM users WHERE did = $1"#,
|
FROM users WHERE did = $1"#,
|
||||||
did.as_str()
|
did.as_str()
|
||||||
)
|
)
|
||||||
@@ -64,12 +64,13 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
deactivated_at: r.deactivated_at,
|
deactivated_at: r.deactivated_at,
|
||||||
takedown_ref: r.takedown_ref,
|
takedown_ref: r.takedown_ref,
|
||||||
is_admin: r.is_admin,
|
is_admin: r.is_admin,
|
||||||
|
inbound_migration: r.inbound_migration,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
|
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin
|
r#"SELECT id, did, handle, email, created_at, deactivated_at, takedown_ref, is_admin, inbound_migration
|
||||||
FROM users WHERE handle = $1"#,
|
FROM users WHERE handle = $1"#,
|
||||||
handle.as_str()
|
handle.as_str()
|
||||||
)
|
)
|
||||||
@@ -86,6 +87,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
deactivated_at: r.deactivated_at,
|
deactivated_at: r.deactivated_at,
|
||||||
takedown_ref: r.takedown_ref,
|
takedown_ref: r.takedown_ref,
|
||||||
is_admin: r.is_admin,
|
is_admin: r.is_admin,
|
||||||
|
inbound_migration: r.inbound_migration,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1863,7 +1865,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
|
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
|
"UPDATE users SET deactivated_at = NULL, inbound_migration = FALSE WHERE did = $1",
|
||||||
did.as_str()
|
did.as_str()
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -2289,7 +2291,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<i64, DbError> {
|
async fn delete_account_with_firehose(&self, user_id: Uuid, did: &Did) -> Result<(), DbError> {
|
||||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
||||||
@@ -2368,11 +2370,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
let account_seq: i64 = sqlx::query_scalar!(
|
let event_id: i64 = sqlx::query_scalar!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO repo_seq (did, event_type, active, status)
|
INSERT INTO repo_seq (did, event_type, active, status)
|
||||||
VALUES ($1, 'account', false, 'deleted')
|
VALUES ($1, 'account', false, 'deleted')
|
||||||
RETURNING seq
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
did.as_str()
|
did.as_str()
|
||||||
)
|
)
|
||||||
@@ -2381,9 +2383,9 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
|
"DELETE FROM repo_seq WHERE did = $1 AND id <> $2",
|
||||||
did.as_str(),
|
did.as_str(),
|
||||||
account_seq
|
event_id
|
||||||
)
|
)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
@@ -2391,12 +2393,12 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
tx.commit().await.map_err(map_sqlx_error)?;
|
tx.commit().await.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", account_seq))
|
sqlx::query!("NOTIFY repo_updates")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(account_seq)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_password_account(
|
async fn create_password_account(
|
||||||
@@ -2426,8 +2428,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
handle, email, did, password_hash,
|
handle, email, did, password_hash,
|
||||||
preferred_comms_channel,
|
preferred_comms_channel,
|
||||||
discord_username, telegram_username, signal_username,
|
discord_username, telegram_username, signal_username,
|
||||||
is_admin, deactivated_at, email_verified
|
is_admin, deactivated_at, inbound_migration, email_verified
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, FALSE) RETURNING id"#,
|
||||||
)
|
)
|
||||||
.bind(input.handle.as_str())
|
.bind(input.handle.as_str())
|
||||||
.bind(&input.email)
|
.bind(&input.email)
|
||||||
@@ -2439,6 +2441,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.bind(&input.signal_username)
|
.bind(&input.signal_username)
|
||||||
.bind(is_first_user)
|
.bind(is_first_user)
|
||||||
.bind(input.deactivated_at)
|
.bind(input.deactivated_at)
|
||||||
|
.bind(input.inbound_migration)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ urlencoding = { workspace = true, optional = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|||||||
@@ -5,46 +5,102 @@ use std::collections::{HashMap, VecDeque};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
|
||||||
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
||||||
|
|
||||||
struct NegativeEntry {
|
struct NegativeEntry {
|
||||||
expires_at: Instant,
|
expires_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PositiveEntry {
|
||||||
|
doc: Arc<LexiconDoc>,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum CacheEntry {
|
||||||
|
Fresh(Arc<LexiconDoc>),
|
||||||
|
Stale(Arc<LexiconDoc>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheEntry {
|
||||||
|
#[cfg(test)]
|
||||||
|
fn is_fresh(&self) -> bool {
|
||||||
|
matches!(self, Self::Fresh(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SchemaStore {
|
struct SchemaStore {
|
||||||
schemas: HashMap<String, Arc<LexiconDoc>>,
|
schemas: HashMap<String, PositiveEntry>,
|
||||||
insertion_order: VecDeque<String>,
|
insertion_order: VecDeque<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DynamicRegistry {
|
pub struct DynamicRegistry {
|
||||||
store: RwLock<SchemaStore>,
|
store: RwLock<SchemaStore>,
|
||||||
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
|
negative_cache: RwLock<HashMap<String, NegativeEntry>>,
|
||||||
|
in_flight: RwLock<HashMap<String, Arc<Notify>>>,
|
||||||
network_disabled: AtomicBool,
|
network_disabled: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InFlightGuard<'a> {
|
||||||
|
registry: &'a DynamicRegistry,
|
||||||
|
nsid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InFlightGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let notify = self.registry.in_flight.write().remove(&self.nsid);
|
||||||
|
if let Some(n) = notify {
|
||||||
|
n.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DynamicRegistry {
|
impl DynamicRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let network_disabled =
|
|
||||||
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
|
|
||||||
Self {
|
Self {
|
||||||
store: RwLock::new(SchemaStore {
|
store: RwLock::new(SchemaStore {
|
||||||
schemas: HashMap::new(),
|
schemas: HashMap::new(),
|
||||||
insertion_order: VecDeque::new(),
|
insertion_order: VecDeque::new(),
|
||||||
}),
|
}),
|
||||||
negative_cache: RwLock::new(HashMap::new()),
|
negative_cache: RwLock::new(HashMap::new()),
|
||||||
network_disabled: AtomicBool::new(network_disabled),
|
in_flight: RwLock::new(HashMap::new()),
|
||||||
|
network_disabled: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
pub fn from_env() -> Self {
|
||||||
|
let registry = Self::new();
|
||||||
|
let disabled =
|
||||||
|
std::env::var("TRANQUIL_LEXICON_OFFLINE").is_ok_and(|v| v == "1" || v == "true");
|
||||||
|
registry.set_network_disabled(disabled);
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_network_disabled(&self, disabled: bool) {
|
pub fn set_network_disabled(&self, disabled: bool) {
|
||||||
self.network_disabled.store(disabled, Ordering::Relaxed);
|
self.network_disabled.store(disabled, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
pub fn get_cached(&self, nsid: &str) -> Option<Arc<LexiconDoc>> {
|
||||||
self.store.read().schemas.get(nsid).cloned()
|
self.store
|
||||||
|
.read()
|
||||||
|
.schemas
|
||||||
|
.get(nsid)
|
||||||
|
.map(|e| Arc::clone(&e.doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_entry(&self, nsid: &str) -> Option<CacheEntry> {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.store.read().schemas.get(nsid).map(|e| {
|
||||||
|
if e.expires_at > now {
|
||||||
|
CacheEntry::Fresh(Arc::clone(&e.doc))
|
||||||
|
} else {
|
||||||
|
CacheEntry::Stale(Arc::clone(&e.doc))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
pub fn is_negative_cached(&self, nsid: &str) -> bool {
|
||||||
@@ -56,7 +112,7 @@ impl DynamicRegistry {
|
|||||||
|
|
||||||
fn insert_negative(&self, nsid: &str) {
|
fn insert_negative(&self, nsid: &str) {
|
||||||
let mut cache = self.negative_cache.write();
|
let mut cache = self.negative_cache.write();
|
||||||
if cache.len() > MAX_DYNAMIC_SCHEMAS {
|
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
cache.retain(|_, entry| entry.expires_at > now);
|
cache.retain(|_, entry| entry.expires_at > now);
|
||||||
}
|
}
|
||||||
@@ -87,29 +143,95 @@ impl DynamicRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if store
|
let entry = PositiveEntry {
|
||||||
.schemas
|
doc: Arc::clone(&arc),
|
||||||
.insert(nsid.clone(), Arc::clone(&arc))
|
expires_at: Instant::now() + POSITIVE_CACHE_TTL,
|
||||||
.is_some()
|
};
|
||||||
{
|
if store.schemas.insert(nsid.clone(), entry).is_some() {
|
||||||
store.insertion_order.retain(|k| k != &nsid);
|
store.insertion_order.retain(|k| k != &nsid);
|
||||||
}
|
}
|
||||||
store.insertion_order.push_back(nsid.clone());
|
store.insertion_order.push_back(nsid.clone());
|
||||||
|
drop(store);
|
||||||
|
|
||||||
self.negative_cache.write().remove(&arc.id);
|
self.negative_cache.write().remove(&arc.id);
|
||||||
|
|
||||||
arc
|
arc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bump_expiry(&self, nsid: &str, duration: Duration) {
|
||||||
|
let mut store = self.store.write();
|
||||||
|
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||||
|
entry.expires_at = Instant::now() + duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
|
pub async fn resolve_and_cache(&self, nsid: &str) -> Result<Arc<LexiconDoc>, ResolveError> {
|
||||||
if let Some(doc) = self.get(nsid) {
|
self.resolve_and_cache_with(nsid, |n| async move { resolve_lexicon(&n).await })
|
||||||
return Ok(doc);
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_and_cache_with<F, Fut>(
|
||||||
|
&self,
|
||||||
|
nsid: &str,
|
||||||
|
resolver: F,
|
||||||
|
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||||
|
where
|
||||||
|
F: FnOnce(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||||
|
{
|
||||||
|
match self.get_entry(nsid) {
|
||||||
|
Some(CacheEntry::Fresh(doc)) => Ok(doc),
|
||||||
|
Some(CacheEntry::Stale(stale)) => self.refresh_stale(nsid, stale, resolver).await,
|
||||||
|
None => self.resolve_fresh(nsid, resolver).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_stale<F, Fut>(
|
||||||
|
&self,
|
||||||
|
nsid: &str,
|
||||||
|
stale: Arc<LexiconDoc>,
|
||||||
|
resolver: F,
|
||||||
|
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||||
|
where
|
||||||
|
F: FnOnce(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||||
|
{
|
||||||
|
if self.network_disabled.load(Ordering::Relaxed) {
|
||||||
|
return Ok(stale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match self.acquire_leadership(nsid) {
|
||||||
|
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||||
|
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||||
|
Err(e) => {
|
||||||
|
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
|
||||||
|
tracing::warn!(
|
||||||
|
nsid = nsid,
|
||||||
|
error = %e,
|
||||||
|
"lexicon refresh failed, serving stale cached entry"
|
||||||
|
);
|
||||||
|
Ok(stale)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
self.wait_for_leader(nsid).await;
|
||||||
|
Ok(self.get_cached(nsid).unwrap_or(stale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_fresh<F, Fut>(
|
||||||
|
&self,
|
||||||
|
nsid: &str,
|
||||||
|
resolver: F,
|
||||||
|
) -> Result<Arc<LexiconDoc>, ResolveError>
|
||||||
|
where
|
||||||
|
F: FnOnce(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||||
|
{
|
||||||
if self.network_disabled.load(Ordering::Relaxed) {
|
if self.network_disabled.load(Ordering::Relaxed) {
|
||||||
return Err(ResolveError::NetworkDisabled);
|
return Err(ResolveError::NetworkDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.is_negative_cached(nsid) {
|
if self.is_negative_cached(nsid) {
|
||||||
return Err(ResolveError::NegativelyCached {
|
return Err(ResolveError::NegativelyCached {
|
||||||
nsid: nsid.to_string(),
|
nsid: nsid.to_string(),
|
||||||
@@ -117,19 +239,73 @@ impl DynamicRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match resolve_lexicon(nsid).await {
|
match self.acquire_leadership(nsid) {
|
||||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
Some(_guard) => match resolver(nsid.to_string()).await {
|
||||||
Err(e) => {
|
Ok(doc) => Ok(self.insert_schema(doc)),
|
||||||
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
Err(e) => {
|
||||||
self.insert_negative(nsid);
|
self.insert_negative(nsid);
|
||||||
Err(e)
|
tracing::debug!(nsid = nsid, error = %e, "caching negative resolution result");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
self.wait_for_leader(nsid).await;
|
||||||
|
match self.get_cached(nsid) {
|
||||||
|
Some(doc) => Ok(doc),
|
||||||
|
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
|
||||||
|
nsid: nsid.to_string(),
|
||||||
|
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
||||||
|
}),
|
||||||
|
None => Err(ResolveError::LeaderAborted {
|
||||||
|
nsid: nsid.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn acquire_leadership(&self, nsid: &str) -> Option<InFlightGuard<'_>> {
|
||||||
|
let mut map = self.in_flight.write();
|
||||||
|
if map.contains_key(nsid) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
map.insert(nsid.to_string(), Arc::new(Notify::new()));
|
||||||
|
Some(InFlightGuard {
|
||||||
|
registry: self,
|
||||||
|
nsid: nsid.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_leader(&self, nsid: &str) {
|
||||||
|
let notify = {
|
||||||
|
let map = self.in_flight.read();
|
||||||
|
match map.get(nsid) {
|
||||||
|
Some(n) => Arc::clone(n),
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let notified = notify.notified();
|
||||||
|
tokio::pin!(notified);
|
||||||
|
notified.as_mut().enable();
|
||||||
|
let still_active = self.in_flight.read().contains_key(nsid);
|
||||||
|
if !still_active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notified.as_mut().await;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn schema_count(&self) -> usize {
|
pub fn schema_count(&self) -> usize {
|
||||||
self.store.read().schemas.len()
|
self.store.read().schemas.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn expire_now(&self, nsid: &str) {
|
||||||
|
let mut store = self.store.write();
|
||||||
|
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||||
|
entry.expires_at = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DynamicRegistry {
|
impl Default for DynamicRegistry {
|
||||||
@@ -171,7 +347,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_empty_lookup() {
|
fn test_empty_lookup() {
|
||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
assert!(registry.get("com.example.nonexistent").is_none());
|
assert!(registry.get_cached("com.example.nonexistent").is_none());
|
||||||
assert_eq!(registry.schema_count(), 0);
|
assert_eq!(registry.schema_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +364,12 @@ mod tests {
|
|||||||
assert_eq!(arc.id, "com.example.test");
|
assert_eq!(arc.id, "com.example.test");
|
||||||
assert_eq!(registry.schema_count(), 1);
|
assert_eq!(registry.schema_count(), 1);
|
||||||
|
|
||||||
let retrieved = registry.get("com.example.test");
|
let retrieved = registry.get_cached("com.example.test");
|
||||||
assert!(retrieved.is_some());
|
assert!(retrieved.is_some());
|
||||||
assert_eq!(retrieved.unwrap().id, "com.example.test");
|
assert_eq!(retrieved.unwrap().id, "com.example.test");
|
||||||
|
|
||||||
|
let entry = registry.get_entry("com.example.test").unwrap();
|
||||||
|
assert!(entry.is_fresh(), "freshly inserted entry must be fresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -210,6 +389,218 @@ mod tests {
|
|||||||
assert!(!registry.is_negative_cached("com.example.test"));
|
assert!(!registry.is_negative_cached("com.example.test"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_positive_entry_reports_stale_after_ttl() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: "pet.nel.stale".to_string(),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.insert_schema(doc);
|
||||||
|
|
||||||
|
assert!(registry.get_entry("pet.nel.stale").unwrap().is_fresh());
|
||||||
|
|
||||||
|
registry.expire_now("pet.nel.stale");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!registry.get_entry("pet.nel.stale").unwrap().is_fresh(),
|
||||||
|
"entry past expiry must be reported stale"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stale_served_on_resolve_failure() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: "pet.nel.flaky".to_string(),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.insert_schema(doc);
|
||||||
|
registry.expire_now("pet.nel.flaky");
|
||||||
|
|
||||||
|
let result = registry
|
||||||
|
.resolve_and_cache_with("pet.nel.flaky", |n| async move {
|
||||||
|
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||||
|
domain: n,
|
||||||
|
reason: "simulated failure".to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let served = result.expect("stale entry must be served when refresh fails");
|
||||||
|
assert_eq!(served.id, "pet.nel.flaky");
|
||||||
|
assert!(
|
||||||
|
registry.get_entry("pet.nel.flaky").unwrap().is_fresh(),
|
||||||
|
"failed refresh must bump expiry so subsequent lookups skip the resolver"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!registry.is_negative_cached("pet.nel.flaky"),
|
||||||
|
"stale refresh failure must not poison negative cache"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fresh_hit_skips_resolver() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: "pet.nel.fresh".to_string(),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.insert_schema(doc);
|
||||||
|
|
||||||
|
let result = registry
|
||||||
|
.resolve_and_cache_with("pet.nel.fresh", |_| async move {
|
||||||
|
panic!("resolver must not run on fresh hit")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stale_served_when_network_disabled() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: "pet.nel.offline".to_string(),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.insert_schema(doc);
|
||||||
|
registry.expire_now("pet.nel.offline");
|
||||||
|
registry.set_network_disabled(true);
|
||||||
|
|
||||||
|
let result = registry
|
||||||
|
.resolve_and_cache_with("pet.nel.offline", |_| async move {
|
||||||
|
panic!("resolver must not run when network disabled")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_successful_refresh_updates_cached_at() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: "pet.nel.refresh".to_string(),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.insert_schema(doc);
|
||||||
|
registry.expire_now("pet.nel.refresh");
|
||||||
|
|
||||||
|
assert!(!registry.get_entry("pet.nel.refresh").unwrap().is_fresh());
|
||||||
|
|
||||||
|
let refreshed = registry
|
||||||
|
.resolve_and_cache_with("pet.nel.refresh", |n| async move {
|
||||||
|
Ok(LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: n,
|
||||||
|
defs: HashMap::new(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(refreshed.id, "pet.nel.refresh");
|
||||||
|
assert!(
|
||||||
|
registry.get_entry("pet.nel.refresh").unwrap().is_fresh(),
|
||||||
|
"refresh must restore freshness"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_single_flight_dedups_concurrent_resolves() {
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
let registry = Arc::new(DynamicRegistry::new());
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
let tasks: Vec<_> = (0..16)
|
||||||
|
.map(|_| {
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
registry
|
||||||
|
.resolve_and_cache_with("pet.nel.herd", |n| {
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
Ok(LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: n,
|
||||||
|
defs: HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = futures_collect(tasks).await;
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.for_each(|r| assert!(r.is_ok(), "all single-flight callers must succeed"));
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"single-flight must coalesce concurrent resolves"
|
||||||
|
);
|
||||||
|
assert_eq!(registry.schema_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_single_flight_followers_observe_leader_failure() {
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
let registry = Arc::new(DynamicRegistry::new());
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
let tasks: Vec<_> = (0..8)
|
||||||
|
.map(|_| {
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
registry
|
||||||
|
.resolve_and_cache_with("pet.nel.failHerd", |n| {
|
||||||
|
let calls = Arc::clone(&calls);
|
||||||
|
async move {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||||
|
domain: n,
|
||||||
|
reason: "simulated".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = futures_collect(tasks).await;
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.for_each(|r| assert!(r.is_err(), "all followers must observe leader failure"));
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"single-flight must coalesce failing resolves too"
|
||||||
|
);
|
||||||
|
assert!(registry.is_negative_cached("pet.nel.failHerd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn futures_collect<T>(handles: Vec<tokio::task::JoinHandle<T>>) -> Vec<T> {
|
||||||
|
futures::future::join_all(handles)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.expect("task panicked"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_eviction_is_fifo() {
|
fn test_eviction_is_fifo() {
|
||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
@@ -217,7 +608,7 @@ mod tests {
|
|||||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||||
let doc = LexiconDoc {
|
let doc = LexiconDoc {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: format!("com.example.schema{}", i),
|
id: format!("pet.nel.schema{}", i),
|
||||||
defs: HashMap::new(),
|
defs: HashMap::new(),
|
||||||
};
|
};
|
||||||
registry.insert_schema(doc);
|
registry.insert_schema(doc);
|
||||||
@@ -226,23 +617,23 @@ mod tests {
|
|||||||
|
|
||||||
let trigger = LexiconDoc {
|
let trigger = LexiconDoc {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: "com.example.trigger".to_string(),
|
id: "pet.nel.trigger".to_string(),
|
||||||
defs: HashMap::new(),
|
defs: HashMap::new(),
|
||||||
};
|
};
|
||||||
registry.insert_schema(trigger);
|
registry.insert_schema(trigger);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
registry.get("com.example.schema0").is_none(),
|
registry.get_cached("pet.nel.schema0").is_none(),
|
||||||
"oldest entry should be evicted"
|
"oldest entry should be evicted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
registry.get("com.example.trigger").is_some(),
|
registry.get_cached("pet.nel.trigger").is_some(),
|
||||||
"newly inserted entry should exist"
|
"newly inserted entry should exist"
|
||||||
);
|
);
|
||||||
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
|
let evict_count = MAX_DYNAMIC_SCHEMAS / 4;
|
||||||
assert!(
|
assert!(
|
||||||
registry
|
registry
|
||||||
.get(&format!("com.example.schema{}", evict_count))
|
.get_cached(&format!("pet.nel.schema{}", evict_count))
|
||||||
.is_some(),
|
.is_some(),
|
||||||
"entry after eviction window should survive"
|
"entry after eviction window should survive"
|
||||||
);
|
);
|
||||||
@@ -253,7 +644,7 @@ mod tests {
|
|||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
let doc = LexiconDoc {
|
let doc = LexiconDoc {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: "com.example.tracked".to_string(),
|
id: "pet.nel.tracked".to_string(),
|
||||||
defs: HashMap::new(),
|
defs: HashMap::new(),
|
||||||
};
|
};
|
||||||
let arc = registry.insert_schema(doc);
|
let arc = registry.insert_schema(doc);
|
||||||
@@ -265,7 +656,7 @@ mod tests {
|
|||||||
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
(0..MAX_DYNAMIC_SCHEMAS).for_each(|i| {
|
||||||
registry.insert_schema(LexiconDoc {
|
registry.insert_schema(LexiconDoc {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: format!("com.example.filler{}", i),
|
id: format!("pet.nel.filler{}", i),
|
||||||
defs: HashMap::new(),
|
defs: HashMap::new(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ impl LexiconRegistry {
|
|||||||
Self {
|
Self {
|
||||||
schemas: HashMap::new(),
|
schemas: HashMap::new(),
|
||||||
#[cfg(feature = "resolve")]
|
#[cfg(feature = "resolve")]
|
||||||
dynamic: crate::dynamic::DynamicRegistry::new(),
|
dynamic: crate::dynamic::DynamicRegistry::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ impl LexiconRegistry {
|
|||||||
self.schemas.get(nsid).cloned().or_else(|| {
|
self.schemas.get(nsid).cloned().or_else(|| {
|
||||||
#[cfg(feature = "resolve")]
|
#[cfg(feature = "resolve")]
|
||||||
{
|
{
|
||||||
self.dynamic.get(nsid)
|
self.dynamic.get_cached(nsid)
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "resolve"))]
|
#[cfg(not(feature = "resolve"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ pub enum ResolveError {
|
|||||||
NegativelyCached { nsid: String, ttl_secs: u64 },
|
NegativelyCached { nsid: String, ttl_secs: u64 },
|
||||||
#[error("network resolution disabled")]
|
#[error("network resolution disabled")]
|
||||||
NetworkDisabled,
|
NetworkDisabled,
|
||||||
|
#[error("leader task for {nsid} aborted before completion")]
|
||||||
|
LeaderAborted { nsid: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ pub async fn authorize_post(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
client_ip: ClientIp,
|
||||||
Json(form): Json<AuthorizeSubmit>,
|
Json(form): Json<AuthorizeSubmit>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let json_response = wants_json(&headers);
|
let json_response = wants_json(&headers);
|
||||||
@@ -488,8 +489,7 @@ pub async fn authorize_post(
|
|||||||
if !password_valid {
|
if !password_valid {
|
||||||
return show_login_error("Invalid identifier or password.", json_response);
|
return show_login_error("Invalid identifier or password.", json_response);
|
||||||
}
|
}
|
||||||
let is_verified = user.channel_verification.has_any_verified();
|
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
|
||||||
if !is_verified {
|
|
||||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
||||||
let handle = resend_info
|
let handle = resend_info
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -617,7 +617,7 @@ pub async fn authorize_post(
|
|||||||
let device_data = DeviceData {
|
let device_data = DeviceData {
|
||||||
session_id: SessionId::generate(),
|
session_id: SessionId::generate(),
|
||||||
user_agent: extract_user_agent(&headers),
|
user_agent: extract_user_agent(&headers),
|
||||||
ip_address: extract_client_ip(&headers, None),
|
ip_address: client_ip.into_string(),
|
||||||
last_seen_at: Utc::now(),
|
last_seen_at: Utc::now(),
|
||||||
};
|
};
|
||||||
if state
|
if state
|
||||||
@@ -854,8 +854,7 @@ pub async fn authorize_select(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let is_verified = user.channel_verification.has_any_verified();
|
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
|
||||||
if !is_verified {
|
|
||||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use tranquil_pds::rate_limit::{
|
|||||||
};
|
};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||||
use tranquil_pds::util::extract_client_ip;
|
use tranquil_pds::util::ClientIp;
|
||||||
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
|
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
|
||||||
use urlencoding::encode as url_encode;
|
use urlencoding::encode as url_encode;
|
||||||
|
|
||||||
|
|||||||
@@ -175,10 +175,7 @@ pub async fn passkey_start(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn passkey_start_discoverable(
|
async fn passkey_start_discoverable(state: AppState, request_id: RequestId) -> Response {
|
||||||
state: AppState,
|
|
||||||
request_id: RequestId,
|
|
||||||
) -> Response {
|
|
||||||
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
|
let (rcr, auth_state) = match state.webauthn_config.start_discoverable_authentication() {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -292,9 +289,7 @@ async fn passkey_start_named(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_verified = user.channel_verification.has_any_verified();
|
if tranquil_api::server::verification_blocks_login(&user.channel_verification) {
|
||||||
|
|
||||||
if !is_verified {
|
|
||||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
@@ -570,16 +565,13 @@ pub async fn passkey_finish(
|
|||||||
Err(response) => return response,
|
Err(response) => return response,
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
let result = match passkey_finish_discoverable(
|
let result =
|
||||||
&state,
|
match passkey_finish_discoverable(&state, &credential, &passkey_finish_request_id)
|
||||||
&credential,
|
.await
|
||||||
&passkey_finish_request_id,
|
{
|
||||||
)
|
Ok(result) => result,
|
||||||
.await
|
Err(response) => return response,
|
||||||
{
|
};
|
||||||
Ok(result) => result,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
if state
|
if state
|
||||||
.repos
|
.repos
|
||||||
.oauth
|
.oauth
|
||||||
|
|||||||
@@ -160,8 +160,10 @@ pub async fn register_complete(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
|
let login_blocked = match state.repos.user.get_session_info_by_did(&did).await {
|
||||||
Ok(Some(info)) => info.channel_verification.has_any_verified(),
|
Ok(Some(info)) => {
|
||||||
|
tranquil_api::server::verification_blocks_login(&info.channel_verification)
|
||||||
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
@@ -189,7 +191,7 @@ pub async fn register_complete(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !is_verified {
|
if login_blocked {
|
||||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
@@ -300,6 +302,7 @@ pub async fn register_complete(
|
|||||||
pub async fn establish_session(
|
pub async fn establish_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
client_ip: ClientIp,
|
||||||
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
|
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let did = &auth.did;
|
let did = &auth.did;
|
||||||
@@ -317,7 +320,7 @@ pub async fn establish_session(
|
|||||||
let device_data = DeviceData {
|
let device_data = DeviceData {
|
||||||
session_id: SessionId::generate(),
|
session_id: SessionId::generate(),
|
||||||
user_agent: extract_user_agent(&headers),
|
user_agent: extract_user_agent(&headers),
|
||||||
ip_address: extract_client_ip(&headers, None),
|
ip_address: client_ip.into_string(),
|
||||||
last_seen_at: Utc::now(),
|
last_seen_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub async fn authorize_2fa_post(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
client_ip: ClientIp,
|
||||||
Json(form): Json<Authorize2faSubmit>,
|
Json(form): Json<Authorize2faSubmit>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
||||||
@@ -251,7 +252,7 @@ pub async fn authorize_2fa_post(
|
|||||||
let device_data = DeviceData {
|
let device_data = DeviceData {
|
||||||
session_id: SessionId::generate(),
|
session_id: SessionId::generate(),
|
||||||
user_agent: extract_user_agent(&headers),
|
user_agent: extract_user_agent(&headers),
|
||||||
ip_address: extract_client_ip(&headers, None),
|
ip_address: client_ip.into_string(),
|
||||||
last_seen_at: Utc::now(),
|
last_seen_at: Utc::now(),
|
||||||
};
|
};
|
||||||
if state
|
if state
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use tranquil_pds::oauth::client::{build_client_metadata, delegation_oauth_urls};
|
|||||||
use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
|
use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::PlainPassword;
|
use tranquil_pds::types::PlainPassword;
|
||||||
use tranquil_pds::util::extract_client_ip;
|
use tranquil_pds::util::ClientIp;
|
||||||
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
|
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
|
||||||
use tranquil_types::{Did, RequestId};
|
use tranquil_types::{Did, RequestId};
|
||||||
|
|
||||||
@@ -402,6 +402,7 @@ pub struct DelegationTokenAuthSubmit {
|
|||||||
pub async fn delegation_auth_token(
|
pub async fn delegation_auth_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
client_ip: ClientIp,
|
||||||
auth: Auth<Active>,
|
auth: Auth<Active>,
|
||||||
Json(form): Json<DelegationTokenAuthSubmit>,
|
Json(form): Json<DelegationTokenAuthSubmit>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
@@ -428,7 +429,7 @@ pub async fn delegation_auth_token(
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ip = extract_client_ip(&headers, None);
|
let ip = client_ip.into_string();
|
||||||
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
|
let user_agent = tranquil_pds::util::extract_user_agent(&headers);
|
||||||
|
|
||||||
finalize_delegation_auth(
|
finalize_delegation_auth(
|
||||||
|
|||||||
@@ -138,21 +138,29 @@ pub async fn pushed_authorization_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
|
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
|
||||||
if let (Some(assertion), Some(assertion_type)) =
|
let assertion = request
|
||||||
(&request.client_assertion, &request.client_assertion_type)
|
.client_assertion
|
||||||
{
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let assertion_type = request
|
||||||
|
.client_assertion_type
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let secret = request.client_secret.as_deref().filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) {
|
||||||
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||||
return Err(OAuthError::InvalidRequest(
|
return Err(OAuthError::InvalidRequest(
|
||||||
"Unsupported client_assertion_type".to_string(),
|
"Unsupported client_assertion_type".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Ok(ClientAuth::PrivateKeyJwt {
|
return Ok(ClientAuth::PrivateKeyJwt {
|
||||||
client_assertion: assertion.clone(),
|
client_assertion: assertion.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(secret) = &request.client_secret {
|
if let Some(secret) = secret {
|
||||||
return Ok(ClientAuth::SecretPost {
|
return Ok(ClientAuth::SecretPost {
|
||||||
client_secret: secret.clone(),
|
client_secret: secret.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ClientAuth::None)
|
Ok(ClientAuth::None)
|
||||||
|
|||||||
@@ -138,13 +138,17 @@ impl TokenRequest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client_auth = match (self.client_assertion, self.client_assertion_type) {
|
let assertion = self.client_assertion.filter(|s| !s.is_empty());
|
||||||
|
let assertion_type = self.client_assertion_type.filter(|s| !s.is_empty());
|
||||||
|
let client_secret = self.client_secret.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let client_auth = match (assertion, assertion_type) {
|
||||||
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
|
(Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt {
|
||||||
client_id: self.client_id,
|
client_id: self.client_id,
|
||||||
assertion,
|
assertion,
|
||||||
assertion_type,
|
assertion_type,
|
||||||
},
|
},
|
||||||
_ => match self.client_secret {
|
_ => match client_secret {
|
||||||
Some(secret) => RequestClientAuth::SecretPost {
|
Some(secret) => RequestClientAuth::SecretPost {
|
||||||
client_id: self.client_id,
|
client_id: self.client_id,
|
||||||
client_secret: secret,
|
client_secret: secret,
|
||||||
|
|||||||
@@ -402,13 +402,15 @@ async fn handle_sso_login(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_verified = match state
|
let login_blocked = match state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
.get_session_info_by_did(&identity.did)
|
.get_session_info_by_did(&identity.did)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(info)) => info.channel_verification.has_any_verified(),
|
Ok(Some(info)) => {
|
||||||
|
tranquil_api::server::verification_blocks_login(&info.channel_verification)
|
||||||
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::error!("User not found for SSO login: {}", identity.did);
|
tracing::error!("User not found for SSO login: {}", identity.did);
|
||||||
return redirect_to_error("Account not found");
|
return redirect_to_error("Account not found");
|
||||||
@@ -419,7 +421,7 @@ async fn handle_sso_login(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !is_verified {
|
if login_blocked {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
did = %identity.did,
|
did = %identity.did,
|
||||||
provider = %provider.as_str(),
|
provider = %provider.as_str(),
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ sha2 = { workspace = true }
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
native-tls-roots = []
|
||||||
|
|||||||
@@ -78,18 +78,21 @@ impl ClientMetadataCache {
|
|||||||
Self {
|
Self {
|
||||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
|
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
http_client: Client::builder()
|
http_client: {
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
let builder = Client::builder()
|
||||||
.connect_timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.pool_max_idle_per_host(10)
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
.pool_max_idle_per_host(10)
|
||||||
.user_agent(concat!(
|
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||||
"Tranquil-PDS/",
|
.user_agent(concat!(
|
||||||
env!("CARGO_PKG_VERSION"),
|
"Tranquil-PDS/",
|
||||||
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
|
env!("CARGO_PKG_VERSION"),
|
||||||
))
|
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
|
||||||
.build()
|
));
|
||||||
.unwrap_or_else(|_| Client::new()),
|
#[cfg(feature = "native-tls-roots")]
|
||||||
|
let builder = builder.danger_accept_invalid_certs(true);
|
||||||
|
builder.build().unwrap_or_else(|_| Client::new())
|
||||||
|
},
|
||||||
cache_ttl_secs,
|
cache_ttl_secs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ metrics-exporter-prometheus = { workspace = true }
|
|||||||
multibase = { workspace = true }
|
multibase = { workspace = true }
|
||||||
multihash = { workspace = true }
|
multihash = { workspace = true }
|
||||||
p256 = { workspace = true }
|
p256 = { workspace = true }
|
||||||
|
parking_lot = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
redis = { workspace = true, optional = true }
|
redis = { workspace = true, optional = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
@@ -90,8 +91,10 @@ s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
|
|||||||
s3 = ["s3-storage"]
|
s3 = ["s3-storage"]
|
||||||
valkey = ["tranquil-cache/valkey", "dep:redis"]
|
valkey = ["tranquil-cache/valkey", "dep:redis"]
|
||||||
frontend = []
|
frontend = []
|
||||||
|
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
ciborium = { workspace = true }
|
ciborium = { workspace = true }
|
||||||
ctor = { workspace = true }
|
ctor = { workspace = true }
|
||||||
testcontainers = { workspace = true }
|
testcontainers = { workspace = true }
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ struct ErrorBody<'a> {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
InternalError(Option<String>),
|
InternalError(Option<String>),
|
||||||
|
RepoCorruption,
|
||||||
AuthenticationRequired,
|
AuthenticationRequired,
|
||||||
AuthenticationFailed(Option<String>),
|
AuthenticationFailed(Option<String>),
|
||||||
InvalidRequest(String),
|
InvalidRequest(String),
|
||||||
InvalidToken(Option<String>),
|
InvalidToken(Option<String>),
|
||||||
ExpiredToken(Option<String>),
|
ExpiredToken(Option<String>),
|
||||||
OAuthExpiredToken(Option<String>),
|
OAuthExpiredToken(Option<String>),
|
||||||
|
UseDpopNonce(String),
|
||||||
|
InvalidDpopProof(String),
|
||||||
TokenRequired,
|
TokenRequired,
|
||||||
AccountDeactivated,
|
AccountDeactivated,
|
||||||
AccountTakedown,
|
AccountTakedown,
|
||||||
@@ -119,10 +122,34 @@ pub enum ApiError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MST_NODE_MISSING_MARKER: &str = "MST node not found";
|
||||||
|
|
||||||
impl ApiError {
|
impl ApiError {
|
||||||
|
pub fn is_repo_corruption(&self) -> bool {
|
||||||
|
matches!(self, Self::RepoCorruption)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail_is_repo_corruption(detail: &str) -> bool {
|
||||||
|
detail.contains(tranquil_store::blockstore::BLOCK_CORRUPTION_MARKER)
|
||||||
|
|| detail.contains(MST_NODE_MISSING_MARKER)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_mst_error(context: &str, e: &jacquard_repo::error::RepoError) -> Self {
|
||||||
|
let detail = format!("{e:#}");
|
||||||
|
if Self::detail_is_repo_corruption(&detail) {
|
||||||
|
tracing::warn!("{context}: repairable MST damage: {detail}");
|
||||||
|
Self::RepoCorruption
|
||||||
|
} else {
|
||||||
|
tracing::error!("{context}: {detail}");
|
||||||
|
Self::InternalError(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn status_code(&self) -> StatusCode {
|
fn status_code(&self) -> StatusCode {
|
||||||
match self {
|
match self {
|
||||||
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||||
StatusCode::BAD_GATEWAY
|
StatusCode::BAD_GATEWAY
|
||||||
}
|
}
|
||||||
@@ -137,6 +164,8 @@ impl ApiError {
|
|||||||
| Self::InvalidToken(_)
|
| Self::InvalidToken(_)
|
||||||
| Self::PasskeyCounterAnomaly
|
| Self::PasskeyCounterAnomaly
|
||||||
| Self::OAuthExpiredToken(_)
|
| Self::OAuthExpiredToken(_)
|
||||||
|
| Self::UseDpopNonce(_)
|
||||||
|
| Self::InvalidDpopProof(_)
|
||||||
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
|
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
|
||||||
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
|
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
|
||||||
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
|
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
|
||||||
@@ -219,7 +248,9 @@ impl ApiError {
|
|||||||
}
|
}
|
||||||
fn error_name(&self) -> Cow<'static, str> {
|
fn error_name(&self) -> Cow<'static, str> {
|
||||||
match self {
|
match self {
|
||||||
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
|
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
|
||||||
|
Cow::Borrowed("InternalServerError")
|
||||||
|
}
|
||||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||||
Cow::Borrowed("UpstreamError")
|
Cow::Borrowed("UpstreamError")
|
||||||
}
|
}
|
||||||
@@ -236,6 +267,8 @@ impl ApiError {
|
|||||||
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
|
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
|
||||||
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
|
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
|
||||||
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
|
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
|
||||||
|
Self::UseDpopNonce(_) => Cow::Borrowed("use_dpop_nonce"),
|
||||||
|
Self::InvalidDpopProof(_) => Cow::Borrowed("invalid_dpop_proof"),
|
||||||
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
|
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
|
||||||
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
||||||
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
||||||
@@ -326,6 +359,7 @@ impl ApiError {
|
|||||||
Self::InternalError(msg) => msg
|
Self::InternalError(msg) => msg
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Internal Server Error".into()),
|
.unwrap_or_else(|| "Internal Server Error".into()),
|
||||||
|
Self::RepoCorruption => "Internal Server Error".into(),
|
||||||
Self::AuthenticationFailed(msg) => msg
|
Self::AuthenticationFailed(msg) => msg
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Authentication failed".into()),
|
.unwrap_or_else(|| "Authentication failed".into()),
|
||||||
@@ -335,6 +369,8 @@ impl ApiError {
|
|||||||
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
|
Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => {
|
||||||
msg.clone().unwrap_or_else(|| "Token has expired".into())
|
msg.clone().unwrap_or_else(|| "Token has expired".into())
|
||||||
}
|
}
|
||||||
|
Self::UseDpopNonce(_) => "DPoP nonce required".into(),
|
||||||
|
Self::InvalidDpopProof(msg) => msg.clone(),
|
||||||
Self::RepoNotFound(msg) => msg
|
Self::RepoNotFound(msg) => msg
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Repository not found".into()),
|
.unwrap_or_else(|| "Repository not found".into()),
|
||||||
@@ -560,6 +596,36 @@ impl IntoResponse for ApiError {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Self::UseDpopNonce(nonce) => {
|
||||||
|
match HeaderValue::from_str(nonce) {
|
||||||
|
Ok(val) => {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(crate::util::HEADER_DPOP_NONCE, val);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(
|
||||||
|
?err,
|
||||||
|
nonce_len = nonce.len(),
|
||||||
|
"generated DPoP nonce is not a valid header value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::WWW_AUTHENTICATE,
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"DPoP error=\"use_dpop_nonce\", error_description=\"Resource server requires nonce in DPoP proof\"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self::InvalidDpopProof(_) => {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::WWW_AUTHENTICATE,
|
||||||
|
HeaderValue::from_static(
|
||||||
|
"DPoP error=\"invalid_dpop_proof\", error_description=\"Invalid DPoP proof\"",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
@@ -596,6 +662,8 @@ impl From<crate::auth::TokenValidationError> for ApiError {
|
|||||||
crate::auth::TokenValidationError::InvalidToken => {
|
crate::auth::TokenValidationError::InvalidToken => {
|
||||||
Self::AuthenticationFailed(Some("Invalid token format".to_string()))
|
Self::AuthenticationFailed(Some("Invalid token format".to_string()))
|
||||||
}
|
}
|
||||||
|
crate::auth::TokenValidationError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
|
||||||
|
crate::auth::TokenValidationError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,10 +693,8 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
|
|||||||
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
|
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
|
||||||
Self::OAuthExpiredToken(Some(msg))
|
Self::OAuthExpiredToken(Some(msg))
|
||||||
}
|
}
|
||||||
crate::auth::extractor::AuthError::UseDpopNonce(_)
|
crate::auth::extractor::AuthError::UseDpopNonce(nonce) => Self::UseDpopNonce(nonce),
|
||||||
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
|
crate::auth::extractor::AuthError::InvalidDpopProof(msg) => Self::InvalidDpopProof(msg),
|
||||||
Self::AuthenticationFailed(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool {
|
|||||||
PROTECTED_METHODS.contains(method)
|
PROTECTED_METHODS.contains(method)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the `feed` generator record from the AppView and return its `did`.
|
||||||
|
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<String> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GetFeedQuery {
|
||||||
|
feed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let feed = serde_urlencoded::from_str::<GetFeedQuery>(query?)
|
||||||
|
.ok()?
|
||||||
|
.feed;
|
||||||
|
let at_uri = crate::types::AtUri::new(feed).ok()?;
|
||||||
|
let repo = at_uri.did()?;
|
||||||
|
let collection = at_uri.collection()?;
|
||||||
|
let rkey = at_uri.rkey()?;
|
||||||
|
|
||||||
|
let resp = proxy_client()
|
||||||
|
.get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord"))
|
||||||
|
.query(&[("repo", repo), ("collection", collection), ("rkey", rkey)])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = resp.json().await.ok()?;
|
||||||
|
body.get("value")?.get("did")?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct XrpcProxyLayer {
|
pub struct XrpcProxyLayer {
|
||||||
state: AppState,
|
state: AppState,
|
||||||
}
|
}
|
||||||
@@ -287,10 +316,27 @@ async fn proxy_handler(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// getFeed must be audienced to the feed generator, not the AppView.
|
||||||
|
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
|
||||||
|
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||||
|
Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"),
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||||
|
to mint an AppView-audienced token"
|
||||||
|
);
|
||||||
|
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(resolved.did.clone(), method)
|
||||||
|
};
|
||||||
|
|
||||||
match crate::auth::create_service_token(
|
match crate::auth::create_service_token(
|
||||||
&auth_user.did,
|
&auth_user.did,
|
||||||
&resolved.did,
|
&token_aud,
|
||||||
Some(method),
|
Some(token_lxm),
|
||||||
&key_bytes,
|
&key_bytes,
|
||||||
) {
|
) {
|
||||||
Ok(new_token) => {
|
Ok(new_token) => {
|
||||||
|
|||||||
@@ -287,8 +287,6 @@ pub fn is_valid_discord_username(username: &str) -> bool {
|
|||||||
&& username
|
&& username
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
|
||||||
&& !username.starts_with('.')
|
|
||||||
&& !username.ends_with('.')
|
|
||||||
&& !username.contains("..")
|
&& !username.contains("..")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,8 +491,6 @@ mod tests {
|
|||||||
assert!(!is_valid_discord_username("Alice"));
|
assert!(!is_valid_discord_username("Alice"));
|
||||||
assert!(!is_valid_discord_username("ALICE"));
|
assert!(!is_valid_discord_username("ALICE"));
|
||||||
assert!(!is_valid_discord_username("user-name"));
|
assert!(!is_valid_discord_username("user-name"));
|
||||||
assert!(!is_valid_discord_username(".username"));
|
|
||||||
assert!(!is_valid_discord_username("username."));
|
|
||||||
assert!(!is_valid_discord_username("user..name"));
|
assert!(!is_valid_discord_username("user..name"));
|
||||||
assert!(!is_valid_discord_username("user name"));
|
assert!(!is_valid_discord_username("user name"));
|
||||||
assert!(!is_valid_discord_username(
|
assert!(!is_valid_discord_username(
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ pub async fn require_verified_or_delegated<'a>(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
user: &'a AuthenticatedUser,
|
user: &'a AuthenticatedUser,
|
||||||
) -> Result<AccountVerified<'a>, ApiError> {
|
) -> Result<AccountVerified<'a>, ApiError> {
|
||||||
|
if tranquil_config::get()
|
||||||
|
.server
|
||||||
|
.disable_account_verification_gate
|
||||||
|
{
|
||||||
|
return Ok(AccountVerified { user });
|
||||||
|
}
|
||||||
|
|
||||||
let is_verified = state
|
let is_verified = state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::marker::PhantomData;
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri},
|
extract::{FromRequestParts, OptionalFromRequestParts, OriginalUri},
|
||||||
http::{StatusCode, header::AUTHORIZATION, request::Parts},
|
http::{header::AUTHORIZATION, request::Parts},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
@@ -35,32 +35,7 @@ pub enum AuthError {
|
|||||||
|
|
||||||
impl IntoResponse for AuthError {
|
impl IntoResponse for AuthError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match self {
|
ApiError::from(self).into_response()
|
||||||
Self::UseDpopNonce(nonce) => (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
[
|
|
||||||
("DPoP-Nonce", nonce.as_str()),
|
|
||||||
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
|
|
||||||
],
|
|
||||||
axum::Json(serde_json::json!({
|
|
||||||
"error": "use_dpop_nonce",
|
|
||||||
"message": "DPoP nonce required"
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
|
|
||||||
Self::InvalidDpopProof(msg) => (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
|
|
||||||
axum::Json(serde_json::json!({
|
|
||||||
"error": "invalid_dpop_proof",
|
|
||||||
"message": msg
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
Self::InsufficientScope(msg) => ApiError::InsufficientScope(Some(msg)).into_response(),
|
|
||||||
other => ApiError::from(other).into_response(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ pub enum Legacy2faOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Legacy2faContext {
|
pub struct Legacy2faContext {
|
||||||
|
pub is_app_password: bool,
|
||||||
pub email_2fa_enabled: bool,
|
pub email_2fa_enabled: bool,
|
||||||
pub has_totp: bool,
|
pub has_totp: bool,
|
||||||
pub allow_legacy_login: bool,
|
pub allow_legacy_login: bool,
|
||||||
@@ -163,7 +164,7 @@ pub struct Legacy2faContext {
|
|||||||
|
|
||||||
impl Legacy2faContext {
|
impl Legacy2faContext {
|
||||||
pub fn requires_2fa(&self) -> bool {
|
pub fn requires_2fa(&self) -> bool {
|
||||||
self.email_2fa_enabled || self.has_totp
|
!self.is_app_password && (self.email_2fa_enabled || self.has_totp)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_blocked(&self) -> bool {
|
pub fn is_blocked(&self) -> bool {
|
||||||
@@ -418,6 +419,7 @@ mod tests {
|
|||||||
let cache = MockCache::new();
|
let cache = MockCache::new();
|
||||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||||
let ctx = Legacy2faContext {
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: false,
|
||||||
email_2fa_enabled: false,
|
email_2fa_enabled: false,
|
||||||
has_totp: false,
|
has_totp: false,
|
||||||
allow_legacy_login: true,
|
allow_legacy_login: true,
|
||||||
@@ -427,11 +429,27 @@ mod tests {
|
|||||||
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_process_flow_not_required_because_app_password() {
|
||||||
|
let cache = MockCache::new();
|
||||||
|
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||||
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: true,
|
||||||
|
email_2fa_enabled: false,
|
||||||
|
has_totp: true,
|
||||||
|
allow_legacy_login: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
|
||||||
|
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_process_flow_blocked() {
|
async fn test_process_flow_blocked() {
|
||||||
let cache = MockCache::new();
|
let cache = MockCache::new();
|
||||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||||
let ctx = Legacy2faContext {
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: false,
|
||||||
email_2fa_enabled: false,
|
email_2fa_enabled: false,
|
||||||
has_totp: true,
|
has_totp: true,
|
||||||
allow_legacy_login: false,
|
allow_legacy_login: false,
|
||||||
@@ -446,6 +464,7 @@ mod tests {
|
|||||||
let cache = MockCache::new();
|
let cache = MockCache::new();
|
||||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||||
let ctx = Legacy2faContext {
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: false,
|
||||||
email_2fa_enabled: false,
|
email_2fa_enabled: false,
|
||||||
has_totp: true,
|
has_totp: true,
|
||||||
allow_legacy_login: true,
|
allow_legacy_login: true,
|
||||||
@@ -460,6 +479,7 @@ mod tests {
|
|||||||
let cache = MockCache::new();
|
let cache = MockCache::new();
|
||||||
let did = Did::new("did:plc:test2".to_string()).unwrap();
|
let did = Did::new("did:plc:test2".to_string()).unwrap();
|
||||||
let ctx = Legacy2faContext {
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: false,
|
||||||
email_2fa_enabled: true,
|
email_2fa_enabled: true,
|
||||||
has_totp: false,
|
has_totp: false,
|
||||||
allow_legacy_login: false,
|
allow_legacy_login: false,
|
||||||
@@ -474,6 +494,7 @@ mod tests {
|
|||||||
let cache = MockCache::new();
|
let cache = MockCache::new();
|
||||||
let did = Did::new("did:plc:test".to_string()).unwrap();
|
let did = Did::new("did:plc:test".to_string()).unwrap();
|
||||||
let ctx = Legacy2faContext {
|
let ctx = Legacy2faContext {
|
||||||
|
is_app_password: false,
|
||||||
email_2fa_enabled: true,
|
email_2fa_enabled: true,
|
||||||
has_totp: false,
|
has_totp: false,
|
||||||
allow_legacy_login: false,
|
allow_legacy_login: false,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user