mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-25 11:44:15 +00:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b3338b31 | ||
|
|
91114c491a | ||
|
|
3d0376c476 | ||
|
|
f1d963988e | ||
|
|
cc9ac301ca | ||
|
|
75c341b94b | ||
|
|
0b29c40b27 | ||
|
|
34ece34296 | ||
|
|
833356116a | ||
|
|
643e1bb902 | ||
|
|
9f05ea5f31 | ||
|
|
71cd282d1e | ||
|
|
0e40bdca19 | ||
|
|
156066fe1b | ||
|
|
311530a9a9 | ||
|
|
08cd3fa100 | ||
|
|
12a8712eae | ||
|
|
cdd5fa70c9 | ||
|
|
218741050d | ||
|
|
b3ff62c221 | ||
|
|
2088f59197 | ||
|
|
2fc5f2e308 | ||
|
|
877b587481 | ||
|
|
695a7d981c | ||
|
|
04689cbe25 | ||
|
|
3474ed588d | ||
|
|
09ba5e4521 | ||
|
|
6750aeccaf | ||
|
|
8c3386a3ab | ||
|
|
eba8167da8 | ||
|
|
2e92310518 | ||
|
|
0e82a38add | ||
|
|
1866d2bf0e | ||
|
|
dc2f924130 | ||
|
|
97224551bf | ||
|
|
68ae485a52 | ||
|
|
228c1bbbf5 | ||
|
|
093484388f | ||
|
|
0f0c50f7d4 | ||
|
|
26aa399cda | ||
|
|
f296bb68df | ||
|
|
d979cb969a | ||
|
|
739db41130 | ||
|
|
c0caa93228 | ||
|
|
3ade3d10c1 | ||
|
|
0189aa9f96 | ||
|
|
d495d7d729 | ||
|
|
73cb89c9b7 | ||
|
|
ecb7934a20 | ||
|
|
9edc7dcdd8 | ||
|
|
479fa3ed22 | ||
|
|
aa815931e0 | ||
|
|
0ce725174d | ||
|
|
dae3cc7e08 | ||
|
|
b9e7955606 | ||
|
|
32c58b1d0b | ||
|
|
1b5a2b319c | ||
|
|
ed3d129594 | ||
|
|
8d0b6f8322 | ||
|
|
0fc577316e | ||
|
|
52d5236e89 | ||
|
|
0274f19d75 | ||
|
|
135912194d | ||
|
|
0b8787d1de | ||
|
|
18455f54f2 | ||
|
|
ce2f05b9d4 | ||
|
|
c88f69f31d | ||
|
|
b3c314ce66 | ||
|
|
434079a732 | ||
|
|
a5a2f30bbe | ||
|
|
dc2fbe6654 | ||
|
|
bc751b0ee2 | ||
|
|
9e78206cf4 | ||
|
|
779dc1b985 | ||
|
|
1dc0c40206 |
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
.containerignore
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1",
|
"query": "SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1 LIMIT 1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -30,5 +30,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "dd1b61d6ec81fd891d4effd3b51e6c22308b878acdc5355dfcb04c5664c9463b"
|
"hash": "03f129e4984e1bed9e87294adc9caf1730906d889101b9039113ec8aa234618d"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as \"total!\" FROM blobs",
|
"query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as \"total!\"\n FROM (SELECT DISTINCT cid, size_bytes FROM blobs) t",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -16,5 +16,5 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0890b2c7c921005f58ed0e57b6e062b2085ce804a4cccb27b4ae2ba6711f24c4"
|
"hash": "155efbae4cd55f73ec0709dda7b18a76e92065e6ae4a6081bd38a19821fbfcc3"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
|
"query": "SELECT cid, takedown_ref FROM blobs WHERE cid = $1 ORDER BY takedown_ref NULLS LAST LIMIT 1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "62942bd21d545eb15bfea4f46378b6c2ebfe12b8bc9e27c63a6c0f77a9105303"
|
"hash": "5996484ff0f8dbc3b278cfd01b8375dbf7bf6da8d903145b12871dda6e1fd5d9"
|
||||||
}
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT storage_key as \"storage_key!\" FROM blobs b\n WHERE created_by_user = $1\n AND NOT EXISTS (\n SELECT 1 FROM blobs o\n WHERE o.cid = b.cid AND o.created_by_user <> $1\n )",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "storage_key!",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8844d942ef2810afc386e5a9838624ee07a43c380d2df31efdba5cf299aab571"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT u.id as user_id, u.did\n FROM users u\n JOIN repos r ON r.user_id = u.id\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "did",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8951136879711bca5b562c34f88e691a8ee16f370f6ef9b88ddb3873ddf2b45f"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (cid) DO NOTHING RETURNING cid",
|
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (cid, created_by_user) DO NOTHING RETURNING cid",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -22,5 +22,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8afea2b745385348f4c78b51f74145d6718bfcf9a3a0c218109ec691aeb930ba"
|
"hash": "996e5513fb55670fe3304a6046381e377da6a187dfa3347bd285078a7b4410f2"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT storage_key FROM blobs WHERE cid = $1",
|
"query": "SELECT storage_key FROM blobs WHERE cid = $1 LIMIT 1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052"
|
"hash": "9fb9e128076b20ff067d01955221488ce7e5b886dba0529fb073c3e0461fe030"
|
||||||
}
|
}
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM blobs WHERE cid = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12"
|
|
||||||
}
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n SELECT DISTINCT b.cid, b.mime_type, b.size_bytes, $1::uuid, b.storage_key\n FROM blobs b WHERE b.cid = $2\n ON CONFLICT (cid, created_by_user) DO NOTHING",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e248d71f595abf0207b01bc2f4e1f312d0c96b0f2f5131dfc13bfbb42a79d886"
|
||||||
|
}
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT storage_key as \"storage_key!\" FROM blobs WHERE created_by_user = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "storage_key!",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "f59010ecdd7f782489e0e03288a06dacd72b33d04c1e2b98475018ad25485852"
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3",
|
"query": "SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = $1\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8e88ec169f0ef14c7207944cd4c2c0970e302c0457f9e317ec752dc13a5b1393"
|
"hash": "f8bb421e07e47f7b0a3b2789e368abfa6ad64152e8660e3efc7117b2d9320f22"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -64,7 +64,7 @@ In order of importance the following rules describe what "correct" means for Tra
|
|||||||
and not something said application relies on for proper functioning.
|
and not something said application relies on for proper functioning.
|
||||||
|
|
||||||
There is bound to be edge cases that these rules don't fully cover.
|
There is bound to be edge cases that these rules don't fully cover.
|
||||||
Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual applicaion.
|
Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual application.
|
||||||
Even Bluesky.
|
Even Bluesky.
|
||||||
|
|
||||||
The rules above are meant to capture Tranquils goals of being correct while being community oriented and avoiding as much "Bluesky-defaultism" as possible.
|
The rules above are meant to capture Tranquils goals of being correct while being community oriented and avoiding as much "Bluesky-defaultism" as possible.
|
||||||
|
|||||||
Generated
+74
-23
@@ -105,6 +105,21 @@ version = "0.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
|
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1250,6 +1265,27 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bs58"
|
name = "bs58"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -7629,7 +7665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-api"
|
name = "tranquil-api"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"backon",
|
"backon",
|
||||||
@@ -7676,12 +7712,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-auth"
|
name = "tranquil-auth"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base32",
|
"base32",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"brotli",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hmac",
|
"hmac",
|
||||||
"k256",
|
"k256",
|
||||||
@@ -7700,7 +7737,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-cache"
|
name = "tranquil-cache"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7715,7 +7752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-comms"
|
name = "tranquil-comms"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7735,19 +7772,22 @@ dependencies = [
|
|||||||
"tranquil-config",
|
"tranquil-config",
|
||||||
"tranquil-db-traits",
|
"tranquil-db-traits",
|
||||||
"tranquil-signal",
|
"tranquil-signal",
|
||||||
|
"tranquil-types",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-config"
|
name = "tranquil-config"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"confique",
|
"confique",
|
||||||
|
"serde",
|
||||||
|
"tranquil-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-crypto"
|
name = "tranquil-crypto"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7763,7 +7803,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-db"
|
name = "tranquil-db"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -7780,7 +7820,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-db-traits"
|
name = "tranquil-db-traits"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -7796,17 +7836,20 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-infra"
|
name = "tranquil-infra"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tranquil-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-lexicon"
|
name = "tranquil-lexicon"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -7818,15 +7861,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tranquil-infra",
|
||||||
"tranquil-types",
|
"tranquil-types",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"urlencoding",
|
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-oauth"
|
name = "tranquil-oauth"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -7843,13 +7886,14 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tranquil-infra",
|
||||||
"tranquil-types",
|
"tranquil-types",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-oauth-server"
|
name = "tranquil-oauth-server"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -7874,6 +7918,7 @@ dependencies = [
|
|||||||
"tranquil-config",
|
"tranquil-config",
|
||||||
"tranquil-crypto",
|
"tranquil-crypto",
|
||||||
"tranquil-db-traits",
|
"tranquil-db-traits",
|
||||||
|
"tranquil-infra",
|
||||||
"tranquil-pds",
|
"tranquil-pds",
|
||||||
"tranquil-scopes",
|
"tranquil-scopes",
|
||||||
"tranquil-types",
|
"tranquil-types",
|
||||||
@@ -7884,7 +7929,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-pds"
|
name = "tranquil-pds"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -7954,6 +7999,7 @@ dependencies = [
|
|||||||
"tranquil-config",
|
"tranquil-config",
|
||||||
"tranquil-db",
|
"tranquil-db",
|
||||||
"tranquil-db-traits",
|
"tranquil-db-traits",
|
||||||
|
"tranquil-infra",
|
||||||
"tranquil-lexicon",
|
"tranquil-lexicon",
|
||||||
"tranquil-oauth",
|
"tranquil-oauth",
|
||||||
"tranquil-oauth-server",
|
"tranquil-oauth-server",
|
||||||
@@ -7974,7 +8020,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-repo"
|
name = "tranquil-repo"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cid",
|
"cid",
|
||||||
@@ -7986,7 +8032,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-ripple"
|
name = "tranquil-ripple"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"backon",
|
"backon",
|
||||||
@@ -8015,7 +8061,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-scopes"
|
name = "tranquil-scopes"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -8032,7 +8078,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-server"
|
name = "tranquil-server"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -8069,7 +8115,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-signal"
|
name = "tranquil-signal"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8084,13 +8130,14 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tranquil-types",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-storage"
|
name = "tranquil-storage"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -8107,7 +8154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-store"
|
name = "tranquil-store"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -8155,7 +8202,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-sync"
|
name = "tranquil-sync"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -8177,17 +8224,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tranquil-types"
|
name = "tranquil-types"
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cid",
|
"cid",
|
||||||
"jacquard-common",
|
"jacquard-common",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.6.6"
|
version = "0.6.7"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
||||||
@@ -137,6 +137,7 @@ tower-layer = "0.3"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
|
url = "2.5"
|
||||||
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
|
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
|
||||||
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
|
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
|
||||||
webauthn-rs-proto = "0.5"
|
webauthn-rs-proto = "0.5"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:1e3c6d9c255be500eb680cdea0ad07554f52ae92dfcbdf07043a2a435b4c1fe3
|
ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:a017e74bd2a12d98342dbecd33d121d2b160415ed777573dc1808969e989d94d
|
||||||
|
|
||||||
FROM node:24-trixie-slim AS frontend
|
FROM node:24-trixie-slim AS frontend
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ podman-compose -f docker-compose.prod.yaml up -d
|
|||||||
### Installation Guides
|
### Installation Guides
|
||||||
|
|
||||||
- [Nix](docs/2_INSTALL_NIX.md)
|
- [Nix](docs/2_INSTALL_NIX.md)
|
||||||
|
- [Alpine](docs/2_INSTALL_ALPINE.md)
|
||||||
- [Containers](docs/2_INSTALL_CONTAINERS.md)
|
- [Containers](docs/2_INSTALL_CONTAINERS.md)
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
@@ -79,6 +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)
|
||||||
|
- [@jola.dev](https://tangled.org/did:plc:bvraa6gajy4tfr3eh2sisdkr)
|
||||||
|
|
||||||
### Amazing contributors
|
### Amazing contributors
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[server]
|
[server]
|
||||||
hostname = "pds.test"
|
hostname = "pds.test"
|
||||||
allow_http_proxy = true
|
allow_http_proxy = true
|
||||||
|
allow_private_fetch = true
|
||||||
invite_code_required = false
|
invite_code_required = false
|
||||||
disable_rate_limiting = true
|
disable_rate_limiting = true
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ dir = "/app/frontend/public"
|
|||||||
|
|
||||||
[database]
|
[database]
|
||||||
url = "postgres://postgres:postgres@db:5432/pds"
|
url = "postgres://postgres:postgres@db:5432/pds"
|
||||||
|
max_connections = 20
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
path = "/var/lib/tranquil-pds/blobs"
|
path = "/var/lib/tranquil-pds/blobs"
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ pub async fn send_email(
|
|||||||
.log_db_err("in send_email")?
|
.log_db_err("in send_email")?
|
||||||
.ok_or(ApiError::AccountNotFound)?;
|
.ok_or(ApiError::AccountNotFound)?;
|
||||||
|
|
||||||
let email = user.email.ok_or(ApiError::NoEmail)?;
|
let email = tranquil_types::EmailAddress::new(&user.email.ok_or(ApiError::NoEmail)?)
|
||||||
|
.map_err(|e| ApiError::InvalidRequest(e.to_string()))?;
|
||||||
let (user_id, handle) = (user.id, user.handle);
|
let (user_id, handle) = (user.id, user.handle);
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
let subject = input
|
let subject = input
|
||||||
@@ -50,9 +51,8 @@ pub async fn send_email(
|
|||||||
.infra
|
.infra
|
||||||
.enqueue_comms(
|
.enqueue_comms(
|
||||||
Some(user_id),
|
Some(user_id),
|
||||||
tranquil_db_traits::CommsChannel::Email,
|
&tranquil_types::Recipient::Email(email),
|
||||||
tranquil_db_traits::CommsType::AdminEmail,
|
tranquil_db_traits::CommsType::AdminEmail,
|
||||||
&email,
|
|
||||||
Some(&subject),
|
Some(&subject),
|
||||||
content,
|
content,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ pub async fn update_account_email(
|
|||||||
Json(input): Json<UpdateAccountEmailInput>,
|
Json(input): Json<UpdateAccountEmailInput>,
|
||||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||||
let account = input.account.trim();
|
let account = input.account.trim();
|
||||||
let email = input.email.trim();
|
let email = tranquil_types::EmailAddress::new(&input.email)?;
|
||||||
if account.is_empty() || email.is_empty() {
|
if account.is_empty() {
|
||||||
return Err(ApiError::InvalidRequest(
|
return Err(ApiError::InvalidRequest(
|
||||||
"account and email are required".into(),
|
"Account is required, silly!".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let account_did: Did = account
|
let account_did: Did = account
|
||||||
@@ -32,7 +32,7 @@ pub async fn update_account_email(
|
|||||||
match state
|
match state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
.admin_update_email(&account_did, email)
|
.admin_update_email(&account_did, &email)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(0) => Err(ApiError::AccountNotFound),
|
Ok(0) => Err(ApiError::AccountNotFound),
|
||||||
@@ -66,11 +66,11 @@ pub async fn update_account_handle(
|
|||||||
{
|
{
|
||||||
return Err(ApiError::InvalidHandle(None));
|
return Err(ApiError::InvalidHandle(None));
|
||||||
}
|
}
|
||||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
let primary = tranquil_pds::handle::ServiceDomains::for_user_handles().primary();
|
||||||
let handle = if !input_handle.contains('.') {
|
let handle = if input_handle.contains('.') {
|
||||||
format!("{}.{}", input_handle, &available_domains[0])
|
|
||||||
} else {
|
|
||||||
input_handle.to_string()
|
input_handle.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.{}", input_handle, primary)
|
||||||
};
|
};
|
||||||
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
|
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
|
||||||
let user_id = state
|
let user_id = state
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use axum::{Json, extract::State};
|
use axum::{Json, extract::State};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, warn};
|
|
||||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||||
use tranquil_pds::auth::{Admin, Auth};
|
use tranquil_pds::auth::{Admin, Auth};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_types::CidLink;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -183,46 +181,6 @@ pub async fn update_server_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref logo_cid) = req.logo_cid {
|
if let Some(ref logo_cid) = req.logo_cid {
|
||||||
let old_logo_cid = state
|
|
||||||
.repos
|
|
||||||
.infra
|
|
||||||
.get_server_config("logo_cid")
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
|
|
||||||
(Some(old), true) => Some(old.clone()),
|
|
||||||
(Some(old), false) if old != logo_cid => Some(old.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(old_cid_str) = should_delete_old {
|
|
||||||
match CidLink::new(old_cid_str) {
|
|
||||||
Ok(old_cid) => {
|
|
||||||
if let Ok(Some(storage_key)) = state
|
|
||||||
.repos
|
|
||||||
.infra
|
|
||||||
.get_blob_storage_key_by_cid(&old_cid)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Err(e) = state.blob_store.delete(&storage_key).await {
|
|
||||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
|
||||||
}
|
|
||||||
if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await {
|
|
||||||
error!("Failed to delete old logo blob record: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Old logo CID in database is invalid, skipping cleanup: {:?}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if logo_cid.is_empty() {
|
if logo_cid.is_empty() {
|
||||||
state
|
state
|
||||||
.repos
|
.repos
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use bcrypt::{DEFAULT_COST, hash};
|
use bcrypt::{DEFAULT_COST, hash};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::error;
|
use tracing::{error, warn};
|
||||||
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
||||||
use tranquil_pds::api::error::ApiError;
|
use tranquil_pds::api::error::ApiError;
|
||||||
use tranquil_pds::api::error::DbResultExt;
|
use tranquil_pds::api::error::DbResultExt;
|
||||||
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
|
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
|
||||||
|
use tranquil_types::{DiscordUsername, SignalUsername, TelegramUsername};
|
||||||
|
|
||||||
pub struct ResolvedRepo {
|
pub struct ResolvedRepo {
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
@@ -156,42 +157,70 @@ pub struct ChannelInput<'a> {
|
|||||||
pub fn extract_verification_recipient(
|
pub fn extract_verification_recipient(
|
||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
input: &ChannelInput<'_>,
|
input: &ChannelInput<'_>,
|
||||||
) -> Result<String, ApiError> {
|
) -> Result<tranquil_pds::comms::VerificationTarget, ApiError> {
|
||||||
match channel {
|
match channel {
|
||||||
CommsChannel::Email => match input.email {
|
CommsChannel::Email => {
|
||||||
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
|
let raw = trimmed(input.email).ok_or(ApiError::MissingEmail)?;
|
||||||
_ => Err(ApiError::MissingEmail),
|
let address = tranquil_types::EmailAddress::new(raw)?;
|
||||||
},
|
Ok(tranquil_pds::comms::VerificationTarget::direct(
|
||||||
CommsChannel::Discord => match input.discord_username {
|
tranquil_db_traits::Recipient::Email(address),
|
||||||
Some(username) if !username.trim().is_empty() => {
|
))
|
||||||
let clean = username.trim().to_lowercase();
|
}
|
||||||
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
|
CommsChannel::Signal => {
|
||||||
return Err(ApiError::InvalidRequest(
|
let raw = trimmed(input.signal_username).ok_or(ApiError::MissingSignalNumber)?;
|
||||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
let username = SignalUsername::new(raw)?;
|
||||||
));
|
Ok(tranquil_pds::comms::VerificationTarget::direct(
|
||||||
}
|
tranquil_db_traits::Recipient::Signal(username),
|
||||||
Ok(clean)
|
))
|
||||||
}
|
}
|
||||||
_ => Err(ApiError::MissingDiscordId),
|
CommsChannel::Telegram => {
|
||||||
},
|
let raw = trimmed(input.telegram_username).ok_or(ApiError::MissingTelegramUsername)?;
|
||||||
CommsChannel::Telegram => match input.telegram_username {
|
let username = TelegramUsername::new(raw)?;
|
||||||
Some(username) if !username.trim().is_empty() => {
|
tranquil_pds::comms::VerificationTarget::resolve(
|
||||||
let clean = username.trim().trim_start_matches('@');
|
channel,
|
||||||
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
|
username.as_str(),
|
||||||
return Err(ApiError::InvalidRequest(
|
input.email,
|
||||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
)
|
||||||
));
|
}
|
||||||
}
|
CommsChannel::Discord => {
|
||||||
Ok(clean.to_string())
|
let raw = trimmed(input.discord_username).ok_or(ApiError::MissingDiscordId)?;
|
||||||
}
|
let username = DiscordUsername::new(raw)?;
|
||||||
_ => Err(ApiError::MissingTelegramUsername),
|
tranquil_pds::comms::VerificationTarget::resolve(
|
||||||
},
|
channel,
|
||||||
CommsChannel::Signal => match input.signal_username {
|
username.as_str(),
|
||||||
Some(username) if !username.trim().is_empty() => {
|
input.email,
|
||||||
Ok(username.trim().trim_start_matches('@').to_lowercase())
|
)
|
||||||
}
|
}
|
||||||
_ => Err(ApiError::MissingSignalNumber),
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
fn trimmed(raw: Option<&str>) -> Option<&str> {
|
||||||
|
raw.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn implicitly_verify_channel(
|
||||||
|
user_repo: &dyn UserRepository,
|
||||||
|
did: &Did,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
preferred_channel: CommsChannel,
|
||||||
|
context: &'static str,
|
||||||
|
) {
|
||||||
|
let Ok(Some(prefs)) = user_repo.get_comms_prefs(user_id).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(recipient) = tranquil_pds::comms::recipient_for(&prefs, preferred_channel) else {
|
||||||
|
warn!(
|
||||||
|
did = %did,
|
||||||
|
preferred = ?preferred_channel,
|
||||||
|
"We skipped implicit verification on {context} because the account doesn't have a valid recipient"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = user_repo
|
||||||
|
.set_channel_verified(did, recipient.channel())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("Implicit verification on {context} failed: {:?}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use tranquil_pds::api::{
|
|||||||
};
|
};
|
||||||
use tranquil_pds::auth::{Active, Auth};
|
use tranquil_pds::auth::{Active, Auth};
|
||||||
use tranquil_pds::delegation::{
|
use tranquil_pds::delegation::{
|
||||||
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
|
DelegationActionType, IdentityResolutionError, SCOPE_PRESETS, ValidatedDelegationScope,
|
||||||
verify_can_control_accounts,
|
verify_can_add_controllers, verify_can_control_accounts,
|
||||||
};
|
};
|
||||||
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
@@ -65,16 +65,16 @@ pub async fn add_controller(
|
|||||||
) -> Result<Json<SuccessResponse>, ApiError> {
|
) -> Result<Json<SuccessResponse>, ApiError> {
|
||||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
|
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
.map_err(|e| match e {
|
||||||
|
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
|
||||||
|
"Controller PDS endpoint isn't a usable https URL".into(),
|
||||||
|
),
|
||||||
|
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
|
||||||
|
})?;
|
||||||
|
|
||||||
if !resolved.is_local
|
if !resolved.is_local
|
||||||
&& let Some(ref pds_url) = resolved.pds_url
|
&& let Some(ref pds_url) = resolved.pds_url
|
||||||
{
|
{
|
||||||
if !pds_url.starts_with("https://") {
|
|
||||||
return Err(ApiError::InvalidDelegation(
|
|
||||||
"Controller PDS must use HTTPS".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match state
|
match state
|
||||||
.cross_pds_oauth
|
.cross_pds_oauth
|
||||||
.check_remote_is_delegated(pds_url, &input.controller_did)
|
.check_remote_is_delegated(pds_url, &input.controller_did)
|
||||||
@@ -368,8 +368,8 @@ pub async fn create_delegated_account(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|e| e.trim().to_string())
|
.map(|e| e.trim().to_string())
|
||||||
.filter(|e| !e.is_empty());
|
.filter(|e| !e.is_empty());
|
||||||
if let Some(ref email) = email
|
if let Some(email) = &email
|
||||||
&& !tranquil_pds::api::validation::is_valid_email(email)
|
&& tranquil_types::EmailAddress::new(email).is_err()
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidEmail);
|
return Err(ApiError::InvalidEmail);
|
||||||
}
|
}
|
||||||
@@ -477,7 +477,12 @@ pub async fn resolve_controller(
|
|||||||
|
|
||||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
|
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
.map_err(|e| match e {
|
||||||
|
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
|
||||||
|
"Controller PDS endpoint isn't a usable https URL".into(),
|
||||||
|
),
|
||||||
|
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(resolved))
|
Ok(Json(resolved))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,20 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
|||||||
None => {
|
None => {
|
||||||
return Json(json!({
|
return Json(json!({
|
||||||
"type": 4,
|
"type": 4,
|
||||||
"data": {"content": "Could not identify user", "flags": 64}
|
"data": {"content": "Couldn't identify user", "flags": 64}
|
||||||
|
}))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (discord_user_id, discord_username) = match (
|
||||||
|
tranquil_types::DiscordUserId::new(&discord_user_id),
|
||||||
|
tranquil_types::DiscordUsername::new(&discord_username),
|
||||||
|
) {
|
||||||
|
(Ok(discord_user_id), Ok(discord_username)) => (discord_user_id, discord_username),
|
||||||
|
_ => {
|
||||||
|
return Json(json!({
|
||||||
|
"type": 4,
|
||||||
|
"data": {"content": "Couldn't verify your Discord account", "flags": 64}
|
||||||
}))
|
}))
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -184,18 +197,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
|||||||
discord_user_id = %discord_user_id,
|
discord_user_id = %discord_user_id,
|
||||||
"Verified Discord user and stored user ID"
|
"Verified Discord user and stored user ID"
|
||||||
);
|
);
|
||||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
comms_repo::try_channel_verified_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
tranquil_db_traits::CommsChannel::Discord,
|
&tranquil_types::Recipient::Discord(discord_user_id),
|
||||||
&discord_user_id,
|
|
||||||
&tranquil_config::get().server.hostname,
|
&tranquil_config::get().server.hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
|
||||||
}
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"type": 4,
|
"type": 4,
|
||||||
"data": {"content": "Verified", "flags": 64}
|
"data": {"content": "Verified", "flags": 64}
|
||||||
@@ -299,11 +308,11 @@ mod tests {
|
|||||||
fn parse_handle_whitespace_trimmed() {
|
fn parse_handle_whitespace_trimmed() {
|
||||||
let options = vec![InteractionOption {
|
let options = vec![InteractionOption {
|
||||||
name: "handle".to_string(),
|
name: "handle".to_string(),
|
||||||
value: serde_json::json!(" alice.example.com "),
|
value: serde_json::json!(" oystercafe.jola.dev "),
|
||||||
}];
|
}];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_start_handle(Some(&options)),
|
parse_start_handle(Some(&options)),
|
||||||
Some("alice.example.com".to_string()),
|
Some("oystercafe.jola.dev".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async fn try_reactivate_migration(
|
|||||||
handle: &Handle,
|
handle: &Handle,
|
||||||
email: &Option<String>,
|
email: &Option<String>,
|
||||||
verification_channel: tranquil_db_traits::CommsChannel,
|
verification_channel: tranquil_db_traits::CommsChannel,
|
||||||
verification_recipient: Option<&str>,
|
verification_recipient: Option<&tranquil_pds::comms::VerificationTarget>,
|
||||||
) -> Option<Response> {
|
) -> Option<Response> {
|
||||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||||
did: did.clone(),
|
did: did.clone(),
|
||||||
@@ -147,12 +147,7 @@ async fn try_reactivate_migration(
|
|||||||
Json(CreateAccountOutput {
|
Json(CreateAccountOutput {
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
did: did.clone(),
|
did: did.clone(),
|
||||||
did_doc: state
|
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
|
||||||
.did_resolver
|
|
||||||
.fetch_did_document(did)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|f| (*f).clone()),
|
|
||||||
access_jwt: access_meta.token,
|
access_jwt: access_meta.token,
|
||||||
refresh_jwt: refresh_meta.token,
|
refresh_jwt: refresh_meta.token,
|
||||||
verification_required,
|
verification_required,
|
||||||
@@ -276,8 +271,8 @@ pub async fn create_account(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|e| e.trim().to_string())
|
.map(|e| e.trim().to_string())
|
||||||
.filter(|e| !e.is_empty());
|
.filter(|e| !e.is_empty());
|
||||||
if let Some(ref email) = email
|
if let Some(email) = &email
|
||||||
&& !tranquil_pds::api::validation::is_valid_email(email)
|
&& tranquil_types::EmailAddress::new(email).is_err()
|
||||||
{
|
{
|
||||||
return ApiError::InvalidEmail.into_response();
|
return ApiError::InvalidEmail.into_response();
|
||||||
}
|
}
|
||||||
@@ -398,7 +393,7 @@ pub async fn create_account(
|
|||||||
&handle,
|
&handle,
|
||||||
&email,
|
&email,
|
||||||
verification_channel,
|
verification_channel,
|
||||||
verification_recipient.as_deref(),
|
verification_recipient.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -568,7 +563,7 @@ pub async fn create_account(
|
|||||||
Json(CreateAccountOutput {
|
Json(CreateAccountOutput {
|
||||||
handle: handle.clone(),
|
handle: handle.clone(),
|
||||||
did,
|
did,
|
||||||
did_doc: did_doc.map(|f| (*f).clone()),
|
did_doc,
|
||||||
access_jwt: session.access_jwt,
|
access_jwt: session.access_jwt,
|
||||||
refresh_jwt: session.refresh_jwt,
|
refresh_jwt: session.refresh_jwt,
|
||||||
verification_required: !is_migration,
|
verification_required: !is_migration,
|
||||||
|
|||||||
@@ -132,12 +132,9 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
|
|||||||
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
|
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
|
||||||
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
||||||
if host_without_port != hostname_without_port {
|
if host_without_port != hostname_without_port {
|
||||||
let is_subdomain = cfg
|
let is_subdomain = tranquil_pds::handle::ServiceDomains::served()
|
||||||
.server
|
.split_handle(host_without_port)
|
||||||
.available_user_domain_list()
|
.is_some();
|
||||||
.into_iter()
|
|
||||||
.chain(std::iter::once(hostname_without_port.to_string()))
|
|
||||||
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
|
|
||||||
if is_subdomain {
|
if is_subdomain {
|
||||||
return serve_handle_did_doc(&state, host_without_port, hostname).await;
|
return serve_handle_did_doc(&state, host_without_port, hostname).await;
|
||||||
}
|
}
|
||||||
@@ -582,26 +579,16 @@ pub async fn update_handle(
|
|||||||
"Inappropriate language in handle".into(),
|
"Inappropriate language in handle".into(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
|
let handle_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
|
||||||
let matched_handle_domain = handle_domains
|
let split = handle_domains.split_handle(&new_handle);
|
||||||
.iter()
|
let is_domain_itself = handle_domains.contains(&new_handle);
|
||||||
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
|
let handle: Handle = if (!new_handle.contains('.') || split.is_some()) && !is_domain_itself {
|
||||||
.max_by_key(|d| d.len())
|
let (short_part, full_handle) = match split {
|
||||||
.cloned();
|
Some((_domain, short)) => (short.to_string(), new_handle.clone()),
|
||||||
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
|
None => (
|
||||||
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
|
new_handle.clone(),
|
||||||
&& !is_domain_itself
|
format!("{}.{}", new_handle, handle_domains.primary()),
|
||||||
{
|
),
|
||||||
let (short_part, full_handle) = match &matched_handle_domain {
|
|
||||||
Some(domain) => {
|
|
||||||
let suffix = format!(".{}", domain);
|
|
||||||
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
|
|
||||||
(short.to_string(), new_handle.clone())
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let primary = &handle_domains[0];
|
|
||||||
(new_handle.clone(), format!("{}.{}", new_handle, primary))
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
if full_handle == current_handle {
|
if full_handle == current_handle {
|
||||||
let handle: Handle = match full_handle.parse() {
|
let handle: Handle = match full_handle.parse() {
|
||||||
|
|||||||
@@ -35,16 +35,27 @@ pub async fn request_plc_operation_signature(
|
|||||||
.log_db_err("creating PLC token")?;
|
.log_db_err("creating PLC token")?;
|
||||||
|
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_plc_operation(
|
match tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
&display_token,
|
tranquil_pds::comms::Notice::PlcOperation {
|
||||||
|
token: &display_token,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to enqueue PLC operation notification: {:?}", e);
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(ApiError::InvalidRequest(
|
||||||
|
"We couldn't deliver the PLC operation code to your notification channels. Please contact the PDS owner."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to enqueue PLC operation notification: {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info!("PLC operation signature requested for user {}", auth.did);
|
info!("PLC operation signature requested for user {}", auth.did);
|
||||||
Ok(Json(EmptyResponse {}))
|
Ok(Json(EmptyResponse {}))
|
||||||
|
|||||||
@@ -9,10 +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::{
|
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
|
||||||
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
|
|
||||||
signing_key_to_did_key,
|
|
||||||
};
|
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -118,18 +115,6 @@ pub async fn sign_plc_operation(
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let signing_did_key = signing_key_to_did_key(&signing_key);
|
|
||||||
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
|
|
||||||
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
|
|
||||||
if let Some(missing) = missing_required_rotation_key(
|
|
||||||
&rotation_key_strs,
|
|
||||||
&signing_did_key,
|
|
||||||
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
|
|
||||||
) {
|
|
||||||
return Err(ApiError::InvalidRequest(missing.message().into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
|
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
|
||||||
error!("Failed to sign PLC operation: {:?}", e);
|
error!("Failed to sign PLC operation: {:?}", e);
|
||||||
ApiError::InternalError(None)
|
ApiError::InternalError(None)
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ pub async fn resolve_signing_key(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(
|
||||||
|
not(feature = "bsky"),
|
||||||
|
expect(
|
||||||
|
unused_variables,
|
||||||
|
reason = "only the bsky block writes display_name into the default profile record"
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn sequence_new_account(
|
pub async fn sequence_new_account(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
@@ -308,18 +315,17 @@ pub async fn enqueue_signup_verification(
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
recipient: &str,
|
target: &tranquil_pds::comms::VerificationTarget,
|
||||||
) {
|
) {
|
||||||
let token =
|
let token =
|
||||||
tranquil_pds::auth::verification_token::generate_signup_token(did, channel, recipient);
|
tranquil_pds::auth::verification_token::generate_signup_token(did, channel, &target.id);
|
||||||
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
channel,
|
target,
|
||||||
recipient,
|
|
||||||
&formatted,
|
&formatted,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
@@ -334,18 +340,17 @@ pub async fn enqueue_migration_verification(
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
recipient: &str,
|
target: &tranquil_pds::comms::VerificationTarget,
|
||||||
) {
|
) {
|
||||||
let token =
|
let token =
|
||||||
tranquil_pds::auth::verification_token::generate_migration_token(did, channel, recipient);
|
tranquil_pds::auth::verification_token::generate_migration_token(did, channel, &target.id);
|
||||||
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
channel,
|
target,
|
||||||
recipient,
|
|
||||||
&formatted,
|
&formatted,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -467,9 +467,15 @@ pub fn api_routes() -> axum::Router<AppState> {
|
|||||||
pub fn well_known_api_routes() -> axum::Router<AppState> {
|
pub fn well_known_api_routes() -> axum::Router<AppState> {
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
|
||||||
axum::Router::new()
|
let routes = axum::Router::new()
|
||||||
.route("/did.json", get(identity::well_known_did))
|
.route("/did.json", get(identity::well_known_did))
|
||||||
.route("/atproto-did", get(identity::well_known_atproto_did))
|
.route("/atproto-did", get(identity::well_known_atproto_did));
|
||||||
|
|
||||||
|
if tranquil_config::get().server.enable_caddy_on_demand_tls {
|
||||||
|
routes.route("/caddy/ask", get(server::caddy_ask))
|
||||||
|
} else {
|
||||||
|
routes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn webhook_routes() -> axum::Router<AppState> {
|
pub fn webhook_routes() -> axum::Router<AppState> {
|
||||||
@@ -489,9 +495,15 @@ pub fn webhook_routes() -> axum::Router<AppState> {
|
|||||||
pub fn misc_routes() -> axum::Router<AppState> {
|
pub fn misc_routes() -> axum::Router<AppState> {
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
|
||||||
axum::Router::new()
|
let router = axum::Router::new()
|
||||||
.route("/health", get(server::health))
|
.route("/health", get(server::health))
|
||||||
.route("/robots.txt", get(server::robots_txt))
|
.route("/robots.txt", get(server::robots_txt))
|
||||||
.route("/favicon.ico", get(server::get_logo))
|
.route("/favicon.ico", get(server::get_logo))
|
||||||
.route("/u/{handle}/did.json", get(identity::user_did_doc))
|
.route("/u/{handle}/did.json", get(identity::user_did_doc));
|
||||||
|
|
||||||
|
if tranquil_config::get().server.rfc_moo_compliance {
|
||||||
|
router.route("/cow.txt", get(server::cow_txt))
|
||||||
|
} else {
|
||||||
|
router
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use tranquil_pds::api::ApiError;
|
|||||||
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
|
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
|
||||||
use tranquil_pds::auth::{AnyUser, Auth};
|
use tranquil_pds::auth::{AnyUser, Auth};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::{Did, Nsid};
|
use tranquil_pds::types::{Did, DidRef, Nsid};
|
||||||
|
|
||||||
static CREATE_REPORT_NSID: LazyLock<Nsid> =
|
static CREATE_REPORT_NSID: LazyLock<Nsid> =
|
||||||
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
|
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
|
||||||
@@ -151,8 +151,9 @@ async fn proxy_to_report_service(
|
|||||||
|
|
||||||
let service_token = match tranquil_pds::auth::create_service_token(
|
let service_token = match tranquil_pds::auth::create_service_token(
|
||||||
&auth_user.did,
|
&auth_user.did,
|
||||||
service_did,
|
&DidRef::from(service_did),
|
||||||
Some(&CREATE_REPORT_NSID),
|
Some(&CREATE_REPORT_NSID),
|
||||||
|
None,
|
||||||
&key_bytes,
|
&key_bytes,
|
||||||
) {
|
) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub struct NotificationPrefsOutput {
|
|||||||
pub telegram_verified: bool,
|
pub telegram_verified: bool,
|
||||||
pub signal_username: Option<String>,
|
pub signal_username: Option<String>,
|
||||||
pub signal_verified: bool,
|
pub signal_verified: bool,
|
||||||
|
pub legacy_login_alerts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_notification_prefs(
|
pub async fn get_notification_prefs(
|
||||||
@@ -32,6 +33,26 @@ pub async fn get_notification_prefs(
|
|||||||
.await
|
.await
|
||||||
.log_db_err("get notification prefs")?
|
.log_db_err("get notification prefs")?
|
||||||
.ok_or(ApiError::AccountNotFound)?;
|
.ok_or(ApiError::AccountNotFound)?;
|
||||||
|
|
||||||
|
let user_id = state
|
||||||
|
.repos
|
||||||
|
.user
|
||||||
|
.get_id_by_did(&auth.did)
|
||||||
|
.await
|
||||||
|
.log_db_err("get user by did")?
|
||||||
|
.ok_or(ApiError::AccountNotFound)?;
|
||||||
|
|
||||||
|
let legacy_login_alerts = state
|
||||||
|
.repos
|
||||||
|
.infra
|
||||||
|
.get_account_preferences(user_id)
|
||||||
|
.await
|
||||||
|
.log_db_err("get legacy login alert prefs")?
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == "legacy_login_alerts")
|
||||||
|
.and_then(|(_, value)| value.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
Ok(Json(NotificationPrefsOutput {
|
Ok(Json(NotificationPrefsOutput {
|
||||||
preferred_channel: prefs.preferred_channel,
|
preferred_channel: prefs.preferred_channel,
|
||||||
email: prefs.email,
|
email: prefs.email,
|
||||||
@@ -41,6 +62,7 @@ pub async fn get_notification_prefs(
|
|||||||
telegram_verified: prefs.telegram_verified,
|
telegram_verified: prefs.telegram_verified,
|
||||||
signal_username: prefs.signal_username,
|
signal_username: prefs.signal_username,
|
||||||
signal_verified: prefs.signal_verified,
|
signal_verified: prefs.signal_verified,
|
||||||
|
legacy_login_alerts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +143,7 @@ pub struct UpdateNotificationPrefsInput {
|
|||||||
pub discord_username: Option<String>,
|
pub discord_username: Option<String>,
|
||||||
pub telegram_username: Option<String>,
|
pub telegram_username: Option<String>,
|
||||||
pub signal_username: Option<String>,
|
pub signal_username: Option<String>,
|
||||||
|
pub legacy_login_alerts: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -136,12 +159,16 @@ pub async fn request_channel_verification(
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
identifier: &str,
|
id: &str,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<String, ApiError> {
|
) -> Result<String, ApiError> {
|
||||||
let token = tranquil_pds::auth::verification_token::generate_channel_update_token(
|
if channel.verifies_via_bot() {
|
||||||
did, channel, identifier,
|
return Err(ApiError::InvalidRequest(
|
||||||
);
|
"This channel verifies through a bot. Please message the bot first so that it's able to reply with useful info".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let token =
|
||||||
|
tranquil_pds::auth::verification_token::generate_channel_update_token(did, channel, id);
|
||||||
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
||||||
|
|
||||||
match channel {
|
match channel {
|
||||||
@@ -150,10 +177,11 @@ pub async fn request_channel_verification(
|
|||||||
let handle = handle.ok_or_else(|| {
|
let handle = handle.ok_or_else(|| {
|
||||||
ApiError::InternalError(Some("Email verification requires a handle".into()))
|
ApiError::InternalError(Some("Email verification requires a handle".into()))
|
||||||
})?;
|
})?;
|
||||||
|
let new_email = tranquil_types::EmailAddress::new(id)?;
|
||||||
tranquil_pds::comms::comms_repo::enqueue_email_update(
|
tranquil_pds::comms::comms_repo::enqueue_email_update(
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
identifier,
|
&new_email,
|
||||||
handle,
|
handle,
|
||||||
&formatted_token,
|
&formatted_token,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -164,10 +192,10 @@ pub async fn request_channel_verification(
|
|||||||
_ => {
|
_ => {
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
let encoded_token = urlencoding::encode(&formatted_token);
|
let encoded_token = urlencoding::encode(&formatted_token);
|
||||||
let encoded_identifier = urlencoding::encode(identifier);
|
let encoded_id = urlencoding::encode(id);
|
||||||
let verify_link = format!(
|
let verify_link = format!(
|
||||||
"https://{}/app/verify?token={}&identifier={}",
|
"https://{}/app/verify?token={}&identifier={}",
|
||||||
hostname, encoded_token, encoded_identifier
|
hostname, encoded_token, encoded_id
|
||||||
);
|
);
|
||||||
let prefs = state
|
let prefs = state
|
||||||
.repos
|
.repos
|
||||||
@@ -189,26 +217,14 @@ pub async fn request_channel_verification(
|
|||||||
strings.channel_verification_subject,
|
strings.channel_verification_subject,
|
||||||
&[("hostname", hostname)],
|
&[("hostname", hostname)],
|
||||||
);
|
);
|
||||||
let recipient = match channel {
|
let recipient = tranquil_db_traits::Recipient::new(channel, id)?;
|
||||||
CommsChannel::Telegram => state
|
|
||||||
.repos
|
|
||||||
.user
|
|
||||||
.get_telegram_chat_id(user_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|id| id.to_string())
|
|
||||||
.unwrap_or_else(|| identifier.to_string()),
|
|
||||||
_ => identifier.to_string(),
|
|
||||||
};
|
|
||||||
state
|
state
|
||||||
.repos
|
.repos
|
||||||
.infra
|
.infra
|
||||||
.enqueue_comms(
|
.enqueue_comms(
|
||||||
Some(user_id),
|
Some(user_id),
|
||||||
channel,
|
|
||||||
tranquil_db_traits::CommsType::ChannelVerification,
|
|
||||||
&recipient,
|
&recipient,
|
||||||
|
tranquil_db_traits::CommsType::ChannelVerification,
|
||||||
Some(&subject),
|
Some(&subject),
|
||||||
&body,
|
&body,
|
||||||
Some(json!({"code": formatted_token})),
|
Some(json!({"code": formatted_token})),
|
||||||
@@ -230,14 +246,7 @@ async fn process_messaging_channel_update(
|
|||||||
effective_channel: CommsChannel,
|
effective_channel: CommsChannel,
|
||||||
verification_required: &mut Vec<CommsChannel>,
|
verification_required: &mut Vec<CommsChannel>,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let clean = match channel {
|
if raw_value.trim().is_empty() {
|
||||||
CommsChannel::Discord => raw_value.trim().to_lowercase(),
|
|
||||||
CommsChannel::Telegram => raw_value.trim_start_matches('@').to_string(),
|
|
||||||
CommsChannel::Signal => raw_value.trim().trim_start_matches('@').to_lowercase(),
|
|
||||||
CommsChannel::Email => raw_value.trim().to_lowercase(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if clean.is_empty() {
|
|
||||||
if effective_channel == channel {
|
if effective_channel == channel {
|
||||||
return Err(ApiError::InvalidRequest(format!(
|
return Err(ApiError::InvalidRequest(format!(
|
||||||
"Cannot remove {:?} while it is the preferred notification channel",
|
"Cannot remove {:?} while it is the preferred notification channel",
|
||||||
@@ -269,26 +278,12 @@ async fn process_messaging_channel_update(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid = match channel {
|
let clean = match channel {
|
||||||
CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean),
|
CommsChannel::Discord => tranquil_types::DiscordUsername::new(raw_value)?.to_string(),
|
||||||
CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean),
|
CommsChannel::Telegram => tranquil_types::TelegramUsername::new(raw_value)?.to_string(),
|
||||||
CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean),
|
CommsChannel::Signal => tranquil_types::SignalUsername::new(raw_value)?.to_string(),
|
||||||
CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean),
|
CommsChannel::Email => tranquil_types::EmailAddress::new(raw_value)?.to_string(),
|
||||||
};
|
};
|
||||||
if !valid {
|
|
||||||
return Err(match channel {
|
|
||||||
CommsChannel::Discord => ApiError::InvalidRequest(
|
|
||||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
|
||||||
),
|
|
||||||
CommsChannel::Telegram => ApiError::InvalidRequest(
|
|
||||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
|
||||||
),
|
|
||||||
CommsChannel::Signal => ApiError::InvalidRequest(
|
|
||||||
"Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(),
|
|
||||||
),
|
|
||||||
CommsChannel::Email => ApiError::InvalidEmail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match channel {
|
match channel {
|
||||||
CommsChannel::Discord => state
|
CommsChannel::Discord => state
|
||||||
@@ -371,23 +366,25 @@ pub async fn update_notification_prefs(
|
|||||||
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
|
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref new_email) = input.email {
|
if let Some(new_email) = &input.email {
|
||||||
let email_clean = new_email.trim().to_lowercase();
|
let email = tranquil_types::EmailAddress::new(new_email).map_err(|_| {
|
||||||
if email_clean.is_empty() {
|
if new_email.trim().is_empty() {
|
||||||
return Err(ApiError::InvalidRequest("Email cannot be empty".into()));
|
ApiError::InvalidRequest("Email can't be empty".into())
|
||||||
}
|
} else {
|
||||||
|
ApiError::InvalidEmail
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
if !tranquil_pds::api::validation::is_valid_email(&email_clean) {
|
if !current_email
|
||||||
return Err(ApiError::InvalidEmail);
|
.as_deref()
|
||||||
}
|
.is_some_and(|e| e.eq_ignore_ascii_case(email.as_str()))
|
||||||
|
{
|
||||||
if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) {
|
|
||||||
request_channel_verification(
|
request_channel_verification(
|
||||||
&state,
|
&state,
|
||||||
user_id,
|
user_id,
|
||||||
&auth.did,
|
&auth.did,
|
||||||
CommsChannel::Email,
|
CommsChannel::Email,
|
||||||
&email_clean,
|
email.as_str(),
|
||||||
Some(&handle),
|
Some(&handle),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -435,6 +432,15 @@ pub async fn update_notification_prefs(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(alerts) = input.legacy_login_alerts {
|
||||||
|
state
|
||||||
|
.repos
|
||||||
|
.infra
|
||||||
|
.upsert_account_preference(user_id, "legacy_login_alerts", json!(alerts))
|
||||||
|
.await
|
||||||
|
.log_db_err("update legacy login alert prefs")?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(UpdateNotificationPrefsOutput {
|
Ok(Json(UpdateNotificationPrefsOutput {
|
||||||
success: true,
|
success: true,
|
||||||
verification_required,
|
verification_required,
|
||||||
|
|||||||
@@ -148,7 +148,13 @@ pub async fn upload_blob(
|
|||||||
size, cid_str
|
size, cid_str
|
||||||
);
|
);
|
||||||
|
|
||||||
match state
|
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
|
||||||
|
let _ = state.blob_store.delete(&temp_key).await;
|
||||||
|
error!("Failed to copy blob to final location: {:?}", e);
|
||||||
|
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = state
|
||||||
.repos
|
.repos
|
||||||
.blob
|
.blob
|
||||||
.insert_blob(
|
.insert_blob(
|
||||||
@@ -160,24 +166,9 @@ pub async fn upload_blob(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = state.blob_store.delete(&temp_key).await;
|
|
||||||
error!("Failed to insert blob record: {:?}", e);
|
|
||||||
return Err(ApiError::InternalError(None));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
|
|
||||||
let _ = state.blob_store.delete(&temp_key).await;
|
let _ = state.blob_store.delete(&temp_key).await;
|
||||||
if let Err(db_err) = state.repos.blob.delete_blob_by_cid(&cid_link).await {
|
error!("Failed to insert blob record: {:?}", e);
|
||||||
error!(
|
return Err(ApiError::InternalError(None));
|
||||||
"Failed to clean up orphaned blob record after copy failure: {:?}",
|
|
||||||
db_err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
error!("Failed to copy blob to final location: {:?}", e);
|
|
||||||
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state.blob_store.delete(&temp_key).await;
|
let _ = state.blob_store.delete(&temp_key).await;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use serde_json::Value;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use tranquil_pds::api::EmptyResponse;
|
use tranquil_pds::api::EmptyResponse;
|
||||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||||
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
|
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
|
||||||
@@ -212,9 +212,10 @@ async fn assert_valid_did_document_for_service(
|
|||||||
if let Some(ref expected_rotation_key) = server_rotation_key
|
if let Some(ref expected_rotation_key) = server_rotation_key
|
||||||
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
|
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidRequest(
|
debug!(
|
||||||
"Server rotation key not included in PLC DID data".into(),
|
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
|
||||||
));
|
did, doc_rotation_keys, expected_rotation_key
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let doc_signing_key = doc_data
|
let doc_signing_key = doc_data
|
||||||
@@ -251,13 +252,10 @@ async fn assert_valid_did_document_for_service(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
|
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
|
||||||
warn!(
|
debug!(
|
||||||
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
|
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
|
||||||
did, doc_rotation_keys, expected_did_key
|
did, doc_rotation_keys, expected_did_key
|
||||||
);
|
);
|
||||||
return Err(ApiError::InvalidRequest(
|
|
||||||
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
|
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
|
||||||
@@ -588,16 +586,27 @@ pub async fn request_account_delete(
|
|||||||
.await
|
.await
|
||||||
.log_db_err("creating deletion token")?;
|
.log_db_err("creating deletion token")?;
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion(
|
match tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
&confirmation_token,
|
tranquil_pds::comms::Notice::AccountDeletion {
|
||||||
|
code: &confirmation_token,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(ApiError::InvalidRequest(
|
||||||
|
"We couldn't deliver the deletion code to your notification channels. Please contact the PDS owner."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info!("Account deletion requested for user {}", session_mfa.did());
|
info!("Account deletion requested for user {}", session_mfa.did());
|
||||||
Ok(Json(EmptyResponse {}))
|
Ok(Json(EmptyResponse {}))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde::de::Error as _;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use tracing::error;
|
||||||
|
use tranquil_pds::handle::ServiceDomains;
|
||||||
|
use tranquil_pds::state::AppState;
|
||||||
|
use tranquil_pds::types::Handle;
|
||||||
|
|
||||||
|
pub struct AskedDomain(Handle);
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for AskedDomain {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let raw = String::deserialize(deserializer)?;
|
||||||
|
let without_root_dot = raw.strip_suffix('.').unwrap_or(&raw);
|
||||||
|
Handle::new(without_root_dot)
|
||||||
|
.map(Self)
|
||||||
|
.map_err(D::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CaddyAskQuery {
|
||||||
|
pub domain: AskedDomain,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn caddy_ask(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(ask): Query<CaddyAskQuery>,
|
||||||
|
) -> StatusCode {
|
||||||
|
let AskedDomain(handle) = ask.domain;
|
||||||
|
if ServiceDomains::served().contains(handle.as_str()) {
|
||||||
|
return StatusCode::OK;
|
||||||
|
}
|
||||||
|
match state.repos.user.get_by_handle(&handle).await {
|
||||||
|
Ok(Some(_)) => StatusCode::OK,
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND,
|
||||||
|
Err(e) => {
|
||||||
|
error!("caddy ask couldn't look up handle {handle}: {e:?}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
..........................
|
||||||
|
....*o|||||||8#@@@@@@@@@@@@@@@@@@@@@@@###&|o:_..
|
||||||
|
..*:o|||&8##@###8888888######@#@###########################|*...
|
||||||
|
.:o|||8#####8888|:::**. *&########################@@################&o_
|
||||||
|
.*o&8###@#8&o*_. :###@##############@########################@@##&o_
|
||||||
|
.*o8########& :##@#@##############@############################@###|_
|
||||||
|
.*o|8##########8o .#######################################################&o_
|
||||||
|
*&##|_ ..*&##8&o*|88888|_ _#######################################@##################|.
|
||||||
|
*#####& *&######&o_..*o|o:_ .&##o _###########################################################&_
|
||||||
|
_##8*##8 .|88|:::|#######8###8|*:_ .&#@@8 _##@@@########################################################&_
|
||||||
|
_#@8_##8_ *8#8|*_ _:|#####&&####8 .&##############################################################|
|
||||||
|
_#@8.|##8_ _::o###8&##8 .|##@############################8###########################@@#|_
|
||||||
|
*###o.|88o ..*&####|..##& _|##########################8|_ .|#############################8
|
||||||
|
*|###|_ ._&####8|*_ _*_ _::&8888888888888888|::*_ .|##@####@@@##################|
|
||||||
|
*&###|_ _:_ .&88###8|*_ ..... .|#####@@@##################8
|
||||||
|
.##@#& _##& .|##o _#@@#@#| .|#######&:_ _|###@####################8
|
||||||
|
.:8##8o _o:*&##| *##8_.&@@##@#| _::o8#8|::|#####|_ _|#################88###8
|
||||||
|
.&##&*_ *###o_###| .|##8*&##|*###o _###8####8|_ _:|###|_ .*o|||o:_ _:::&8888888888|_ _##8
|
||||||
|
.###|_. _###o *###|*&#######8 *##8 .##8_ _:|###|_ _|###|_ .&########o _##&
|
||||||
|
_|####8|&##8:_ _|#########88o .##8 *##& _|###o .|###o .#########| .o##o
|
||||||
|
o#8|*:#@@###o _:::*__*_ _##8_ _##8_ _&##|_ *##8_ *8#####8|_ .*oo:_ o##|
|
||||||
|
*###o.&#####& _oo* .8##& .8##8_ .|##& o##& _::::_.*o|8######|_ .##8.
|
||||||
|
_###&o&##8_:*_ .###& .###|_&#####| _##8 :###o *ooo&#########@#@#& ....:##&
|
||||||
|
.|8||###&. _**_ .###88##|*&###|*._&##& *|##8_ *o&####@@####@@######& .*o||||||&#######8_
|
||||||
|
*&###o _|88##8_ _:8######|*:###|_ _##################88|_ *&#################&
|
||||||
|
*#####o *&8o *##& _:::*_.&##|_ _#@##############8_ :##################8*
|
||||||
|
.###&##8_.|88o *&8o _@@& .###| .&####@#########|_ .####@###@@########8*
|
||||||
|
_##&.|###|_.... .|88o _##8* *&###|_ *###o _:&88######8|_ .*o|||##################o
|
||||||
|
_##8_ _|########|_ .*o8####&#@@#@##o *#@8 _*:*. .&###@###################|
|
||||||
|
.|##& _:::::&##& .*&##############@#8_ .###o _#@####################|_
|
||||||
|
.&##|_ .&##8_ *o&####################8_ *##& .&#####@@############8*
|
||||||
|
.|##8_.&###&####8_ _########################8**##& _##################|_
|
||||||
|
.&###&##888888##8_ .|88######@###########|*######o _|8###############|_
|
||||||
|
.&#####o *###|_ _::::::*o##8**o##8 .|###8o .&##@#############&*
|
||||||
|
.|####o _|###|_ _##8.*&##& _*_ .#################|
|
||||||
|
_*_ _|###|_.. .|#####8|_ *&#@#########8###&
|
||||||
|
*#######&|o:_... ..*:::*. ......._:o&8####888&o:#####8_
|
||||||
|
_###|&888#####@#####&|||o:_........................._:o||||8##@@@@####8|:*_ _:::*_
|
||||||
|
.|#@###o _:::o#@#888######@@@@@@@@@@@@@@@@@@@@@@@@#####888|::::::**_
|
||||||
|
_::*_ :##& *&8|_:::::::::::::::::::::::::**_
|
||||||
|
.###8||&##8o
|
||||||
|
_|888888|_
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ pub async fn request_email_update(
|
|||||||
|
|
||||||
let Some(_current_email) = user.email else {
|
let Some(_current_email) = user.email else {
|
||||||
return Err(ApiError::InvalidRequest(
|
return Err(ApiError::InvalidRequest(
|
||||||
"account does not have an email address".into(),
|
"Account doesn't have an email address".into(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,36 +89,43 @@ pub async fn request_email_update(
|
|||||||
ApiError::InternalError(Some("Failed to generate verification code".into()))
|
ApiError::InternalError(Some("Failed to generate verification code".into()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(Json(ref inp)) = input
|
if let Some(Json(inp)) = &input
|
||||||
&& let Some(ref new_email) = inp.new_email
|
&& let Some(new_email) = inp.new_email.as_deref()
|
||||||
|
&& let Ok(address) = tranquil_types::EmailAddress::new(new_email)
|
||||||
{
|
{
|
||||||
let new_email = new_email.trim().to_lowercase();
|
let pending = PendingEmailUpdate {
|
||||||
if !new_email.is_empty() && tranquil_pds::api::validation::is_valid_email(&new_email) {
|
new_email: address.as_str().to_string(),
|
||||||
let pending = PendingEmailUpdate {
|
token_hash: hash_token(&token),
|
||||||
new_email,
|
authorized: false,
|
||||||
token_hash: hash_token(&token),
|
};
|
||||||
authorized: false,
|
let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did);
|
||||||
};
|
if let Ok(json) = serde_json::to_string(&pending)
|
||||||
if let Ok(json) = serde_json::to_string(&pending) {
|
&& let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await
|
||||||
let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did);
|
{
|
||||||
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
|
warn!("Failed to cache pending email update: {:?}", e);
|
||||||
warn!("Failed to cache pending email update: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_short_token_email(
|
match tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user.id,
|
user.id,
|
||||||
&token,
|
tranquil_pds::comms::Notice::ShortTokenEmail { token: &token },
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to enqueue email update notification: {:?}", e);
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(ApiError::InvalidRequest(
|
||||||
|
"We couldn't deliver the confirmation code to your notification channels. Please contact the PDS owner."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to enqueue email update notification: {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,13 +157,11 @@ pub async fn confirm_email(
|
|||||||
.log_db_err("getting email info")?
|
.log_db_err("getting email info")?
|
||||||
.ok_or(ApiError::AccountNotFound)?;
|
.ok_or(ApiError::AccountNotFound)?;
|
||||||
|
|
||||||
let Some(ref email) = user.email else {
|
let Some(email) = &user.email else {
|
||||||
return Err(ApiError::InvalidEmail);
|
return Err(ApiError::InvalidEmail);
|
||||||
};
|
};
|
||||||
let current_email = email.to_lowercase();
|
let provided_email = tranquil_types::EmailAddress::new(input.email.trim())?;
|
||||||
|
if provided_email.as_str() != email.to_lowercase() {
|
||||||
let provided_email = input.email.trim().to_lowercase();
|
|
||||||
if provided_email != current_email {
|
|
||||||
return Err(ApiError::InvalidEmail);
|
return Err(ApiError::InvalidEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@ pub async fn confirm_email(
|
|||||||
let verified = tranquil_pds::auth::verification_token::verify_signup_token(
|
let verified = tranquil_pds::auth::verification_token::verify_signup_token(
|
||||||
&confirmation_code,
|
&confirmation_code,
|
||||||
CommsChannel::Email,
|
CommsChannel::Email,
|
||||||
&provided_email,
|
provided_email.as_str(),
|
||||||
);
|
);
|
||||||
|
|
||||||
match verified {
|
match verified {
|
||||||
@@ -226,17 +231,14 @@ pub async fn update_email(
|
|||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let current_email = user.email.clone();
|
let current_email = user.email.clone();
|
||||||
let email_verified = user.email_verified;
|
let email_verified = user.email_verified;
|
||||||
let new_email = input.email.trim().to_lowercase();
|
let new_email = tranquil_types::EmailAddress::new(input.email.trim()).map_err(|_| {
|
||||||
|
ApiError::InvalidRequest(
|
||||||
if !tranquil_pds::api::validation::is_valid_email(&new_email) {
|
|
||||||
return Err(ApiError::InvalidRequest(
|
|
||||||
"This email address is not supported, please use a different email.".into(),
|
"This email address is not supported, please use a different email.".into(),
|
||||||
));
|
)
|
||||||
}
|
})?;
|
||||||
|
|
||||||
let email_unchanged = current_email
|
let email_unchanged = current_email
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| new_email == c.to_lowercase())
|
.map(|c| new_email.as_str() == c.to_lowercase())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if email_unchanged {
|
if email_unchanged {
|
||||||
@@ -283,7 +285,7 @@ pub async fn update_email(
|
|||||||
if let Some(pending_json) = state.cache.get(&cache_key).await
|
if let Some(pending_json) = state.cache.get(&cache_key).await
|
||||||
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
|
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
|
||||||
&& pending.authorized
|
&& pending.authorized
|
||||||
&& pending.new_email == new_email
|
&& pending.new_email == new_email.as_str()
|
||||||
{
|
{
|
||||||
authorized_via_link = true;
|
authorized_via_link = true;
|
||||||
let _ = state.cache.delete(&cache_key).await;
|
let _ = state.cache.delete(&cache_key).await;
|
||||||
@@ -350,24 +352,26 @@ pub async fn update_email(
|
|||||||
state
|
state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
.update_email(user_id, &new_email)
|
.update_email(user_id, new_email.as_str())
|
||||||
.await
|
.await
|
||||||
.log_db_err("updating email")?;
|
.log_db_err("updating email")?;
|
||||||
|
|
||||||
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
|
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
|
||||||
did,
|
did,
|
||||||
CommsChannel::Email,
|
CommsChannel::Email,
|
||||||
&new_email,
|
new_email.as_str(),
|
||||||
);
|
);
|
||||||
let formatted_token =
|
let formatted_token =
|
||||||
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
|
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
|
let target = tranquil_pds::comms::VerificationTarget::direct(
|
||||||
|
tranquil_db_traits::Recipient::Email(new_email.clone()),
|
||||||
|
);
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
tranquil_db_traits::CommsChannel::Email,
|
&target,
|
||||||
&new_email,
|
|
||||||
&formatted_token,
|
&formatted_token,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
@@ -565,15 +569,22 @@ pub async fn check_email_in_use(
|
|||||||
_rate_limit: RateLimited<VerificationCheckLimit>,
|
_rate_limit: RateLimited<VerificationCheckLimit>,
|
||||||
Json(input): Json<CheckEmailInUseInput>,
|
Json(input): Json<CheckEmailInUseInput>,
|
||||||
) -> Result<Json<InUseOutput>, ApiError> {
|
) -> Result<Json<InUseOutput>, ApiError> {
|
||||||
let email = input.email.trim().to_lowercase();
|
let raw = input.email.trim();
|
||||||
if email.is_empty() {
|
let email = tranquil_types::EmailAddress::new(raw).map_err(|_| {
|
||||||
return Err(ApiError::InvalidRequest("email is required".into()));
|
ApiError::InvalidRequest(
|
||||||
}
|
if raw.is_empty() {
|
||||||
|
"Email is required"
|
||||||
|
} else {
|
||||||
|
"Invalid email address"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let count = state
|
let count = state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
.count_accounts_by_email(&email)
|
.count_accounts_by_email(email.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("DB error checking email usage: {:?}", e);
|
error!("DB error checking email usage: {:?}", e);
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ pub async fn robots_txt() -> impl IntoResponse {
|
|||||||
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
|
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn cow_txt() -> &'static str {
|
||||||
|
include_str!("cow.txt")
|
||||||
|
}
|
||||||
pub fn is_self_hosted_did_web_enabled() -> bool {
|
pub fn is_self_hosted_did_web_enabled() -> bool {
|
||||||
tranquil_config::get().server.enable_pds_hosted_did_web
|
tranquil_config::get().server.enable_pds_hosted_did_web
|
||||||
}
|
}
|
||||||
@@ -73,7 +77,12 @@ pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServ
|
|||||||
let pds_hostname = &cfg.server.hostname;
|
let pds_hostname = &cfg.server.hostname;
|
||||||
|
|
||||||
Json(DescribeServerOutput {
|
Json(DescribeServerOutput {
|
||||||
available_user_domains: cfg.server.user_handle_domain_list(),
|
available_user_domains: match cfg.server.user_handle_domains.as_deref() {
|
||||||
|
Some(domains) if !domains.is_empty() => {
|
||||||
|
domains.iter().map(|d| d.as_str().to_owned()).collect()
|
||||||
|
}
|
||||||
|
_ => vec![cfg.server.hostname_without_port().to_owned()],
|
||||||
|
},
|
||||||
invite_code_required: cfg.server.invite_code_required,
|
invite_code_required: cfg.server.invite_code_required,
|
||||||
did: format!("did:web:{}", pds_hostname),
|
did: format!("did:web:{}", pds_hostname),
|
||||||
links: DescribeServerLinks {
|
links: DescribeServerLinks {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod account_status;
|
pub mod account_status;
|
||||||
pub mod app_password;
|
pub mod app_password;
|
||||||
|
pub mod caddy;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
@@ -22,13 +23,14 @@ pub use account_status::{
|
|||||||
request_account_delete,
|
request_account_delete,
|
||||||
};
|
};
|
||||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||||
|
pub use caddy::caddy_ask;
|
||||||
pub use email::{
|
pub use email::{
|
||||||
authorize_email_update, check_channel_verified, check_email_in_use, check_email_update_status,
|
authorize_email_update, check_channel_verified, check_email_in_use, check_email_update_status,
|
||||||
check_email_verified, confirm_email, request_email_update, update_email,
|
check_email_verified, confirm_email, request_email_update, update_email,
|
||||||
};
|
};
|
||||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||||
pub use logo::get_logo;
|
pub use logo::get_logo;
|
||||||
pub use meta::{describe_server, health, robots_txt};
|
pub use meta::{cow_txt, describe_server, health, robots_txt};
|
||||||
pub use migration::{get_did_document, update_did_document};
|
pub use migration::{get_did_document, update_did_document};
|
||||||
pub use passkey_account::{
|
pub use passkey_account::{
|
||||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ pub async fn create_passkey_account(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|e| e.trim().to_string())
|
.map(|e| e.trim().to_string())
|
||||||
.filter(|e| !e.is_empty());
|
.filter(|e| !e.is_empty());
|
||||||
if let Some(ref email) = email
|
if let Some(email) = &email
|
||||||
&& !tranquil_pds::api::validation::is_valid_email(email)
|
&& tranquil_types::EmailAddress::new(email).is_err()
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidEmail);
|
return Err(ApiError::InvalidEmail);
|
||||||
}
|
}
|
||||||
@@ -703,11 +703,11 @@ pub async fn request_passkey_recovery(
|
|||||||
urlencoding::encode(&recovery_token)
|
urlencoding::encode(&recovery_token)
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery(
|
let _ = tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user.id,
|
user.id,
|
||||||
&recovery_url,
|
tranquil_pds::comms::Notice::PasskeyRecovery { url: &recovery_url },
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -776,21 +776,14 @@ pub async fn recover_passkey_account(
|
|||||||
if result.passkeys_deleted > 0 {
|
if result.passkeys_deleted > 0 {
|
||||||
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
|
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
|
||||||
}
|
}
|
||||||
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await {
|
crate::common::implicitly_verify_channel(
|
||||||
let actual_channel =
|
state.repos.user.as_ref(),
|
||||||
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
&input.did,
|
||||||
if let Err(e) = state
|
user.id,
|
||||||
.repos
|
user.preferred_comms_channel,
|
||||||
.user
|
"passkey recovery",
|
||||||
.set_channel_verified(&input.did, actual_channel)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Failed to implicitly verify channel on passkey recovery: {:?}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!(did = %input.did, "Passkey-only account recovered with temporary password");
|
info!(did = %input.did, "Passkey-only account recovered with temporary password");
|
||||||
Ok(Json(SuccessResponse { success: true }))
|
Ok(Json(SuccessResponse { success: true }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,11 +90,13 @@ pub async fn request_password_reset(
|
|||||||
return Err(ApiError::InternalError(None));
|
return Err(ApiError::InternalError(None));
|
||||||
}
|
}
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
&display_code,
|
tranquil_pds::comms::Notice::PasswordReset {
|
||||||
|
code: &display_code,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -193,21 +195,14 @@ pub async fn reset_password(
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user_id).await {
|
crate::common::implicitly_verify_channel(
|
||||||
let actual_channel =
|
state.repos.user.as_ref(),
|
||||||
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
&user.did,
|
||||||
if let Err(e) = state
|
user_id,
|
||||||
.repos
|
user.preferred_comms_channel,
|
||||||
.user
|
"password reset",
|
||||||
.set_channel_verified(&user.did, actual_channel)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
"Failed to implicitly verify channel on password reset: {:?}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Password reset completed for user {}", user_id);
|
info!("Password reset completed for user {}", user_id);
|
||||||
Ok(Json(EmptyResponse {}))
|
Ok(Json(EmptyResponse {}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
|
|||||||
use tranquil_pds::api::error::ApiError;
|
use tranquil_pds::api::error::ApiError;
|
||||||
use tranquil_pds::auth::extractor::{Auth, Permissive};
|
use tranquil_pds::auth::extractor::{Auth, Permissive};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
use tranquil_pds::types::Did;
|
use tranquil_pds::types::DidRef;
|
||||||
use tranquil_types::Nsid;
|
use tranquil_types::Nsid;
|
||||||
|
|
||||||
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
|
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
|
||||||
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct GetServiceAuthParams {
|
pub struct GetServiceAuthParams {
|
||||||
pub aud: Did,
|
pub aud: DidRef,
|
||||||
pub lxm: Option<Nsid>,
|
pub lxm: Option<Nsid>,
|
||||||
pub exp: Option<i64>,
|
pub exp: Option<i64>,
|
||||||
}
|
}
|
||||||
@@ -169,14 +169,19 @@ pub async fn get_service_auth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let service_token =
|
let service_token = match tranquil_pds::auth::create_service_token(
|
||||||
match tranquil_pds::auth::create_service_token(&auth.did, ¶ms.aud, lxm, &key_bytes) {
|
&auth.did,
|
||||||
Ok(t) => t,
|
¶ms.aud,
|
||||||
Err(e) => {
|
lxm,
|
||||||
error!("Failed to create service token: {:?}", e);
|
params.exp,
|
||||||
return ApiError::InternalError(None).into_response();
|
&key_bytes,
|
||||||
}
|
) {
|
||||||
};
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to create service token: {:?}", e);
|
||||||
|
return ApiError::InternalError(None).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(GetServiceAuthOutput {
|
Json(GetServiceAuthOutput {
|
||||||
|
|||||||
@@ -220,21 +220,34 @@ pub async fn create_session(
|
|||||||
}
|
}
|
||||||
Ok(tranquil_pds::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
|
Ok(tranquil_pds::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_2fa_code(
|
match tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
row.id,
|
row.id,
|
||||||
code.as_str(),
|
tranquil_pds::comms::Notice::TwoFactorCode {
|
||||||
|
code: code.as_str(),
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("Failed to send 2FA code: {:?}", e);
|
Ok(Some(_)) => {}
|
||||||
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did)
|
Ok(None) => {
|
||||||
.await;
|
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did)
|
||||||
return Err(ApiError::InternalError(Some(
|
.await;
|
||||||
"Failed to send verification code. Please try again.".into(),
|
return Err(ApiError::InvalidRequest(
|
||||||
)));
|
"We couldn't deliver the verification code to your notification channels. Please contact the PDS owner."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to send 2FA code: {:?}", e);
|
||||||
|
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did)
|
||||||
|
.await;
|
||||||
|
return Err(ApiError::InternalError(Some(
|
||||||
|
"Failed to send verification code. Please try again.".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Err(ApiError::AuthFactorTokenRequired);
|
return Err(ApiError::AuthFactorTokenRequired);
|
||||||
}
|
}
|
||||||
@@ -317,23 +330,39 @@ pub async fn create_session(
|
|||||||
return Err(ApiError::InternalError(None));
|
return Err(ApiError::InternalError(None));
|
||||||
}
|
}
|
||||||
if is_legacy_login && !used_totp_factor {
|
if is_legacy_login && !used_totp_factor {
|
||||||
warn!(
|
let alerts_enabled = match state.repos.infra.get_account_preferences(row.id).await {
|
||||||
did = %row.did,
|
Ok(prefs) => prefs
|
||||||
ip = %client_ip,
|
.iter()
|
||||||
"Legacy login on TOTP-enabled account - sending notification"
|
.find(|(name, _)| name == "legacy_login_alerts")
|
||||||
);
|
.and_then(|(_, value)| value.as_bool())
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
.unwrap_or(true),
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_legacy_login(
|
Err(e) => {
|
||||||
state.repos.user.as_ref(),
|
warn!("Failed to fetch legacy login alert preference: {:?}", e);
|
||||||
state.repos.infra.as_ref(),
|
true
|
||||||
row.id,
|
}
|
||||||
hostname,
|
};
|
||||||
client_ip,
|
|
||||||
row.preferred_comms_channel,
|
if alerts_enabled {
|
||||||
)
|
warn!(
|
||||||
.await
|
did = %row.did,
|
||||||
{
|
ip = %client_ip,
|
||||||
error!("Failed to queue legacy login notification: {:?}", e);
|
"Legacy login on TOTP-enabled account - sending notification"
|
||||||
|
);
|
||||||
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
|
state.repos.user.as_ref(),
|
||||||
|
state.repos.infra.as_ref(),
|
||||||
|
row.id,
|
||||||
|
tranquil_pds::comms::Notice::LegacyLoginAlert {
|
||||||
|
channel: row.preferred_comms_channel,
|
||||||
|
ip: client_ip,
|
||||||
|
},
|
||||||
|
hostname,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Failed to queue legacy login notification: {:?}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let handle = row.handle.clone();
|
let handle = row.handle.clone();
|
||||||
@@ -351,7 +380,7 @@ pub async fn create_session(
|
|||||||
refresh_jwt: refresh_meta.token,
|
refresh_jwt: refresh_meta.token,
|
||||||
handle,
|
handle,
|
||||||
did: row.did,
|
did: row.did,
|
||||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
did_doc: did_doc.ok(),
|
||||||
email: row.email,
|
email: row.email,
|
||||||
email_confirmed: Some(row.channel_verification.email),
|
email_confirmed: Some(row.channel_verification.email),
|
||||||
email_auth_factor: email_auth_factor_out,
|
email_auth_factor: email_auth_factor_out,
|
||||||
@@ -444,7 +473,7 @@ pub async fn get_session(
|
|||||||
status: account_state.status_for_session().map(String::from),
|
status: account_state.status_for_session().map(String::from),
|
||||||
migrated_to_pds,
|
migrated_to_pds,
|
||||||
migrated_at,
|
migrated_at,
|
||||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
did_doc: did_doc.ok(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
|
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
|
||||||
@@ -800,7 +829,7 @@ async fn build_refresh_session_output(
|
|||||||
preferred_locale: u.preferred_locale,
|
preferred_locale: u.preferred_locale,
|
||||||
is_admin: u.is_admin,
|
is_admin: u.is_admin,
|
||||||
active: account_state.is_active(),
|
active: account_state.is_active(),
|
||||||
did_doc: did_doc.ok().map(|f| (*f).clone()),
|
did_doc: did_doc.ok(),
|
||||||
status: account_state.status_for_session().map(String::from),
|
status: account_state.status_for_session().map(String::from),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -854,15 +883,13 @@ pub async fn confirm_signup(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let identifier = match row.channel {
|
let Some(id) = row.channel_identifier() else {
|
||||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
warn!(
|
||||||
tranquil_db_traits::CommsChannel::Discord => {
|
did = %input.did,
|
||||||
row.discord_username.clone().unwrap_or_default()
|
channel = ?row.channel,
|
||||||
}
|
"We can't confirm signup because the account doesn't have an identifier on file"
|
||||||
tranquil_db_traits::CommsChannel::Telegram => {
|
);
|
||||||
row.telegram_username.clone().unwrap_or_default()
|
return Err(ApiError::InvalidRequest("Invalid verification code".into()));
|
||||||
}
|
|
||||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let normalized_token =
|
let normalized_token =
|
||||||
@@ -870,7 +897,7 @@ pub async fn confirm_signup(
|
|||||||
match tranquil_pds::auth::verification_token::verify_signup_token(
|
match tranquil_pds::auth::verification_token::verify_signup_token(
|
||||||
&normalized_token,
|
&normalized_token,
|
||||||
row.channel,
|
row.channel,
|
||||||
&identifier,
|
id,
|
||||||
) {
|
) {
|
||||||
Ok(token_data) => {
|
Ok(token_data) => {
|
||||||
if token_data.did != input.did {
|
if token_data.did != input.did {
|
||||||
@@ -926,10 +953,11 @@ pub async fn confirm_signup(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_welcome(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
row.id,
|
row.id,
|
||||||
|
tranquil_pds::comms::Notice::Welcome,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -948,6 +976,35 @@ pub async fn confirm_signup(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resend_signup_verification(
|
||||||
|
state: &AppState,
|
||||||
|
row: &tranquil_db_traits::UserResendVerification,
|
||||||
|
did: &Did,
|
||||||
|
context: &'static str,
|
||||||
|
) -> bool {
|
||||||
|
let Some(id) = row.channel_identifier() else {
|
||||||
|
warn!(did = %did, channel = ?row.channel, "We skipped {context} because the account doesn't have a recipient on file");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match tranquil_pds::comms::VerificationTarget::resolve(row.channel, id, row.email.as_deref()) {
|
||||||
|
Ok(target) => {
|
||||||
|
crate::identity::provision::enqueue_signup_verification(
|
||||||
|
state,
|
||||||
|
row.id,
|
||||||
|
did,
|
||||||
|
row.channel,
|
||||||
|
&target,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!(did = %did, channel = ?row.channel, "We skipped {context} because the account doesn't have a valid recipient");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
|
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
pub struct AutoResendResult {
|
pub struct AutoResendResult {
|
||||||
@@ -976,37 +1033,15 @@ pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<Aut
|
|||||||
handle: row.handle.clone(),
|
handle: row.handle.clone(),
|
||||||
channel: row.channel,
|
channel: row.channel,
|
||||||
};
|
};
|
||||||
let is_bot_channel = matches!(
|
if row.channel.verifies_via_bot() || debounced {
|
||||||
row.channel,
|
|
||||||
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
|
|
||||||
);
|
|
||||||
if is_bot_channel || debounced {
|
|
||||||
return Some(result);
|
return Some(result);
|
||||||
}
|
}
|
||||||
let recipient = match row.channel {
|
if resend_signup_verification(state, &row, did, "auto-resend verification").await {
|
||||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
let _ = state
|
||||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
.cache
|
||||||
_ => return Some(result),
|
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
|
||||||
};
|
.await;
|
||||||
if recipient.is_empty() {
|
|
||||||
warn!(
|
|
||||||
"No recipient configured for auto-resend verification: {}",
|
|
||||||
did
|
|
||||||
);
|
|
||||||
return Some(result);
|
|
||||||
}
|
}
|
||||||
crate::identity::provision::enqueue_signup_verification(
|
|
||||||
state,
|
|
||||||
row.id,
|
|
||||||
did,
|
|
||||||
row.channel,
|
|
||||||
&recipient,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = state
|
|
||||||
.cache
|
|
||||||
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
|
|
||||||
.await;
|
|
||||||
Some(result)
|
Some(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,32 +1071,12 @@ pub async fn resend_verification(
|
|||||||
return Err(ApiError::InternalError(None));
|
return Err(ApiError::InternalError(None));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let is_verified = row.channel_verification.has_any_verified();
|
if row.channel_verification.has_any_verified() {
|
||||||
if is_verified {
|
|
||||||
return Err(ApiError::InvalidRequest(
|
return Err(ApiError::InvalidRequest(
|
||||||
"Account is already verified".into(),
|
"Account is already verified".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
resend_signup_verification(&state, &row, &input.did, "resend verification").await;
|
||||||
let recipient = match row.channel {
|
|
||||||
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
|
|
||||||
tranquil_db_traits::CommsChannel::Discord => {
|
|
||||||
row.discord_username.clone().unwrap_or_default()
|
|
||||||
}
|
|
||||||
tranquil_db_traits::CommsChannel::Telegram => {
|
|
||||||
row.telegram_username.clone().unwrap_or_default()
|
|
||||||
}
|
|
||||||
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
crate::identity::provision::enqueue_signup_verification(
|
|
||||||
&state,
|
|
||||||
row.id,
|
|
||||||
&input.did,
|
|
||||||
row.channel,
|
|
||||||
&recipient,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(Json(SuccessResponse { success: true }))
|
Ok(Json(SuccessResponse { success: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
|
|||||||
use tranquil_pds::auth::{
|
use tranquil_pds::auth::{
|
||||||
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||||
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
|
is_backup_code_format, verify_backup_code, verify_password_mfa, verify_totp_code,
|
||||||
verify_totp_code, verify_totp_mfa,
|
verify_totp_mfa,
|
||||||
};
|
};
|
||||||
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
@@ -163,11 +163,9 @@ pub async fn disable_totp(
|
|||||||
auth: Auth<Active>,
|
auth: Auth<Active>,
|
||||||
Json(input): Json<DisableTotpInput>,
|
Json(input): Json<DisableTotpInput>,
|
||||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||||
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
|
|
||||||
|
|
||||||
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
|
||||||
&state,
|
&state,
|
||||||
session_mfa.did(),
|
auth.did.as_str(),
|
||||||
"Too many verification attempts. Please try again in a few minutes.",
|
"Too many verification attempts. Please try again in a few minutes.",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -184,7 +182,7 @@ pub async fn disable_totp(
|
|||||||
|
|
||||||
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
|
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
|
||||||
|
|
||||||
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
|
||||||
|
|
||||||
Ok(Json(EmptyResponse {}))
|
Ok(Json(EmptyResponse {}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ pub async fn resend_migration_verification(
|
|||||||
let channel = input
|
let channel = input
|
||||||
.channel
|
.channel
|
||||||
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
||||||
let identifier = input.identifier.trim().to_lowercase();
|
let id = input.identifier.trim().to_lowercase();
|
||||||
|
|
||||||
let user = match state.repos.user.get_by_email(&identifier).await {
|
let user = match state.repos.user.get_by_email(&id).await {
|
||||||
Ok(Some(u)) => u,
|
Ok(Some(u)) => u,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||||
@@ -73,15 +73,18 @@ pub async fn resend_migration_verification(
|
|||||||
if user.email_verified {
|
if user.email_verified {
|
||||||
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
|
||||||
}
|
}
|
||||||
|
let target = tranquil_pds::comms::VerificationTarget::resolve(channel, &id, Some(&id)).ok();
|
||||||
crate::identity::provision::enqueue_migration_verification(
|
if let Some(target) = target {
|
||||||
&state,
|
crate::identity::provision::enqueue_migration_verification(
|
||||||
user.id,
|
&state, user.id, &user.did, channel, &target,
|
||||||
&user.did,
|
)
|
||||||
channel,
|
.await;
|
||||||
&identifier,
|
} else {
|
||||||
)
|
warn!(
|
||||||
.await;
|
channel = ?channel,
|
||||||
|
"We skipped migration verification because unfortunately the account doesn't have a valid recipient"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
info!(did = %user.did, channel = ?channel, "Resent migration verification");
|
info!(did = %user.did, channel = ?channel, "Resent migration verification");
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ pub async fn verify_token_internal(
|
|||||||
handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await
|
handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await
|
||||||
}
|
}
|
||||||
VerificationPurpose::Signup => {
|
VerificationPurpose::Signup => {
|
||||||
handle_signup_verification(state, &token_data.did, token_data.channel, &identifier)
|
handle_signup_verification(state, &token_data.did, token_data.channel).await
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +166,7 @@ async fn handle_channel_update(
|
|||||||
|
|
||||||
info!(did = %did, channel = ?channel, "Channel verified successfully");
|
info!(did = %did, channel = ?channel, "Channel verified successfully");
|
||||||
|
|
||||||
notify_channel_verified(state, user_id, channel, identifier).await;
|
notify_channel_verified(state, user_id, channel).await;
|
||||||
|
|
||||||
Ok(Json(VerifyTokenOutput {
|
Ok(Json(VerifyTokenOutput {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -177,43 +176,49 @@ async fn handle_channel_update(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn notify_channel_verified(
|
async fn notify_channel_verified(state: &AppState, user_id: uuid::Uuid, channel: CommsChannel) {
|
||||||
state: &AppState,
|
let prefs = match state.repos.user.get_comms_prefs(user_id).await {
|
||||||
user_id: uuid::Uuid,
|
Ok(Some(prefs)) => prefs,
|
||||||
channel: CommsChannel,
|
Ok(None) => {
|
||||||
identifier: &str,
|
warn!(
|
||||||
) {
|
user_id = %user_id,
|
||||||
let recipient = match channel {
|
channel = ?channel,
|
||||||
CommsChannel::Telegram => state
|
"We skipped channel-verified notice because the account doesn't have comms preferences"
|
||||||
.repos
|
);
|
||||||
.user
|
return;
|
||||||
.get_telegram_chat_id(user_id)
|
}
|
||||||
.await
|
Err(e) => {
|
||||||
.ok()
|
warn!(
|
||||||
.flatten()
|
user_id = %user_id,
|
||||||
.map(|id| id.to_string())
|
channel = ?channel,
|
||||||
.unwrap_or_else(|| identifier.to_string()),
|
error = ?e,
|
||||||
_ => identifier.to_string(),
|
"We skipped channel-verified notice because we couldn't load the account's comms preferences"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
let Some(recipient) = tranquil_pds::comms::recipient_for(&prefs, channel) else {
|
||||||
|
warn!(
|
||||||
|
user_id = %user_id,
|
||||||
|
channel = ?channel,
|
||||||
|
"We skipped channel-verified notice because the account doesn't have a valid recipient"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
comms_repo::try_channel_verified_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id,
|
user_id,
|
||||||
channel,
|
|
||||||
&recipient,
|
&recipient,
|
||||||
&tranquil_config::get().server.hostname,
|
&tranquil_config::get().server.hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_signup_verification(
|
async fn handle_signup_verification(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
identifier: &str,
|
|
||||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||||
let user = state
|
let user = state
|
||||||
.repos
|
.repos
|
||||||
@@ -238,7 +243,7 @@ async fn handle_signup_verification(
|
|||||||
|
|
||||||
info!(did = %did, channel = ?channel, "Signup verified successfully");
|
info!(did = %did, channel = ?channel, "Signup verified successfully");
|
||||||
|
|
||||||
notify_channel_verified(state, user.id, channel, identifier).await;
|
notify_channel_verified(state, user.id, channel).await;
|
||||||
|
|
||||||
Ok(Json(VerifyTokenOutput {
|
Ok(Json(VerifyTokenOutput {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ pub async fn handle_telegram_webhook(
|
|||||||
return StatusCode::OK;
|
return StatusCode::OK;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let username = match tranquil_types::TelegramUsername::new(username) {
|
||||||
|
Ok(username) => username,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "We ignored a /start with an invalid Telegram username");
|
||||||
|
return StatusCode::OK;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
telegram_username = %username,
|
telegram_username = %username,
|
||||||
@@ -95,17 +102,21 @@ pub async fn handle_telegram_webhook(
|
|||||||
chat_id = from.id,
|
chat_id = from.id,
|
||||||
"Verified Telegram user and stored chat_id"
|
"Verified Telegram user and stored chat_id"
|
||||||
);
|
);
|
||||||
if let Err(e) = comms_repo::enqueue_channel_verified(
|
match tranquil_types::TelegramChatId::from_i64(from.id) {
|
||||||
state.repos.user.as_ref(),
|
Some(chat_id) => {
|
||||||
state.repos.infra.as_ref(),
|
comms_repo::try_channel_verified_notice(
|
||||||
user_id,
|
state.repos.user.as_ref(),
|
||||||
tranquil_db_traits::CommsChannel::Telegram,
|
state.repos.infra.as_ref(),
|
||||||
&from.id.to_string(),
|
user_id,
|
||||||
&tranquil_config::get().server.hostname,
|
&tranquil_types::Recipient::Telegram(chat_id),
|
||||||
)
|
&tranquil_config::get().server.hostname,
|
||||||
.await
|
)
|
||||||
{
|
.await;
|
||||||
warn!(error = %e, "Failed to enqueue channel verified notification");
|
}
|
||||||
|
None => warn!(
|
||||||
|
chat_id = from.id,
|
||||||
|
"We skipped verified notice because the Telegram chat ID can't be 0"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
@@ -178,8 +189,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn payload_with_extra_whitespace_trimmed() {
|
fn payload_with_extra_whitespace_trimmed() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_start_handle(Some("/start alice_example_com ")),
|
parse_start_handle(Some("/start oys_nel_pet ")),
|
||||||
Some("alice.example.com".to_string()),
|
Some("oys.nel.pet".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ subtle = { workspace = true }
|
|||||||
totp-rs = { workspace = true }
|
totp-rs = { workspace = true }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
brotli = "8.0.4"
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use brotli::{CompressorWriter, Decompressor};
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
const COMPRESSED_PREFIX: &str = "$br$";
|
||||||
|
const QUALITY: u32 = 9;
|
||||||
|
const WINDOW_BITS: u32 = 16;
|
||||||
|
const BUFFER_SIZE: usize = 4096;
|
||||||
|
const MAX_SCOPE_LEN: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ScopeDecodeError {
|
||||||
|
Base64DecodeFailed,
|
||||||
|
DecompressFailed,
|
||||||
|
TooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ScopeDecodeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Base64DecodeFailed => write!(f, "Base64 decode of compressed scope failed"),
|
||||||
|
Self::DecompressFailed => write!(f, "Brotli decompression of scope failed"),
|
||||||
|
Self::TooLarge => write!(f, "Decompressed scope exceeds maximum length"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ScopeDecodeError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ScopeEncodeError {
|
||||||
|
TooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ScopeEncodeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::TooLarge => write!(f, "Scope exceeds maximum length"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ScopeEncodeError {}
|
||||||
|
|
||||||
|
fn brotli_compress(input: &str) -> Vec<u8> {
|
||||||
|
let mut writer = CompressorWriter::new(Vec::new(), BUFFER_SIZE, QUALITY, WINDOW_BITS);
|
||||||
|
|
||||||
|
writer
|
||||||
|
.write_all(input.as_bytes())
|
||||||
|
.expect("writing to a Vec cannot fail");
|
||||||
|
|
||||||
|
writer.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brotli_decompress(input: &[u8]) -> Result<String, ScopeDecodeError> {
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
Decompressor::new(input, BUFFER_SIZE)
|
||||||
|
.take(MAX_SCOPE_LEN + 1)
|
||||||
|
.read_to_string(&mut output)
|
||||||
|
.map_err(|_| ScopeDecodeError::DecompressFailed)?;
|
||||||
|
|
||||||
|
if output.len() as u64 > MAX_SCOPE_LEN {
|
||||||
|
return Err(ScopeDecodeError::TooLarge);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_scope(scope: &str) -> Result<String, ScopeEncodeError> {
|
||||||
|
if scope.len() as u64 > MAX_SCOPE_LEN {
|
||||||
|
return Err(ScopeEncodeError::TooLarge);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagged = format!(
|
||||||
|
"{COMPRESSED_PREFIX}{}",
|
||||||
|
URL_SAFE_NO_PAD.encode(brotli_compress(scope))
|
||||||
|
);
|
||||||
|
|
||||||
|
if tagged.len() < scope.len() || scope.starts_with(COMPRESSED_PREFIX) {
|
||||||
|
Ok(tagged)
|
||||||
|
} else {
|
||||||
|
Ok(scope.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_scope(scope: &str) -> Result<String, ScopeDecodeError> {
|
||||||
|
let Some(encoded) = scope.strip_prefix(COMPRESSED_PREFIX) else {
|
||||||
|
return Ok(scope.to_owned());
|
||||||
|
};
|
||||||
|
|
||||||
|
let compressed = URL_SAFE_NO_PAD
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| ScopeDecodeError::Base64DecodeFailed)?;
|
||||||
|
|
||||||
|
brotli_decompress(&compressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn long_scope() -> String {
|
||||||
|
let mut scope = String::from("transition:generic transition:chat.bsky");
|
||||||
|
for collection in [
|
||||||
|
"social.colibri.message",
|
||||||
|
"social.colibri.community",
|
||||||
|
"social.colibri.reaction",
|
||||||
|
"social.colibri.member",
|
||||||
|
"social.colibri.channel.read",
|
||||||
|
] {
|
||||||
|
scope.push_str(&format!(" repo:{collection}?action=create&action=delete"));
|
||||||
|
}
|
||||||
|
scope
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_scope_roundtrips_through_compression() {
|
||||||
|
let scope = long_scope();
|
||||||
|
let encoded = encode_scope(&scope).unwrap();
|
||||||
|
|
||||||
|
assert!(encoded.starts_with(COMPRESSED_PREFIX));
|
||||||
|
assert!(encoded.len() < scope.len());
|
||||||
|
assert_eq!(decode_scope(&encoded).unwrap(), scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_scope_stays_plaintext() {
|
||||||
|
let encoded = encode_scope("com.atproto.access").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(encoded, "com.atproto.access");
|
||||||
|
assert_eq!(decode_scope(&encoded).unwrap(), "com.atproto.access");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untagged_scope_passes_through() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_scope("com.atproto.refresh").unwrap(),
|
||||||
|
"com.atproto.refresh"
|
||||||
|
);
|
||||||
|
assert_eq!(decode_scope("").unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_compressed_scope_errors_instead_of_panicking() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_scope("$br$not valid base64!"),
|
||||||
|
Err(ScopeDecodeError::Base64DecodeFailed)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_scope("$br$AAAAAAAAAAAAAAAA"),
|
||||||
|
Err(ScopeDecodeError::DecompressFailed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compression_bomb_is_rejected() {
|
||||||
|
let bomb = URL_SAFE_NO_PAD.encode(brotli_compress(&"a".repeat(MAX_SCOPE_LEN as usize * 2)));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
decode_scope(&format!("{COMPRESSED_PREFIX}{bomb}")),
|
||||||
|
Err(ScopeDecodeError::TooLarge)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plaintext_that_looks_compressed_roundtrips() {
|
||||||
|
let scope = "$br$repo:*";
|
||||||
|
let encoded = encode_scope(scope).unwrap();
|
||||||
|
|
||||||
|
assert!(encoded.starts_with(COMPRESSED_PREFIX));
|
||||||
|
assert_eq!(decode_scope(&encoded).unwrap(), scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_rejects_oversized_scope() {
|
||||||
|
let oversized = "a".repeat(MAX_SCOPE_LEN as usize + 1);
|
||||||
|
|
||||||
|
assert_eq!(encode_scope(&oversized), Err(ScopeEncodeError::TooLarge));
|
||||||
|
assert!(encode_scope(&"a".repeat(MAX_SCOPE_LEN as usize)).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod compress;
|
||||||
mod token;
|
mod token;
|
||||||
mod totp;
|
mod totp;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -12,6 +13,8 @@ pub use token::{
|
|||||||
create_service_token_hs256,
|
create_service_token_hs256,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use compress::{ScopeDecodeError, ScopeEncodeError, decode_scope, encode_scope};
|
||||||
|
|
||||||
pub use totp::{
|
pub use totp::{
|
||||||
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
|
||||||
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
use crate::compress::encode_scope;
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
|
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use tranquil_types::{Did, Jti, Nsid};
|
use tranquil_types::{Did, DidRef, Jti, Nsid};
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
@@ -125,16 +127,20 @@ pub fn create_refresh_token_with_jti(
|
|||||||
|
|
||||||
pub fn create_service_token(
|
pub fn create_service_token(
|
||||||
did: &Did,
|
did: &Did,
|
||||||
aud: &Did,
|
aud: &DidRef,
|
||||||
lxm: Option<&Nsid>,
|
lxm: Option<&Nsid>,
|
||||||
|
exp: Option<i64>,
|
||||||
key_bytes: &[u8],
|
key_bytes: &[u8],
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||||
|
|
||||||
let expiration = Utc::now()
|
let expiration = match exp {
|
||||||
.checked_add_signed(Duration::seconds(60))
|
Some(exp) => exp,
|
||||||
.expect("valid timestamp")
|
None => Utc::now()
|
||||||
.timestamp();
|
.checked_add_signed(Duration::seconds(60))
|
||||||
|
.expect("valid timestamp")
|
||||||
|
.timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
iss: did.clone(),
|
iss: did.clone(),
|
||||||
@@ -205,7 +211,7 @@ fn create_signed_token_pinned(
|
|||||||
aud: format!("did:web:{}", aud_hostname),
|
aud: format!("did:web:{}", aud_hostname),
|
||||||
exp: expiration,
|
exp: expiration,
|
||||||
iat: Utc::now().timestamp(),
|
iat: Utc::now().timestamp(),
|
||||||
scope: Some(scope.to_string()),
|
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
|
||||||
lxm: None,
|
lxm: None,
|
||||||
jti: jti.clone(),
|
jti: jti.clone(),
|
||||||
act,
|
act,
|
||||||
@@ -328,7 +334,7 @@ fn create_hs256_token_with_metadata(
|
|||||||
),
|
),
|
||||||
exp: expiration,
|
exp: expiration,
|
||||||
iat: Utc::now().timestamp(),
|
iat: Utc::now().timestamp(),
|
||||||
scope: Some(scope.to_string()),
|
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
|
||||||
lxm: None,
|
lxm: None,
|
||||||
jti: jti.clone(),
|
jti: jti.clone(),
|
||||||
act: None,
|
act: None,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::compress::decode_scope;
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
|
||||||
TokenVerifyError, UnsafeClaims,
|
TokenVerifyError, UnsafeClaims,
|
||||||
@@ -164,9 +166,15 @@ pub fn verify_token_es256k(
|
|||||||
.decode(claims_b64)
|
.decode(claims_b64)
|
||||||
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
|
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
|
||||||
|
|
||||||
let claims: Claims = serde_json::from_slice(&claims_bytes)
|
let mut claims: Claims = serde_json::from_slice(&claims_bytes)
|
||||||
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
|
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
|
||||||
|
|
||||||
|
if let Some(scope) = &claims.scope {
|
||||||
|
claims.scope = Some(
|
||||||
|
decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
if claims.exp < now {
|
if claims.exp < now {
|
||||||
return Err(TokenVerifyError::Expired);
|
return Err(TokenVerifyError::Expired);
|
||||||
@@ -244,9 +252,13 @@ fn verify_token_hs256_internal(
|
|||||||
.decode(claims_b64)
|
.decode(claims_b64)
|
||||||
.context("Base64 decode of claims failed")?;
|
.context("Base64 decode of claims failed")?;
|
||||||
|
|
||||||
let claims: Claims =
|
let mut claims: Claims =
|
||||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||||
|
|
||||||
|
if let Some(scope) = &claims.scope {
|
||||||
|
claims.scope = Some(decode_scope(scope).context("Invalid scope claim encoding")?);
|
||||||
|
}
|
||||||
|
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
if claims.exp < now {
|
if claims.exp < now {
|
||||||
return Err(anyhow!("Token expired"));
|
return Err(anyhow!("Token expired"));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ valkey = ["dep:redis"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tranquil-config = { workspace = true }
|
tranquil-config = { workspace = true }
|
||||||
tranquil-infra = { workspace = true }
|
tranquil-infra = { workspace = true, features = ["cache-keys"] }
|
||||||
tranquil-ripple = { workspace = true }
|
tranquil-ripple = { workspace = true }
|
||||||
|
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
|
pub use tranquil_infra::{
|
||||||
|
Cache, CacheError, DistributedRateLimiter, cache_keys, cached_json, read_json, write_json,
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -173,11 +175,10 @@ pub async fn create_cache(
|
|||||||
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
|
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
|
||||||
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
|
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
|
||||||
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
|
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
|
||||||
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
|
|
||||||
|
|
||||||
#[cfg(feature = "valkey")]
|
#[cfg(feature = "valkey")]
|
||||||
if backend == "valkey" {
|
if backend == "valkey" {
|
||||||
if let Some(url) = valkey_url {
|
if let Some(url) = cache_cfg.and_then(|c| c.valkey_url.as_deref()) {
|
||||||
match ValkeyCache::new(url).await {
|
match ValkeyCache::new(url).await {
|
||||||
Ok(cache) => {
|
Ok(cache) => {
|
||||||
tracing::info!("using valkey cache at {url}");
|
tracing::info!("using valkey cache at {url}");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tranquil-config = { workspace = true }
|
tranquil-config = { workspace = true }
|
||||||
tranquil-signal = { workspace = true }
|
tranquil-signal = { workspace = true }
|
||||||
|
tranquil-types = { workspace = true }
|
||||||
|
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
use lettre::Message;
|
use lettre::Message;
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
use lettre::message::header::ContentType;
|
use lettre::message::header::{ContentType, MIME_VERSION_1_0};
|
||||||
|
use lettre::message::header::{Header, HeaderName, HeaderValue};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::types::EmailDomain;
|
use super::types::EmailDomain;
|
||||||
use crate::sender::SendError;
|
use crate::sender::SendError;
|
||||||
use crate::types::QueuedComms;
|
use crate::{CommsType, QueuedComms};
|
||||||
|
|
||||||
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
|
pub(super) fn build(
|
||||||
let to: Mailbox = qc
|
from: &Mailbox,
|
||||||
.recipient
|
qc: &QueuedComms,
|
||||||
|
to: &tranquil_types::EmailAddress,
|
||||||
|
apply_atmos_categories: bool,
|
||||||
|
) -> Result<Message, SendError> {
|
||||||
|
let to: Mailbox = to
|
||||||
|
.as_str()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
|
||||||
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
let subject = qc.subject.as_deref().unwrap_or("Notification");
|
||||||
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
|
||||||
Message::builder()
|
let builder = Message::builder()
|
||||||
.from(from.clone())
|
.from(from.clone())
|
||||||
.to(to)
|
.to(to)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.message_id(Some(message_id))
|
.message_id(Some(message_id))
|
||||||
.header(ContentType::TEXT_PLAIN)
|
.header(MIME_VERSION_1_0)
|
||||||
|
.header(ContentType::TEXT_PLAIN);
|
||||||
|
|
||||||
|
let category = apply_atmos_categories
|
||||||
|
.then(|| atmos_category(qc.comms_type))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let builder = match category {
|
||||||
|
Some(category) => builder.header(category),
|
||||||
|
None => builder,
|
||||||
|
};
|
||||||
|
|
||||||
|
builder
|
||||||
.body(qc.body.clone())
|
.body(qc.body.clone())
|
||||||
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
.map_err(|e| SendError::MessageBuild(e.to_string()))
|
||||||
}
|
}
|
||||||
@@ -34,10 +52,57 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
|
|||||||
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// for use with comail.at
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
|
||||||
|
enum AtmosCategory {
|
||||||
|
PasswordReset,
|
||||||
|
MfaOtp,
|
||||||
|
Verification,
|
||||||
|
}
|
||||||
|
impl AtmosCategory {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::PasswordReset => "password-reset",
|
||||||
|
Self::MfaOtp => "mfa-otp",
|
||||||
|
Self::Verification => "verification",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Header for AtmosCategory {
|
||||||
|
fn name() -> HeaderName {
|
||||||
|
HeaderName::new_from_ascii_str("X-Atmos-Category")
|
||||||
|
}
|
||||||
|
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
//since we're never receiving email, we don't care about parsing
|
||||||
|
Err("X-Atmos-Category is write-only".into())
|
||||||
|
}
|
||||||
|
fn display(&self) -> HeaderValue {
|
||||||
|
HeaderValue::new(Self::name(), self.as_str().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
|
||||||
|
use CommsType::*;
|
||||||
|
match comms_type {
|
||||||
|
EmailVerification
|
||||||
|
| ChannelVerification
|
||||||
|
| ChannelVerified
|
||||||
|
| MigrationVerification
|
||||||
|
| LegacyLoginAlert
|
||||||
|
| EmailUpdate
|
||||||
|
| PlcOperation
|
||||||
|
| AccountDeletion
|
||||||
|
| Welcome => Some(AtmosCategory::Verification),
|
||||||
|
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
|
||||||
|
TwoFactorCode => Some(AtmosCategory::MfaOtp),
|
||||||
|
AdminEmail => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::types::{CommsChannel, CommsStatus, CommsType};
|
use crate::{CommsChannel, CommsStatus};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -45,6 +110,10 @@ mod tests {
|
|||||||
"Test Sender <noreply@nel.pet>".parse().unwrap()
|
"Test Sender <noreply@nel.pet>".parse().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to(recipient: &str) -> tranquil_types::EmailAddress {
|
||||||
|
tranquil_types::EmailAddress::new(recipient).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
|
fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
|
||||||
QueuedComms {
|
QueuedComms {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
@@ -71,6 +140,8 @@ mod tests {
|
|||||||
let msg = build(
|
let msg = build(
|
||||||
&from_mailbox(),
|
&from_mailbox(),
|
||||||
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
|
||||||
|
&to("user@nel.pet"),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
@@ -78,6 +149,7 @@ mod tests {
|
|||||||
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
|
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
|
||||||
assert!(raw.contains("To: user@nel.pet"));
|
assert!(raw.contains("To: user@nel.pet"));
|
||||||
assert!(raw.contains("Subject: Welcome"));
|
assert!(raw.contains("Subject: Welcome"));
|
||||||
|
assert!(raw.contains("MIME-Version: 1.0"));
|
||||||
assert!(lower.contains("content-type: text/plain"));
|
assert!(lower.contains("content-type: text/plain"));
|
||||||
assert!(raw.contains("Hello world."));
|
assert!(raw.contains("Hello world."));
|
||||||
}
|
}
|
||||||
@@ -87,6 +159,8 @@ mod tests {
|
|||||||
let msg = build(
|
let msg = build(
|
||||||
&from_mailbox(),
|
&from_mailbox(),
|
||||||
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
|
||||||
|
&to("user@jola.dev"),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
@@ -96,11 +170,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn header_injection_rejected() {
|
fn header_injection_rejected() {
|
||||||
let result = build(
|
let result = tranquil_types::EmailAddress::new("x@jola.dev\r\nBcc: evil@x");
|
||||||
&from_mailbox(),
|
assert!(result.is_err());
|
||||||
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
|
|
||||||
);
|
|
||||||
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -108,12 +179,14 @@ mod tests {
|
|||||||
let msg = build(
|
let msg = build(
|
||||||
&from_mailbox(),
|
&from_mailbox(),
|
||||||
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
|
||||||
|
&to("user@jola.dev"),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.expect("subject CRLF should be encoded, not rejected");
|
.expect("subject CRLF should be encoded, not rejected");
|
||||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!raw.contains("Bcc:"),
|
!raw.contains("Bcc:"),
|
||||||
"CRLF in subject must not produce a Bcc header: {raw}"
|
"CRLF in subject mustn't produce a Bcc header: {raw}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
raw.contains("Subject: ="),
|
raw.contains("Subject: ="),
|
||||||
@@ -123,7 +196,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn message_id_uses_from_domain() {
|
fn message_id_uses_from_domain() {
|
||||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@nel.pet", Some("s"), "b"),
|
||||||
|
&to("user@jola.dev"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
let line = raw
|
let line = raw
|
||||||
.lines()
|
.lines()
|
||||||
@@ -137,15 +216,60 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_subject_uses_default() {
|
fn missing_subject_uses_default() {
|
||||||
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@nel.pet", None, "Body"),
|
||||||
|
&to("user@nel.pet"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let raw = String::from_utf8(msg.formatted()).unwrap();
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
assert!(raw.contains("Subject: Notification"));
|
assert!(raw.contains("Subject: Notification"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recipient_domain_extracted() {
|
fn recipient_domain_extracted() {
|
||||||
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
|
let msg = build(
|
||||||
|
&from_mailbox(),
|
||||||
|
&fixture("user@Nel.PET", Some("s"), "b"),
|
||||||
|
&to("user@nel.pet"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let d = recipient_domain(&msg).unwrap();
|
let d = recipient_domain(&msg).unwrap();
|
||||||
assert_eq!(d.as_str(), "nel.pet");
|
assert_eq!(d.as_str(), "nel.pet");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atmos_category_header_present_when_enabled_and_mapped() {
|
||||||
|
let qc = QueuedComms {
|
||||||
|
comms_type: CommsType::PasswordReset,
|
||||||
|
..fixture("user@nel.pet", Some("s"), "b")
|
||||||
|
};
|
||||||
|
let msg = build(&from_mailbox(), &qc, &to("user@jola.dev"), true).unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(raw.contains("X-Atmos-Category: password-reset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atmos_category_header_absent_when_disabled() {
|
||||||
|
let qc = QueuedComms {
|
||||||
|
comms_type: CommsType::PasswordReset,
|
||||||
|
..fixture("user@nel.pet", Some("s"), "b")
|
||||||
|
};
|
||||||
|
let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), false).unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(!raw.contains("X-Atmos-Category"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atmos_category_header_absent_when_unmapped() {
|
||||||
|
let qc = QueuedComms {
|
||||||
|
comms_type: CommsType::AdminEmail,
|
||||||
|
..fixture("user@nel.pet", Some("s"), "b")
|
||||||
|
};
|
||||||
|
let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), true).unwrap();
|
||||||
|
let raw = String::from_utf8(msg.formatted()).unwrap();
|
||||||
|
assert!(!raw.contains("X-Atmos-Category"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use self::types::{
|
|||||||
SmtpUsername, TlsMode,
|
SmtpUsername, TlsMode,
|
||||||
};
|
};
|
||||||
use crate::sender::{CommsSender, SendError};
|
use crate::sender::{CommsSender, SendError};
|
||||||
use crate::types::{CommsChannel, QueuedComms};
|
use crate::{CommsChannel, QueuedComms};
|
||||||
|
|
||||||
pub struct EmailSender {
|
pub struct EmailSender {
|
||||||
from: Mailbox,
|
from: Mailbox,
|
||||||
@@ -124,6 +124,7 @@ fn build_smarthost(
|
|||||||
Ok(SendMode::Smarthost {
|
Ok(SendMode::Smarthost {
|
||||||
transport: Box::new(builder.build()),
|
transport: Box::new(builder.build()),
|
||||||
total_timeout,
|
total_timeout,
|
||||||
|
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,14 +177,38 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
|
|||||||
DkimSigner::load(selector, domain, path).map(Some)
|
DkimSigner::load(selector, domain, path).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wants_atmos_categories(mode: &SendMode) -> bool {
|
||||||
|
match mode {
|
||||||
|
SendMode::Smarthost {
|
||||||
|
apply_atmos_categories,
|
||||||
|
..
|
||||||
|
} => *apply_atmos_categories,
|
||||||
|
SendMode::DirectMx { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl CommsSender for EmailSender {
|
impl CommsSender for EmailSender {
|
||||||
fn channel(&self) -> CommsChannel {
|
fn channel(&self) -> CommsChannel {
|
||||||
CommsChannel::Email
|
CommsChannel::Email
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
async fn send(
|
||||||
let mut message = message::build(&self.from, notification)?;
|
&self,
|
||||||
|
notification: &QueuedComms,
|
||||||
|
recipient: &tranquil_types::Recipient,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let tranquil_types::Recipient::Email(address) = recipient else {
|
||||||
|
return Err(SendError::InvalidRecipient(
|
||||||
|
"Recipient isn't an email address".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let mut message = message::build(
|
||||||
|
&self.from,
|
||||||
|
notification,
|
||||||
|
address,
|
||||||
|
wants_atmos_categories(&self.mode),
|
||||||
|
)?;
|
||||||
if let Some(signer) = &self.dkim {
|
if let Some(signer) = &self.dkim {
|
||||||
signer.sign(&mut message);
|
signer.sign(&mut message);
|
||||||
}
|
}
|
||||||
@@ -196,3 +221,45 @@ impl CommsSender for EmailSender {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use lettre::Tokio1Executor;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
|
||||||
|
let transport =
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
|
||||||
|
SendMode::Smarthost {
|
||||||
|
transport: Box::new(transport),
|
||||||
|
total_timeout: Duration::from_secs(10),
|
||||||
|
apply_atmos_categories,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dummy_direct_mx() -> SendMode {
|
||||||
|
SendMode::DirectMx {
|
||||||
|
resolver: Arc::new(TokioAsyncResolver::tokio(
|
||||||
|
ResolverConfig::default(),
|
||||||
|
ResolverOpts::default(),
|
||||||
|
)),
|
||||||
|
helo: HeloName::parse("mta.nel.pet").unwrap(),
|
||||||
|
command_timeout: Duration::from_secs(5),
|
||||||
|
total_timeout: Duration::from_secs(10),
|
||||||
|
require_tls: false,
|
||||||
|
inflight: Arc::new(Semaphore::new(1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn smarthost_reflects_its_own_flag() {
|
||||||
|
assert!(wants_atmos_categories(&dummy_smarthost(true)));
|
||||||
|
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_mx_never_wants_atmos_categories() {
|
||||||
|
assert!(!wants_atmos_categories(&dummy_direct_mx()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub enum SendMode {
|
|||||||
Smarthost {
|
Smarthost {
|
||||||
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
|
||||||
total_timeout: Duration,
|
total_timeout: Duration,
|
||||||
|
apply_atmos_categories: bool,
|
||||||
},
|
},
|
||||||
DirectMx {
|
DirectMx {
|
||||||
resolver: Arc<TokioAsyncResolver>,
|
resolver: Arc<TokioAsyncResolver>,
|
||||||
@@ -33,8 +34,15 @@ pub enum SendMode {
|
|||||||
impl std::fmt::Debug for SendMode {
|
impl std::fmt::Debug for SendMode {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Smarthost { total_timeout, .. } => {
|
Self::Smarthost {
|
||||||
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
|
total_timeout,
|
||||||
|
apply_atmos_categories,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Self::DirectMx {
|
Self::DirectMx {
|
||||||
helo, require_tls, ..
|
helo, require_tls, ..
|
||||||
@@ -52,6 +60,7 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
|
|||||||
SendMode::Smarthost {
|
SendMode::Smarthost {
|
||||||
transport,
|
transport,
|
||||||
total_timeout,
|
total_timeout,
|
||||||
|
..
|
||||||
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
|
||||||
SendMode::DirectMx {
|
SendMode::DirectMx {
|
||||||
resolver,
|
resolver,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
pub mod email;
|
pub mod email;
|
||||||
mod locale;
|
mod locale;
|
||||||
mod sender;
|
mod sender;
|
||||||
mod types;
|
|
||||||
|
|
||||||
pub use email::EmailSender;
|
pub use email::EmailSender;
|
||||||
pub use locale::{
|
pub use locale::{
|
||||||
@@ -10,6 +9,5 @@ pub use locale::{
|
|||||||
};
|
};
|
||||||
pub use sender::{
|
pub use sender::{
|
||||||
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
|
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number,
|
||||||
is_valid_signal_username,
|
|
||||||
};
|
};
|
||||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
pub use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType, QueuedComms};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use reqwest::Client;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::types::{CommsChannel, QueuedComms};
|
use tranquil_db_traits::{CommsChannel, QueuedComms};
|
||||||
|
|
||||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||||
const MAX_RETRIES: u32 = 3;
|
const MAX_RETRIES: u32 = 3;
|
||||||
@@ -12,7 +12,11 @@ const INITIAL_RETRY_DELAY_MS: u64 = 500;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait CommsSender: Send + Sync {
|
pub trait CommsSender: Send + Sync {
|
||||||
fn channel(&self) -> CommsChannel;
|
fn channel(&self) -> CommsChannel;
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>;
|
async fn send(
|
||||||
|
&self,
|
||||||
|
notification: &QueuedComms,
|
||||||
|
recipient: &tranquil_types::Recipient,
|
||||||
|
) -> Result<(), SendError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -140,10 +144,6 @@ pub fn is_valid_phone_number(number: &str) -> bool {
|
|||||||
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
|
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_valid_signal_username(username: &str) -> bool {
|
|
||||||
tranquil_signal::SignalUsername::parse(username).is_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)]
|
||||||
@@ -355,8 +355,17 @@ impl CommsSender for DiscordSender {
|
|||||||
CommsChannel::Discord
|
CommsChannel::Discord
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
async fn send(
|
||||||
let channel_id = self.open_dm_channel(¬ification.recipient).await?;
|
&self,
|
||||||
|
notification: &QueuedComms,
|
||||||
|
recipient: &tranquil_types::Recipient,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let tranquil_types::Recipient::Discord(user_id) = recipient else {
|
||||||
|
return Err(SendError::InvalidRecipient(
|
||||||
|
"Recipient isn't a Discord user ID".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let channel_id = self.open_dm_channel(user_id.as_str()).await?;
|
||||||
|
|
||||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||||
let content = format!("**{}**\n\n{}", subject, notification.body);
|
let content = format!("**{}**\n\n{}", subject, notification.body);
|
||||||
@@ -453,14 +462,22 @@ impl CommsSender for TelegramSender {
|
|||||||
CommsChannel::Telegram
|
CommsChannel::Telegram
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
async fn send(
|
||||||
let chat_id = ¬ification.recipient;
|
&self,
|
||||||
|
notification: &QueuedComms,
|
||||||
|
recipient: &tranquil_types::Recipient,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let tranquil_types::Recipient::Telegram(chat_id) = recipient else {
|
||||||
|
return Err(SendError::InvalidRecipient(
|
||||||
|
"Recipient isn't a Telegram chat ID".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
|
let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
|
||||||
let body = escape_html(¬ification.body);
|
let body = escape_html(¬ification.body);
|
||||||
let text = format!("<b>{}</b>\n\n{}", subject, body);
|
let text = format!("<b>{}</b>\n\n{}", subject, body);
|
||||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
|
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id.as_str(),
|
||||||
"text": text,
|
"text": text,
|
||||||
"parse_mode": "HTML"
|
"parse_mode": "HTML"
|
||||||
});
|
});
|
||||||
@@ -488,9 +505,16 @@ impl CommsSender for SignalSender {
|
|||||||
CommsChannel::Signal
|
CommsChannel::Signal
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
async fn send(
|
||||||
let username = tranquil_signal::SignalUsername::parse(¬ification.recipient)
|
&self,
|
||||||
.map_err(|e| SendError::InvalidRecipient(e.to_string()))?;
|
notification: &QueuedComms,
|
||||||
|
recipient: &tranquil_types::Recipient,
|
||||||
|
) -> Result<(), SendError> {
|
||||||
|
let tranquil_types::Recipient::Signal(username) = recipient else {
|
||||||
|
return Err(SendError::InvalidRecipient(
|
||||||
|
"Recipient isn't a Signal username".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
let client = self
|
let client = self
|
||||||
.slot
|
.slot
|
||||||
@@ -505,7 +529,7 @@ impl CommsSender for SignalSender {
|
|||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
for attempt in 0..MAX_RETRIES {
|
for attempt in 0..MAX_RETRIES {
|
||||||
match client.send(&username, message.clone()).await {
|
match client.send(username, message.clone()).await {
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType, QueuedComms};
|
|
||||||
|
|
||||||
pub struct NewComms {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub channel: CommsChannel,
|
|
||||||
pub comms_type: CommsType,
|
|
||||||
pub recipient: String,
|
|
||||||
pub subject: Option<String>,
|
|
||||||
pub body: String,
|
|
||||||
pub metadata: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewComms {
|
|
||||||
pub fn new(
|
|
||||||
user_id: Uuid,
|
|
||||||
channel: CommsChannel,
|
|
||||||
comms_type: CommsType,
|
|
||||||
recipient: String,
|
|
||||||
subject: Option<String>,
|
|
||||||
body: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
user_id,
|
|
||||||
channel,
|
|
||||||
comms_type,
|
|
||||||
recipient,
|
|
||||||
subject,
|
|
||||||
body,
|
|
||||||
metadata: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn email(
|
|
||||||
user_id: Uuid,
|
|
||||||
comms_type: CommsType,
|
|
||||||
recipient: String,
|
|
||||||
subject: String,
|
|
||||||
body: String,
|
|
||||||
) -> Self {
|
|
||||||
Self::new(
|
|
||||||
user_id,
|
|
||||||
CommsChannel::Email,
|
|
||||||
comms_type,
|
|
||||||
recipient,
|
|
||||||
Some(subject),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,6 +32,10 @@ fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to(recipient: &str) -> tranquil_types::Recipient {
|
||||||
|
tranquil_types::Recipient::new(CommsChannel::Email, recipient).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
|
fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
|
||||||
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
|
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10))
|
||||||
}
|
}
|
||||||
@@ -53,6 +57,7 @@ fn build_smarthost_sender_with_total_timeout(
|
|||||||
SendMode::Smarthost {
|
SendMode::Smarthost {
|
||||||
transport: Box::new(transport),
|
transport: Box::new(transport),
|
||||||
total_timeout,
|
total_timeout,
|
||||||
|
apply_atmos_categories: false,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -100,7 +105,9 @@ async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 {
|
|||||||
async fn rcpt_550_classifies_as_smtp_permanent() {
|
async fn rcpt_550_classifies_as_smtp_permanent() {
|
||||||
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await;
|
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 sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
let result = sender
|
||||||
|
.send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet"))
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Err(SendError::SmtpPermanent(_)) => {}
|
Err(SendError::SmtpPermanent(_)) => {}
|
||||||
other => panic!("expected SmtpPermanent, got {other:?}"),
|
other => panic!("expected SmtpPermanent, got {other:?}"),
|
||||||
@@ -111,7 +118,9 @@ async fn rcpt_550_classifies_as_smtp_permanent() {
|
|||||||
async fn rcpt_421_classifies_as_smtp_transient() {
|
async fn rcpt_421_classifies_as_smtp_transient() {
|
||||||
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await;
|
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 sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
let result = sender
|
||||||
|
.send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet"))
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Err(SendError::SmtpTransient(_)) => {}
|
Err(SendError::SmtpTransient(_)) => {}
|
||||||
other => panic!("expected SmtpTransient, got {other:?}"),
|
other => panic!("expected SmtpTransient, got {other:?}"),
|
||||||
@@ -119,10 +128,13 @@ async fn rcpt_421_classifies_as_smtp_transient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_recipient_classifies_as_invalid_recipient() {
|
async fn send_rejects_mismatched_recipient_variant() {
|
||||||
let port = spawn_stub(b"250 OK\r\n").await;
|
let port = spawn_stub(b"250 OK\r\n").await;
|
||||||
let sender = build_smarthost_sender("127.0.0.1", port);
|
let sender = build_smarthost_sender("127.0.0.1", port);
|
||||||
let result = sender.send(&fixture("not-an-address", "x", "x")).await;
|
let recipient = tranquil_types::Recipient::new(CommsChannel::Signal, "oys.01").unwrap();
|
||||||
|
let result = sender
|
||||||
|
.send(&fixture("oys@nel.pet", "x", "x"), &recipient)
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Err(SendError::InvalidRecipient(_)) => {}
|
Err(SendError::InvalidRecipient(_)) => {}
|
||||||
other => panic!("expected InvalidRecipient, got {other:?}"),
|
other => panic!("expected InvalidRecipient, got {other:?}"),
|
||||||
@@ -145,7 +157,9 @@ async fn smarthost_silent_relay_hits_total_timeout() {
|
|||||||
let sender =
|
let sender =
|
||||||
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
|
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await;
|
let result = sender
|
||||||
|
.send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet"))
|
||||||
|
.await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
match result {
|
match result {
|
||||||
Err(SendError::Timeout) => {}
|
Err(SendError::Timeout) => {}
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
tranquil-types = { workspace = true }
|
||||||
confique = { workspace = true }
|
confique = { workspace = true }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use confique::Config;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use tranquil_types::Domain;
|
||||||
|
|
||||||
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
|
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
|
||||||
|
|
||||||
@@ -30,7 +31,6 @@ impl fmt::Display for ConfigError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ConfigError {}
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
/// Initialize the global configuration. Must be called once at startup before
|
/// Initialize the global configuration. Must be called once at startup before
|
||||||
/// any other code accesses the configuration. Panics if called more than once.
|
/// any other code accesses the configuration. Panics if called more than once.
|
||||||
pub fn init(config: TranquilConfig) {
|
pub fn init(config: TranquilConfig) {
|
||||||
@@ -224,6 +224,12 @@ impl TranquilConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(e) = Domain::new(self.server.hostname_without_port()) {
|
||||||
|
errors.push(format!(
|
||||||
|
"server.hostname (PDS_HOSTNAME) must be a plain domain, {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// -- email -----------------------------------------------------------
|
// -- email -----------------------------------------------------------
|
||||||
self.email
|
self.email
|
||||||
.validate(self.server.hostname_without_port(), &mut errors);
|
.validate(self.server.hostname_without_port(), &mut errors);
|
||||||
@@ -428,7 +434,7 @@ pub struct ServerConfig {
|
|||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
|
|
||||||
/// Address to bind the HTTP server to.
|
/// Address to bind the HTTP server to.
|
||||||
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
|
#[config(env = "SERVER_HOST", default = "[::1]")]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
|
|
||||||
/// Port to bind the HTTP server to.
|
/// Port to bind the HTTP server to.
|
||||||
@@ -438,13 +444,25 @@ pub struct ServerConfig {
|
|||||||
/// List of domains for user handles.
|
/// List of domains for user handles.
|
||||||
/// Defaults to the PDS hostname when not set.
|
/// Defaults to the PDS hostname when not set.
|
||||||
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
|
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
|
||||||
pub user_handle_domains: Option<Vec<String>>,
|
pub user_handle_domains: Option<Vec<Domain>>,
|
||||||
|
|
||||||
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
|
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
|
||||||
/// long-term commitment to serve DID documents; opt-in only.
|
/// long-term commitment to serve DID documents; opt-in only.
|
||||||
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
|
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
|
||||||
pub enable_pds_hosted_did_web: bool,
|
pub enable_pds_hosted_did_web: bool,
|
||||||
|
|
||||||
|
/// The caddy on-demand TLS requires we serve
|
||||||
|
/// the endpoint `/.well-known/caddy/ask`.
|
||||||
|
/// It will be used so that caddy can create TLS
|
||||||
|
/// certs for us on the fly
|
||||||
|
/// and we don't have to do annoying wildcard certs.
|
||||||
|
#[config(env = "ENABLE_CADDY_ON_DEMAND_TLS", default = true)]
|
||||||
|
pub enable_caddy_on_demand_tls: bool,
|
||||||
|
|
||||||
|
/// iykyk!
|
||||||
|
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
|
||||||
|
pub rfc_moo_compliance: bool,
|
||||||
|
|
||||||
/// When set to true, skip age-assurance birthday prompt for all accounts.
|
/// When set to true, skip age-assurance birthday prompt for all accounts.
|
||||||
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
|
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
|
||||||
pub age_assurance_override: bool,
|
pub age_assurance_override: bool,
|
||||||
@@ -461,6 +479,10 @@ pub struct ServerConfig {
|
|||||||
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
|
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
|
||||||
pub disable_rate_limiting: bool,
|
pub disable_rate_limiting: bool,
|
||||||
|
|
||||||
|
/// Allow outbound fetches to private network addresses. Useful for local development using docker compose.
|
||||||
|
#[config(env = "ALLOW_PRIVATE_FETCH", default = false)]
|
||||||
|
pub allow_private_fetch: bool,
|
||||||
|
|
||||||
/// Skip the verified-comms-channel gate for login and record writes.
|
/// Skip the verified-comms-channel gate for login and record writes.
|
||||||
/// Please keep this off unless you're an invite-only PDS!
|
/// Please keep this off unless you're an invite-only PDS!
|
||||||
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
|
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
|
||||||
@@ -565,20 +587,6 @@ impl ServerConfig {
|
|||||||
pub fn banned_word_list(&self) -> Vec<String> {
|
pub fn banned_word_list(&self) -> Vec<String> {
|
||||||
self.banned_words.clone().unwrap_or_default()
|
self.banned_words.clone().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
|
|
||||||
pub fn user_handle_domain_list(&self) -> Vec<String> {
|
|
||||||
self.user_handle_domains
|
|
||||||
.as_deref()
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(|v| v.to_vec())
|
|
||||||
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
|
|
||||||
pub fn available_user_domain_list(&self) -> Vec<String> {
|
|
||||||
self.user_handle_domain_list()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Config)]
|
#[derive(Debug, Config)]
|
||||||
@@ -762,8 +770,12 @@ pub struct StorageConfig {
|
|||||||
#[config(env = "S3_ENDPOINT")]
|
#[config(env = "S3_ENDPOINT")]
|
||||||
pub s3_endpoint: Option<String>,
|
pub s3_endpoint: Option<String>,
|
||||||
|
|
||||||
|
/// Path on the storage for the S3 blob backend.
|
||||||
|
#[config(env = "S3_PATH", default = "")]
|
||||||
|
pub s3_path: String,
|
||||||
|
|
||||||
/// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db.
|
/// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db.
|
||||||
/// tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS.
|
/// `tranquil-store` is our own solution, not a tried-and-tested out-of-box database. If you're not feeling brave, don't choose this.
|
||||||
#[config(env = "REPO_BACKEND", default = "postgres")]
|
#[config(env = "REPO_BACKEND", default = "postgres")]
|
||||||
pub repo_backend: String,
|
pub repo_backend: String,
|
||||||
}
|
}
|
||||||
@@ -831,7 +843,7 @@ pub struct PlcConfig {
|
|||||||
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
|
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
|
||||||
pub connect_timeout_secs: u64,
|
pub connect_timeout_secs: u64,
|
||||||
|
|
||||||
/// Seconds to cache DID documents in memory.
|
/// Seconds to cache DID documents.
|
||||||
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
|
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
|
||||||
pub did_cache_ttl_secs: u64,
|
pub did_cache_ttl_secs: u64,
|
||||||
}
|
}
|
||||||
@@ -1120,6 +1132,10 @@ pub struct SmarthostConfig {
|
|||||||
/// stuck relay cannot stall the comms queue.
|
/// stuck relay cannot stall the comms queue.
|
||||||
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
|
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
|
||||||
pub total_timeout_secs: u64,
|
pub total_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
|
||||||
|
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
|
||||||
|
pub apply_atmos_categories: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Config)]
|
#[derive(Debug, Config)]
|
||||||
@@ -1472,12 +1488,13 @@ pub struct ImportConfig {
|
|||||||
/// trimming whitespace and dropping empty entries.
|
/// trimming whitespace and dropping empty entries.
|
||||||
///
|
///
|
||||||
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
|
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
|
||||||
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
|
fn split_comma_list<T: std::str::FromStr>(value: &str) -> Result<Vec<T>, T::Err> {
|
||||||
Ok(value
|
value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|item| item.trim().to_string())
|
.map(str::trim)
|
||||||
.filter(|item| !item.is_empty())
|
.filter(|item| !item.is_empty())
|
||||||
.collect())
|
.map(T::from_str)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Config)]
|
#[derive(Debug, Config)]
|
||||||
@@ -1981,6 +1998,7 @@ port = 587
|
|||||||
pool_size: 4,
|
pool_size: 4,
|
||||||
command_timeout_secs: 30,
|
command_timeout_secs: 30,
|
||||||
total_timeout_secs: 60,
|
total_timeout_secs: 60,
|
||||||
|
apply_atmos_categories: false,
|
||||||
},
|
},
|
||||||
direct_mx: DirectMxConfig {
|
direct_mx: DirectMxConfig {
|
||||||
command_timeout_secs: 30,
|
command_timeout_secs: 30,
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ pub trait BlobRepository: Send + Sync {
|
|||||||
takedown_ref: Option<&str>,
|
takedown_ref: Option<&str>,
|
||||||
) -> Result<bool, DbError>;
|
) -> Result<bool, DbError>;
|
||||||
|
|
||||||
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<bool, DbError>;
|
|
||||||
|
|
||||||
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError>;
|
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError>;
|
||||||
|
|
||||||
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError>;
|
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError>;
|
||||||
|
|
||||||
|
async fn ensure_blob_ownership(&self, user_id: Uuid, cid: &CidLink) -> Result<bool, DbError>;
|
||||||
|
|
||||||
async fn insert_record_blobs(
|
async fn insert_record_blobs(
|
||||||
&self,
|
&self,
|
||||||
repo_id: Uuid,
|
repo_id: Uuid,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tranquil_types::{CidLink, Did, Handle, InviteCode};
|
use tranquil_types::{Did, Handle, InviteCode};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::DbError;
|
use crate::DbError;
|
||||||
@@ -44,60 +44,7 @@ impl InviteCodeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
pub use tranquil_types::{CommsChannel, Recipient};
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
#[sqlx(type_name = "comms_channel", rename_all = "snake_case")]
|
|
||||||
pub enum CommsChannel {
|
|
||||||
Email,
|
|
||||||
Discord,
|
|
||||||
Telegram,
|
|
||||||
Signal,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommsChannel {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Email => "email",
|
|
||||||
Self::Discord => "discord",
|
|
||||||
Self::Telegram => "telegram",
|
|
||||||
Self::Signal => "signal",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn display_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Email => "email",
|
|
||||||
Self::Discord => "Discord",
|
|
||||||
Self::Telegram => "Telegram",
|
|
||||||
Self::Signal => "Signal",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::str::FromStr for CommsChannel {
|
|
||||||
type Err = InvalidCommsChannel;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s {
|
|
||||||
"email" => Ok(Self::Email),
|
|
||||||
"discord" => Ok(Self::Discord),
|
|
||||||
"telegram" => Ok(Self::Telegram),
|
|
||||||
"signal" => Ok(Self::Signal),
|
|
||||||
_ => Err(InvalidCommsChannel),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InvalidCommsChannel;
|
|
||||||
|
|
||||||
impl std::fmt::Display for InvalidCommsChannel {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str("invalid comms channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for InvalidCommsChannel {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||||
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
|
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
|
||||||
@@ -226,9 +173,8 @@ pub trait InfraRepository: Send + Sync {
|
|||||||
async fn enqueue_comms(
|
async fn enqueue_comms(
|
||||||
&self,
|
&self,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
channel: CommsChannel,
|
recipient: &Recipient,
|
||||||
comms_type: CommsType,
|
comms_type: CommsType,
|
||||||
recipient: &str,
|
|
||||||
subject: Option<&str>,
|
subject: Option<&str>,
|
||||||
body: &str,
|
body: &str,
|
||||||
metadata: Option<serde_json::Value>,
|
metadata: Option<serde_json::Value>,
|
||||||
@@ -417,10 +363,6 @@ pub trait InfraRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn delete_server_config(&self, key: &str) -> Result<(), DbError>;
|
async fn delete_server_config(&self, key: &str) -> Result<(), DbError>;
|
||||||
|
|
||||||
async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result<Option<String>, DbError>;
|
|
||||||
|
|
||||||
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError>;
|
|
||||||
|
|
||||||
async fn get_admin_account_info_by_did(
|
async fn get_admin_account_info_by_did(
|
||||||
&self,
|
&self,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pub use infra::{
|
|||||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
|
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
|
||||||
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
|
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
|
||||||
InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo,
|
InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo,
|
||||||
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull,
|
QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull,
|
||||||
};
|
};
|
||||||
pub use invite_code::{InviteCodeError, ValidatedInviteCode};
|
pub use invite_code::{InviteCodeError, ValidatedInviteCode};
|
||||||
pub use oauth::{
|
pub use oauth::{
|
||||||
@@ -36,8 +36,8 @@ pub use repo::{
|
|||||||
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData,
|
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData,
|
||||||
EventBlockInline, EventBlocks, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
|
EventBlockInline, EventBlocks, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
|
||||||
PruneCount, RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo,
|
PruneCount, RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo,
|
||||||
RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository,
|
RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoIdentity, RepoInfo, RepoListItem,
|
||||||
RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
|
RepoRepository, RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
|
||||||
UserWithoutBlocks,
|
UserWithoutBlocks,
|
||||||
};
|
};
|
||||||
pub use scope::{DbScope, InvalidScopeError};
|
pub use scope::{DbScope, InvalidScopeError};
|
||||||
|
|||||||
@@ -171,6 +171,12 @@ pub struct UserNeedingRecordBlobsBackfill {
|
|||||||
pub did: Did,
|
pub did: Did,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RepoIdentity {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub did: Did,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RepoSeqEvent {
|
pub struct RepoSeqEvent {
|
||||||
pub seq: SequenceNumber,
|
pub seq: SequenceNumber,
|
||||||
@@ -545,6 +551,8 @@ pub trait RepoRepository: Send + Sync {
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<UserNeedingRecordBlobsBackfill>, DbError>;
|
) -> Result<Vec<UserNeedingRecordBlobsBackfill>, DbError>;
|
||||||
|
|
||||||
|
async fn get_all_repo_identities(&self) -> Result<Vec<RepoIdentity>, DbError>;
|
||||||
|
|
||||||
async fn insert_record_blobs(
|
async fn insert_record_blobs(
|
||||||
&self,
|
&self,
|
||||||
repo_id: Uuid,
|
repo_id: Uuid,
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ pub trait UserRepository: Send + Sync {
|
|||||||
channel: CommsChannel,
|
channel: CommsChannel,
|
||||||
) -> Result<Option<bool>, DbError>;
|
) -> Result<Option<bool>, DbError>;
|
||||||
|
|
||||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>;
|
async fn admin_update_email(
|
||||||
|
&self,
|
||||||
|
did: &Did,
|
||||||
|
email: &tranquil_types::EmailAddress,
|
||||||
|
) -> Result<u64, DbError>;
|
||||||
|
|
||||||
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
|
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
|
||||||
|
|
||||||
@@ -266,13 +270,11 @@ pub trait UserRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn store_telegram_chat_id(
|
async fn store_telegram_chat_id(
|
||||||
&self,
|
&self,
|
||||||
telegram_username: &str,
|
telegram_username: &tranquil_types::TelegramUsername,
|
||||||
chat_id: i64,
|
chat_id: i64,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<Option<Uuid>, DbError>;
|
) -> Result<Option<Uuid>, DbError>;
|
||||||
|
|
||||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
|
|
||||||
|
|
||||||
async fn set_unverified_discord(
|
async fn set_unverified_discord(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -281,8 +283,8 @@ pub trait UserRepository: Send + Sync {
|
|||||||
|
|
||||||
async fn store_discord_user_id(
|
async fn store_discord_user_id(
|
||||||
&self,
|
&self,
|
||||||
discord_username: &str,
|
discord_username: &tranquil_types::DiscordUsername,
|
||||||
discord_id: &str,
|
discord_id: &tranquil_types::DiscordUserId,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<Option<Uuid>, DbError>;
|
) -> Result<Option<Uuid>, DbError>;
|
||||||
|
|
||||||
@@ -911,6 +913,25 @@ pub struct UserResendVerification {
|
|||||||
pub channel_verification: ChannelVerificationStatus,
|
pub channel_verification: ChannelVerificationStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
macro_rules! channel_identifier {
|
||||||
|
($name:ty) => {
|
||||||
|
impl $name {
|
||||||
|
pub fn channel_identifier(&self) -> Option<&str> {
|
||||||
|
match self.channel {
|
||||||
|
CommsChannel::Email => self.email.as_deref(),
|
||||||
|
CommsChannel::Discord => self.discord_username.as_deref(),
|
||||||
|
CommsChannel::Telegram => self.telegram_username.as_deref(),
|
||||||
|
CommsChannel::Signal => self.signal_username.as_deref(),
|
||||||
|
}
|
||||||
|
.filter(|identifier| !identifier.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
channel_identifier!(UserConfirmSignup);
|
||||||
|
channel_identifier!(UserResendVerification);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserResetCodeInfo {
|
pub struct UserResetCodeInfo {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
let result = sqlx::query_scalar!(
|
let result = sqlx::query_scalar!(
|
||||||
r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)
|
r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (cid) DO NOTHING RETURNING cid"#,
|
ON CONFLICT (cid, created_by_user) DO NOTHING RETURNING cid"#,
|
||||||
cid.as_str(),
|
cid.as_str(),
|
||||||
mime_type,
|
mime_type,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
@@ -49,7 +49,7 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
|
|
||||||
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
|
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1",
|
"SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1 LIMIT 1",
|
||||||
cid.as_str()
|
cid.as_str()
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -68,7 +68,7 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
cid: &CidLink,
|
cid: &CidLink,
|
||||||
) -> Result<Option<BlobWithTakedown>, DbError> {
|
) -> Result<Option<BlobWithTakedown>, DbError> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
|
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1 ORDER BY takedown_ref NULLS LAST LIMIT 1",
|
||||||
cid.as_str()
|
cid.as_str()
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -86,11 +86,13 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
|
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
|
||||||
let result =
|
let result = sqlx::query_scalar!(
|
||||||
sqlx::query_scalar!("SELECT storage_key FROM blobs WHERE cid = $1", cid.as_str())
|
"SELECT storage_key FROM blobs WHERE cid = $1 LIMIT 1",
|
||||||
.fetch_optional(&self.pool)
|
cid.as_str()
|
||||||
.await
|
)
|
||||||
.map_err(map_sqlx_error)?;
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -147,7 +149,8 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
|
|
||||||
async fn sum_blob_storage(&self) -> Result<i64, DbError> {
|
async fn sum_blob_storage(&self) -> Result<i64, DbError> {
|
||||||
let result = sqlx::query_scalar!(
|
let result = sqlx::query_scalar!(
|
||||||
r#"SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as "total!" FROM blobs"#
|
r#"SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as "total!"
|
||||||
|
FROM (SELECT DISTINCT cid, size_bytes FROM blobs) t"#
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -173,15 +176,6 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<bool, DbError> {
|
|
||||||
let result = sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(result.rows_affected() > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError> {
|
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError> {
|
||||||
let result = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
let result = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -193,7 +187,12 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
|
|
||||||
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError> {
|
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError> {
|
||||||
let results = sqlx::query_scalar!(
|
let results = sqlx::query_scalar!(
|
||||||
r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
|
r#"SELECT storage_key as "storage_key!" FROM blobs b
|
||||||
|
WHERE created_by_user = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM blobs o
|
||||||
|
WHERE o.cid = b.cid AND o.created_by_user <> $1
|
||||||
|
)"#,
|
||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -203,6 +202,22 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_blob_ownership(&self, user_id: Uuid, cid: &CidLink) -> Result<bool, DbError> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)
|
||||||
|
SELECT DISTINCT b.cid, b.mime_type, b.size_bytes, $1::uuid, b.storage_key
|
||||||
|
FROM blobs b WHERE b.cid = $2
|
||||||
|
ON CONFLICT (cid, created_by_user) DO NOTHING"#,
|
||||||
|
user_id,
|
||||||
|
cid.as_str()
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
async fn insert_record_blobs(
|
async fn insert_record_blobs(
|
||||||
&self,
|
&self,
|
||||||
repo_id: Uuid,
|
repo_id: Uuid,
|
||||||
@@ -238,7 +253,7 @@ impl BlobRepository for PostgresBlobRepository {
|
|||||||
let results = sqlx::query!(
|
let results = sqlx::query!(
|
||||||
r#"SELECT rb.blob_cid, rb.record_uri
|
r#"SELECT rb.blob_cid, rb.record_uri
|
||||||
FROM record_blobs rb
|
FROM record_blobs rb
|
||||||
LEFT JOIN blobs b ON rb.blob_cid = b.cid
|
LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = $1
|
||||||
WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2
|
WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2
|
||||||
ORDER BY rb.blob_cid
|
ORDER BY rb.blob_cid
|
||||||
LIMIT $3"#,
|
LIMIT $3"#,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ use tranquil_db_traits::{
|
|||||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
|
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
|
||||||
DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow,
|
DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow,
|
||||||
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
|
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
|
||||||
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
|
QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
|
||||||
};
|
};
|
||||||
use tranquil_types::{CidLink, Did, InviteCode};
|
use tranquil_types::{Did, InviteCode};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::col;
|
use super::col;
|
||||||
@@ -29,9 +29,8 @@ impl InfraRepository for PostgresInfraRepository {
|
|||||||
async fn enqueue_comms(
|
async fn enqueue_comms(
|
||||||
&self,
|
&self,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
channel: CommsChannel,
|
recipient: &Recipient,
|
||||||
comms_type: CommsType,
|
comms_type: CommsType,
|
||||||
recipient: &str,
|
|
||||||
subject: Option<&str>,
|
subject: Option<&str>,
|
||||||
body: &str,
|
body: &str,
|
||||||
metadata: Option<serde_json::Value>,
|
metadata: Option<serde_json::Value>,
|
||||||
@@ -42,9 +41,9 @@ impl InfraRepository for PostgresInfraRepository {
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
user_id,
|
user_id,
|
||||||
channel as CommsChannel,
|
recipient.channel() as CommsChannel,
|
||||||
comms_type as CommsType,
|
comms_type as CommsType,
|
||||||
recipient,
|
recipient.as_str(),
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
metadata
|
metadata
|
||||||
@@ -1010,25 +1009,6 @@ impl InfraRepository for PostgresInfraRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
|
|
||||||
let result =
|
|
||||||
sqlx::query_scalar!("SELECT storage_key FROM blobs WHERE cid = $1", cid.as_str())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError> {
|
|
||||||
sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_admin_account_info_by_did(
|
async fn get_admin_account_info_by_did(
|
||||||
&self,
|
&self,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ use sqlx::PgPool;
|
|||||||
use tranquil_db_traits::{
|
use tranquil_db_traits::{
|
||||||
AccountStatus, CommitEventData, DbError, EventBlockInline, EventBlocks, FullRecordInfo,
|
AccountStatus, CommitEventData, DbError, EventBlockInline, EventBlocks, FullRecordInfo,
|
||||||
ImportBlock, ImportRecord, ImportRepoError, PruneCount, RecordInfo, RecordWithTakedown,
|
ImportBlock, ImportRecord, ImportRepoError, PruneCount, RecordInfo, RecordWithTakedown,
|
||||||
RepoAccountInfo, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev,
|
RepoAccountInfo, RepoEventType, RepoIdentity, RepoInfo, RepoListItem, RepoRepository,
|
||||||
SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
|
RepoWithoutRev, SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill,
|
||||||
|
UserWithoutBlocks,
|
||||||
};
|
};
|
||||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
|
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1650,6 +1651,28 @@ impl RepoRepository for PostgresRepoRepository {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_all_repo_identities(&self) -> Result<Vec<RepoIdentity>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT u.id as user_id, u.did
|
||||||
|
FROM users u
|
||||||
|
JOIN repos r ON r.user_id = u.id
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
Ok(RepoIdentity {
|
||||||
|
user_id: r.user_id,
|
||||||
|
did: column(r.did, col::USERS_DID)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn insert_record_blobs(
|
async fn insert_record_blobs(
|
||||||
&self,
|
&self,
|
||||||
repo_id: Uuid,
|
repo_id: Uuid,
|
||||||
|
|||||||
@@ -660,10 +660,14 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
|
async fn admin_update_email(
|
||||||
|
&self,
|
||||||
|
did: &Did,
|
||||||
|
email: &tranquil_types::EmailAddress,
|
||||||
|
) -> Result<u64, DbError> {
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"UPDATE users SET email = $1 WHERE did = $2",
|
"UPDATE users SET email = $1 WHERE did = $2",
|
||||||
email,
|
email.as_str(),
|
||||||
did.as_str()
|
did.as_str()
|
||||||
)
|
)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -3300,10 +3304,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn store_discord_user_id(
|
async fn store_discord_user_id(
|
||||||
&self,
|
&self,
|
||||||
discord_username: &str,
|
discord_username: &tranquil_types::DiscordUsername,
|
||||||
discord_id: &str,
|
discord_id: &tranquil_types::DiscordUserId,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<Option<Uuid>, DbError> {
|
) -> Result<Option<Uuid>, DbError> {
|
||||||
|
let (discord_username, discord_id) = (discord_username.as_str(), discord_id.as_str());
|
||||||
let result = match handle {
|
let result = match handle {
|
||||||
Some(h) => sqlx::query_scalar!(
|
Some(h) => sqlx::query_scalar!(
|
||||||
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id",
|
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id",
|
||||||
@@ -3362,10 +3367,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn store_telegram_chat_id(
|
async fn store_telegram_chat_id(
|
||||||
&self,
|
&self,
|
||||||
telegram_username: &str,
|
telegram_username: &tranquil_types::TelegramUsername,
|
||||||
chat_id: i64,
|
chat_id: i64,
|
||||||
handle: Option<&Handle>,
|
handle: Option<&Handle>,
|
||||||
) -> Result<Option<Uuid>, DbError> {
|
) -> Result<Option<Uuid>, DbError> {
|
||||||
|
let telegram_username = telegram_username.as_str();
|
||||||
let result = match handle {
|
let result = match handle {
|
||||||
Some(h) => sqlx::query_scalar!(
|
Some(h) => sqlx::query_scalar!(
|
||||||
"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
|
"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
|
||||||
@@ -3393,14 +3399,6 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError> {
|
|
||||||
let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(map_sqlx_error)?;
|
|
||||||
Ok(row.flatten())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_password_reset_info(
|
async fn get_password_reset_info(
|
||||||
&self,
|
&self,
|
||||||
email: &str,
|
email: &str,
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
testing = []
|
||||||
|
cache-keys = ["dep:tranquil-types"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
tranquil-types = { workspace = true, optional = true }
|
||||||
|
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use tranquil_types::{
|
||||||
|
CidLink, ClientId, CrossPdsState, Did, EmailTokenPurpose, Handle, Jti, JwksUri, Nsid, PdsUrl,
|
||||||
|
SsoIssuer, SsoJwksUri,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn session_key(did: &Did, jti: &Jti) -> String {
|
||||||
|
format!("auth:session:{}:{}", did, jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn signing_key_key(did: &Did) -> String {
|
||||||
|
format!("auth:key:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_status_key(did: &Did) -> String {
|
||||||
|
format!("auth:status:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(handle: &Handle) -> String {
|
||||||
|
format!("handle:{}", handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reauth_key(did: &Did) -> String {
|
||||||
|
format!("reauth:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plc_doc_key(did: &Did) -> String {
|
||||||
|
format!("plc:doc:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plc_data_key(did: &Did) -> String {
|
||||||
|
format!("plc:data:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn did_web_doc_key(did: &Did) -> String {
|
||||||
|
format!("did:web:doc:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn email_update_key(did: &Did) -> String {
|
||||||
|
format!("email_update:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn email_token_key(did: &Did, purpose: EmailTokenPurpose) -> String {
|
||||||
|
format!("email_token:{}:{}", purpose, did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legacy_2fa_challenge_key(did: &Did) -> String {
|
||||||
|
format!("legacy_2fa:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legacy_2fa_cooldown_key(did: &Did) -> String {
|
||||||
|
format!("legacy_2fa_cooldown:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scope_ref_key(cid: &CidLink) -> String {
|
||||||
|
format!("scope_ref:{}", cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auto_verify_sent_key(did: &Did) -> String {
|
||||||
|
format!("auto_verify_sent:{}", did)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn permission_set_key(nsid: &Nsid, aud: Option<&str>) -> String {
|
||||||
|
match aud {
|
||||||
|
Some(a) => format!("permset:{}:{}", nsid, a),
|
||||||
|
None => format!("permset:{}", nsid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oauth_client_meta_key(client_id: &ClientId) -> String {
|
||||||
|
format!("oauth:client_meta:{}", client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oauth_client_jwks_key(jwks_uri: &JwksUri) -> String {
|
||||||
|
format!("oauth:jwks:{}", jwks_uri.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oauth_client_jwks_cooldown_key(jwks_uri: &JwksUri) -> String {
|
||||||
|
format!("oauth:jwks_cooldown:{}", jwks_uri.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sso_jwks_key(jwks_uri: &SsoJwksUri) -> String {
|
||||||
|
format!("sso:jwks:{}", jwks_uri.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn oidc_discovery_key(issuer: &SsoIssuer) -> String {
|
||||||
|
format!("oidc:discovery:{}", issuer.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cross_pds_state_key(state: &CrossPdsState) -> String {
|
||||||
|
format!("cross_pds_state:{}", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cross_pds_oauth_meta_key(pds_url: &PdsUrl) -> String {
|
||||||
|
format!("cross_pds_oauth_meta:v2:{}", pds_url.canonical())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lexicon_doc_key(nsid: &Nsid) -> String {
|
||||||
|
format!("lexicon:doc:{}", nsid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lexicon_negative_key(nsid: &Nsid) -> String {
|
||||||
|
format!("lexicon:neg:{}", nsid)
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
#[cfg(feature = "cache-keys")]
|
||||||
|
pub mod cache_keys;
|
||||||
|
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
mod memory_cache;
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
pub use memory_cache::MemoryCache;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -57,6 +66,42 @@ pub trait Cache: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn read_json<T: serde::de::DeserializeOwned>(cache: &dyn Cache, key: &str) -> Option<T> {
|
||||||
|
let json = cache.get(key).await?;
|
||||||
|
serde_json::from_str(&json).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_json<T: serde::Serialize>(
|
||||||
|
cache: &dyn Cache,
|
||||||
|
key: &str,
|
||||||
|
value: &T,
|
||||||
|
ttl: Duration,
|
||||||
|
) {
|
||||||
|
if let Ok(json) = serde_json::to_string(value) {
|
||||||
|
let _ = cache.set(key, &json, ttl).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cached_json<T, E, Fut>(
|
||||||
|
cache: &dyn Cache,
|
||||||
|
key: &str,
|
||||||
|
ttl: Duration,
|
||||||
|
fetch: impl FnOnce() -> Fut,
|
||||||
|
) -> Result<T, E>
|
||||||
|
where
|
||||||
|
T: serde::Serialize + serde::de::DeserializeOwned,
|
||||||
|
Fut: Future<Output = Result<T, E>>,
|
||||||
|
{
|
||||||
|
match read_json(cache, key).await {
|
||||||
|
Some(value) => Ok(value),
|
||||||
|
None => {
|
||||||
|
let value = fetch().await?;
|
||||||
|
write_json(cache, key, &value, ttl).await;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait DistributedRateLimiter: Send + Sync {
|
pub trait DistributedRateLimiter: Send + Sync {
|
||||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
|
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use crate::{Cache, CacheError};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
value: Vec<u8>,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MemoryCache {
|
||||||
|
entries: Mutex<HashMap<String, Entry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, key: &str) -> Option<Vec<u8>> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match entries.get(key) {
|
||||||
|
Some(entry) if entry.expires_at > now => Some(entry.value.clone()),
|
||||||
|
Some(_) => {
|
||||||
|
entries.remove(key);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, key: &str, value: Vec<u8>, ttl: Duration) {
|
||||||
|
let entry = Entry {
|
||||||
|
value,
|
||||||
|
expires_at: Instant::now() + ttl,
|
||||||
|
};
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(key.to_string(), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Cache for MemoryCache {
|
||||||
|
async fn get(&self, key: &str) -> Option<String> {
|
||||||
|
self.read(key).and_then(|v| String::from_utf8(v).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||||
|
self.write(key, value.as_bytes().to_vec(), ttl);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
|
||||||
|
self.read(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
|
||||||
|
self.write(key, value.to_vec(), ttl);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
|
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:tranquil-infra"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tranquil-types = { path = "../tranquil-types", default-features = false }
|
tranquil-types = { workspace = true }
|
||||||
|
tranquil-infra = { workspace = true, optional = true, features = ["cache-keys"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
@@ -19,9 +20,9 @@ hickory-resolver = { workspace = true, optional = true }
|
|||||||
tokio = { workspace = true, optional = true }
|
tokio = { workspace = true, optional = true }
|
||||||
parking_lot = { workspace = true, optional = true }
|
parking_lot = { workspace = true, optional = true }
|
||||||
tracing = { workspace = true, optional = true }
|
tracing = { workspace = true, optional = true }
|
||||||
urlencoding = { workspace = true, optional = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
tranquil-infra = { workspace = true, features = ["testing", "cache-keys"] }
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ 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;
|
use tokio::sync::Notify;
|
||||||
|
use tranquil_infra::cache_keys::{lexicon_doc_key, lexicon_negative_key};
|
||||||
|
use tranquil_infra::{Cache, read_json, write_json};
|
||||||
use tranquil_types::Nsid;
|
use tranquil_types::Nsid;
|
||||||
|
|
||||||
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
|
||||||
const POSITIVE_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 REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
|
||||||
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
|
||||||
@@ -17,6 +19,13 @@ struct NegativeEntry {
|
|||||||
expires_at: Instant,
|
expires_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn negative_ttl_for(error: &ResolveError) -> Duration {
|
||||||
|
match error.is_definitive() {
|
||||||
|
true => NEGATIVE_CACHE_TTL,
|
||||||
|
false => REFRESH_FAILURE_BACKOFF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct PositiveEntry {
|
struct PositiveEntry {
|
||||||
doc: Arc<LexiconDoc>,
|
doc: Arc<LexiconDoc>,
|
||||||
expires_at: Instant,
|
expires_at: Instant,
|
||||||
@@ -44,6 +53,7 @@ pub struct DynamicRegistry {
|
|||||||
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
|
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
|
||||||
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
|
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
|
||||||
network_disabled: AtomicBool,
|
network_disabled: AtomicBool,
|
||||||
|
shared: RwLock<Option<Arc<dyn Cache>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InFlightGuard<'a> {
|
struct InFlightGuard<'a> {
|
||||||
@@ -70,9 +80,18 @@ impl DynamicRegistry {
|
|||||||
negative_cache: RwLock::new(HashMap::new()),
|
negative_cache: RwLock::new(HashMap::new()),
|
||||||
in_flight: RwLock::new(HashMap::new()),
|
in_flight: RwLock::new(HashMap::new()),
|
||||||
network_disabled: AtomicBool::new(false),
|
network_disabled: AtomicBool::new(false),
|
||||||
|
shared: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_shared_cache(&self, cache: Arc<dyn Cache>) {
|
||||||
|
*self.shared.write() = Some(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_cache(&self) -> Option<Arc<dyn Cache>> {
|
||||||
|
self.shared.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
let registry = Self::new();
|
let registry = Self::new();
|
||||||
let disabled =
|
let disabled =
|
||||||
@@ -105,13 +124,17 @@ impl DynamicRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
||||||
let cache = self.negative_cache.read();
|
self.negative_remaining(nsid).is_some()
|
||||||
cache
|
|
||||||
.get(nsid)
|
|
||||||
.is_some_and(|entry| entry.expires_at > Instant::now())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_negative(&self, nsid: &Nsid) {
|
fn negative_remaining(&self, nsid: &Nsid) -> Option<Duration> {
|
||||||
|
self.negative_cache
|
||||||
|
.read()
|
||||||
|
.get(nsid)
|
||||||
|
.and_then(|entry| entry.expires_at.checked_duration_since(Instant::now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_negative(&self, nsid: &Nsid, ttl: Duration) {
|
||||||
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();
|
||||||
@@ -120,7 +143,7 @@ impl DynamicRegistry {
|
|||||||
cache.insert(
|
cache.insert(
|
||||||
nsid.clone(),
|
nsid.clone(),
|
||||||
NegativeEntry {
|
NegativeEntry {
|
||||||
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
|
expires_at: Instant::now() + ttl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +182,44 @@ impl DynamicRegistry {
|
|||||||
arc
|
arc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shared_get(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
|
||||||
|
let cache = self.shared_cache()?;
|
||||||
|
let doc = read_json::<LexiconDoc>(cache.as_ref(), &lexicon_doc_key(nsid)).await?;
|
||||||
|
Some(self.insert_schema(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shared_put(&self, doc: &LexiconDoc) {
|
||||||
|
let Some(cache) = self.shared_cache() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
write_json(
|
||||||
|
cache.as_ref(),
|
||||||
|
&lexicon_doc_key(&doc.id),
|
||||||
|
doc,
|
||||||
|
POSITIVE_CACHE_TTL,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = cache.delete(&lexicon_negative_key(&doc.id)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shared_is_negative(&self, nsid: &Nsid) -> bool {
|
||||||
|
match self.shared_cache() {
|
||||||
|
Some(cache) => cache.get(&lexicon_negative_key(nsid)).await.is_some(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shared_put_negative(&self, nsid: &Nsid, error: &ResolveError) {
|
||||||
|
if !error.is_definitive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(cache) = self.shared_cache() {
|
||||||
|
let _ = cache
|
||||||
|
.set(&lexicon_negative_key(nsid), "1", NEGATIVE_CACHE_TTL)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn bump_expiry(&self, nsid: &Nsid, duration: Duration) {
|
fn bump_expiry(&self, nsid: &Nsid, duration: Duration) {
|
||||||
let mut store = self.store.write();
|
let mut store = self.store.write();
|
||||||
if let Some(entry) = store.schemas.get_mut(nsid) {
|
if let Some(entry) = store.schemas.get_mut(nsid) {
|
||||||
@@ -203,15 +264,23 @@ impl DynamicRegistry {
|
|||||||
|
|
||||||
match self.acquire_leadership(nsid) {
|
match self.acquire_leadership(nsid) {
|
||||||
Some(_guard) => match resolver(nsid.clone()).await {
|
Some(_guard) => match resolver(nsid.clone()).await {
|
||||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
Ok(doc) => {
|
||||||
|
self.shared_put(&doc).await;
|
||||||
|
Ok(self.insert_schema(doc))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
let (doc, source) = match self.shared_get(nsid).await {
|
||||||
|
Some(doc) => (doc, "shared"),
|
||||||
|
None => (stale, "local"),
|
||||||
|
};
|
||||||
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
|
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
nsid = %nsid,
|
nsid = %nsid,
|
||||||
error = %e,
|
error = %e,
|
||||||
"lexicon refresh failed, serving stale cached entry"
|
source,
|
||||||
|
"lexicon refresh failed, serving cached entry"
|
||||||
);
|
);
|
||||||
Ok(stale)
|
Ok(doc)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
@@ -230,34 +299,59 @@ impl DynamicRegistry {
|
|||||||
F: FnOnce(Nsid) -> Fut,
|
F: FnOnce(Nsid) -> Fut,
|
||||||
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
|
||||||
{
|
{
|
||||||
if self.network_disabled.load(Ordering::Relaxed) {
|
if let Some(doc) = self.shared_get(nsid).await {
|
||||||
return Err(ResolveError::NetworkDisabled);
|
return Ok(doc);
|
||||||
}
|
}
|
||||||
if self.is_negative_cached(nsid) {
|
|
||||||
|
if let Some(remaining) = self.negative_remaining(nsid) {
|
||||||
return Err(ResolveError::NegativelyCached {
|
return Err(ResolveError::NegativelyCached {
|
||||||
nsid: nsid.clone(),
|
nsid: nsid.clone(),
|
||||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
ttl_secs: remaining.as_secs(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.shared_is_negative(nsid).await {
|
||||||
|
// Cache reports 0 remaining TTL for shared negative hit,
|
||||||
|
// so we mirror for the backoff rather than a full `NEGATIVE_CACHE_TTL`.
|
||||||
|
self.insert_negative(nsid, REFRESH_FAILURE_BACKOFF);
|
||||||
|
return Err(ResolveError::NegativelyCached {
|
||||||
|
nsid: nsid.clone(),
|
||||||
|
ttl_secs: REFRESH_FAILURE_BACKOFF.as_secs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.network_disabled.load(Ordering::Relaxed) {
|
||||||
|
return Err(ResolveError::NetworkDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
match self.acquire_leadership(nsid) {
|
match self.acquire_leadership(nsid) {
|
||||||
Some(_guard) => match resolver(nsid.clone()).await {
|
Some(_guard) => match resolver(nsid.clone()).await {
|
||||||
Ok(doc) => Ok(self.insert_schema(doc)),
|
Ok(doc) => {
|
||||||
|
self.shared_put(&doc).await;
|
||||||
|
Ok(self.insert_schema(doc))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.insert_negative(nsid);
|
let ttl = negative_ttl_for(&e);
|
||||||
tracing::debug!(nsid = %nsid, error = %e, "caching negative resolution result");
|
self.insert_negative(nsid, ttl);
|
||||||
|
self.shared_put_negative(nsid, &e).await;
|
||||||
|
tracing::debug!(
|
||||||
|
nsid = %nsid,
|
||||||
|
error = %e,
|
||||||
|
ttl_secs = ttl.as_secs(),
|
||||||
|
"caching negative resolution result"
|
||||||
|
);
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
self.wait_for_leader(nsid).await;
|
self.wait_for_leader(nsid).await;
|
||||||
match self.get_cached(nsid) {
|
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
|
||||||
Some(doc) => Ok(doc),
|
(Some(doc), _) => Ok(doc),
|
||||||
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
|
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
|
||||||
nsid: nsid.clone(),
|
nsid: nsid.clone(),
|
||||||
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
|
ttl_secs: remaining.as_secs(),
|
||||||
}),
|
}),
|
||||||
None => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
|
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +410,7 @@ impl Default for DynamicRegistry {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tranquil_infra::MemoryCache;
|
||||||
|
|
||||||
fn nsid(s: &str) -> Nsid {
|
fn nsid(s: &str) -> Nsid {
|
||||||
s.parse().unwrap()
|
s.parse().unwrap()
|
||||||
@@ -324,19 +419,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_negative_cache() {
|
fn test_negative_cache() {
|
||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
|
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
|
||||||
|
|
||||||
registry.insert_negative(&nsid("com.example.test"));
|
registry.insert_negative(&nsid("pet.nel.negative"), NEGATIVE_CACHE_TTL);
|
||||||
assert!(registry.is_negative_cached(&nsid("com.example.test")));
|
assert!(registry.is_negative_cached(&nsid("pet.nel.negative")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_negative_cache_returns_appropriate_error_variant() {
|
async fn test_negative_cache_returns_appropriate_error_variant() {
|
||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
registry.insert_negative(&nsid("com.example.cached"));
|
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
|
||||||
|
|
||||||
let err = registry
|
let err = registry
|
||||||
.resolve_and_cache(&nsid("com.example.cached"))
|
.resolve_and_cache(&nsid("pet.nel.cached"))
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
@@ -383,17 +478,17 @@ mod tests {
|
|||||||
fn test_negative_cache_cleared_on_insert() {
|
fn test_negative_cache_cleared_on_insert() {
|
||||||
let registry = DynamicRegistry::new();
|
let registry = DynamicRegistry::new();
|
||||||
|
|
||||||
registry.insert_negative(&nsid("com.example.test"));
|
registry.insert_negative(&nsid("pet.nel.cleared"), NEGATIVE_CACHE_TTL);
|
||||||
assert!(registry.is_negative_cached(&nsid("com.example.test")));
|
assert!(registry.is_negative_cached(&nsid("pet.nel.cleared")));
|
||||||
|
|
||||||
let doc = LexiconDoc {
|
let doc = LexiconDoc {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: nsid("com.example.test"),
|
id: nsid("pet.nel.cleared"),
|
||||||
defs: HashMap::new(),
|
defs: HashMap::new(),
|
||||||
};
|
};
|
||||||
registry.insert_schema(doc);
|
registry.insert_schema(doc);
|
||||||
|
|
||||||
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
|
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -692,4 +787,95 @@ mod tests {
|
|||||||
"evicted Arc should be freed when no external references remain"
|
"evicted Arc should be freed when no external references remain"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_shared_positive_hit_skips_resolver() {
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
let cache = Arc::new(MemoryCache::new());
|
||||||
|
registry.set_shared_cache(cache.clone());
|
||||||
|
let doc = LexiconDoc {
|
||||||
|
lexicon: 1,
|
||||||
|
id: nsid("pet.nel.sharedDoc"),
|
||||||
|
defs: HashMap::new(),
|
||||||
|
};
|
||||||
|
cache
|
||||||
|
.set(
|
||||||
|
&lexicon_doc_key(&nsid("pet.nel.sharedDoc")),
|
||||||
|
&serde_json::to_string(&doc).unwrap(),
|
||||||
|
POSITIVE_CACHE_TTL,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resolved = registry
|
||||||
|
.resolve_and_cache_with(&nsid("pet.nel.sharedDoc"), |_| async move {
|
||||||
|
panic!("resolver mustn't run on a shared positive hit")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resolved.id, "pet.nel.sharedDoc");
|
||||||
|
assert!(registry.get_cached(&nsid("pet.nel.sharedDoc")).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_definitive_failure_writes_shared_negative_and_peers_mirror_it() {
|
||||||
|
let cache = Arc::new(MemoryCache::new());
|
||||||
|
let registry = DynamicRegistry::new();
|
||||||
|
registry.set_shared_cache(cache.clone());
|
||||||
|
|
||||||
|
let _ = registry
|
||||||
|
.resolve_and_cache_with(&nsid("pet.nel.gone"), |n| async move {
|
||||||
|
Err::<LexiconDoc, _>(ResolveError::SchemaNotFound {
|
||||||
|
nsid: n,
|
||||||
|
url: "https://oyster.cafe".to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.get(&lexicon_negative_key(&nsid("pet.nel.gone")))
|
||||||
|
.await
|
||||||
|
.is_some(),
|
||||||
|
"definitive failure must write the shared negative key"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = registry
|
||||||
|
.resolve_and_cache_with(&nsid("pet.nel.transient"), |n| async move {
|
||||||
|
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
|
||||||
|
domain: n.into_inner(),
|
||||||
|
reason: "simulated".to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.get(&lexicon_negative_key(&nsid("pet.nel.transient")))
|
||||||
|
.await
|
||||||
|
.is_none(),
|
||||||
|
"transient failure must stay out of the shared negative key"
|
||||||
|
);
|
||||||
|
|
||||||
|
let peer = DynamicRegistry::new();
|
||||||
|
peer.set_shared_cache(cache);
|
||||||
|
let err = peer
|
||||||
|
.resolve_and_cache_with(&nsid("pet.nel.gone"), |_| async move {
|
||||||
|
panic!("resolver mustn't run on a shared negative hit")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
match err {
|
||||||
|
ResolveError::NegativelyCached { ttl_secs, .. } => assert!(
|
||||||
|
ttl_secs <= REFRESH_FAILURE_BACKOFF.as_secs(),
|
||||||
|
"local mirror must use the backoff TTL, got {}s",
|
||||||
|
ttl_secs
|
||||||
|
),
|
||||||
|
other => panic!("expected NegativelyCached, got: {}", other),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
peer.negative_remaining(&nsid("pet.nel.gone"))
|
||||||
|
.expect("local mirror exists")
|
||||||
|
<= REFRESH_FAILURE_BACKOFF
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,28 @@ pub fn is_valid_datetime(s: &str) -> bool {
|
|||||||
chrono::DateTime::parse_from_rfc3339(s).is_ok()
|
chrono::DateTime::parse_from_rfc3339(s).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks the scheme only, not the character set or structure of what
|
||||||
|
/// follows. The aim is to accept at least all valid URIs; we can always
|
||||||
|
/// tighten this later. It does not parse the authority, because at-uris
|
||||||
|
/// put colons in the authority (at://did:plc:abc123/collection/rkey) and
|
||||||
|
/// any 3986 authority parser reads that as a non-numeric port and rejects
|
||||||
|
/// it.
|
||||||
pub fn is_valid_uri(s: &str) -> bool {
|
pub fn is_valid_uri(s: &str) -> bool {
|
||||||
s.split_once("://").is_some_and(|(scheme, rest)| {
|
let Some((scheme, rest)) = s.split_once(':') else {
|
||||||
!scheme.is_empty()
|
return false;
|
||||||
&& scheme
|
};
|
||||||
.chars()
|
let valid_scheme = !scheme.is_empty()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||||
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
&& scheme
|
||||||
&& !rest.is_empty()
|
.chars()
|
||||||
})
|
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_');
|
||||||
|
if !valid_scheme {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match rest.strip_prefix("//") {
|
||||||
|
Some(authority_and_path) => !authority_and_path.is_empty(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_valid_cid(s: &str) -> bool {
|
pub fn is_valid_cid(s: &str) -> bool {
|
||||||
@@ -151,6 +164,32 @@ mod tests {
|
|||||||
assert!(!is_valid_uri("https://"));
|
assert!(!is_valid_uri("https://"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_uris_without_authority() {
|
||||||
|
// RFC 3986 hier-part doesn't require "//": scheme ":" opaque-part is also a URI.
|
||||||
|
assert!(is_valid_uri("urn:isbn:9780141439518"));
|
||||||
|
assert!(is_valid_uri("mailto:user@example.com"));
|
||||||
|
assert!(is_valid_uri("mbid:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5")); //authority-less / path-rootless
|
||||||
|
assert!(is_valid_uri(
|
||||||
|
"has_an_underscore:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5"
|
||||||
|
));
|
||||||
|
assert!(is_valid_uri("urn:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_uri_without_scheme() {
|
||||||
|
assert!(!is_valid_uri(":no-scheme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_uris_dont_reject_at_uri_authority_colons() {
|
||||||
|
// at-uri authorities contain colons (did:plc:...); is_valid_uri must not
|
||||||
|
// reject them the way a strict RFC 3986 authority parser would.
|
||||||
|
assert!(is_valid_uri(
|
||||||
|
"at://did:plc:cwdkf4xxjpznceembuuspt3d/sh.tangled.repo.pull/3mtjn7zouwn22"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_valid_cids() {
|
fn test_valid_cids() {
|
||||||
assert!(is_valid_cid("bafyreiabcdef123456"));
|
assert!(is_valid_cid("bafyreiabcdef123456"));
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ impl LexiconRegistry {
|
|||||||
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
|
||||||
self.dynamic.is_negative_cached(nsid)
|
self.dynamic.is_negative_cached(nsid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "resolve")]
|
||||||
|
pub fn set_shared_cache(&self, cache: Arc<dyn tranquil_infra::Cache>) {
|
||||||
|
self.dynamic.set_shared_cache(cache);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ResolvedRef {
|
pub struct ResolvedRef {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tranquil_types::{Did, Nsid};
|
use tranquil_types::did_doc::extract_pds_endpoint;
|
||||||
|
use tranquil_types::{
|
||||||
|
Did, Nsid, SchemaHostUrl, UrlKind, dns_guard, redirect_policy, url_kind, url_reach_permits,
|
||||||
|
};
|
||||||
|
|
||||||
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
|
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ fn client() -> &'static Client {
|
|||||||
.connect_timeout(Duration::from_secs(5))
|
.connect_timeout(Duration::from_secs(5))
|
||||||
.pool_max_idle_per_host(4)
|
.pool_max_idle_per_host(4)
|
||||||
.pool_idle_timeout(Duration::from_secs(60))
|
.pool_idle_timeout(Duration::from_secs(60))
|
||||||
.redirect(reqwest::redirect::Policy::limited(3))
|
.redirect(redirect_policy(url_kind::SchemaHost::REACH_POLICY))
|
||||||
|
.dns_resolver(dns_guard(url_kind::SchemaHost::REACH_POLICY))
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build lexicon resolver HTTP client")
|
.expect("failed to build lexicon resolver HTTP client")
|
||||||
})
|
})
|
||||||
@@ -63,6 +67,8 @@ pub enum ResolveError {
|
|||||||
NoPdsEndpoint { did: Did },
|
NoPdsEndpoint { did: Did },
|
||||||
#[error("schema fetch failed from {url}: {reason}")]
|
#[error("schema fetch failed from {url}: {reason}")]
|
||||||
SchemaFetch { url: String, reason: String },
|
SchemaFetch { url: String, reason: String },
|
||||||
|
#[error("no schema record for {nsid} at {url}")]
|
||||||
|
SchemaNotFound { nsid: Nsid, url: String },
|
||||||
#[error("schema deserialization failed: {0}")]
|
#[error("schema deserialization failed: {0}")]
|
||||||
InvalidSchema(String),
|
InvalidSchema(String),
|
||||||
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
|
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
|
||||||
@@ -73,6 +79,23 @@ pub enum ResolveError {
|
|||||||
LeaderAborted { nsid: Nsid },
|
LeaderAborted { nsid: Nsid },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ResolveError {
|
||||||
|
pub fn is_definitive(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::NoDid { .. }
|
||||||
|
| Self::NoPdsEndpoint { .. }
|
||||||
|
| Self::InvalidSchema(_)
|
||||||
|
| Self::SchemaNotFound { .. } => true,
|
||||||
|
Self::DnsLookup { .. }
|
||||||
|
| Self::DidResolution { .. }
|
||||||
|
| Self::SchemaFetch { .. }
|
||||||
|
| Self::NegativelyCached { .. }
|
||||||
|
| Self::NetworkDisabled
|
||||||
|
| Self::LeaderAborted { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn nsid_to_authority(nsid: &Nsid) -> String {
|
pub fn nsid_to_authority(nsid: &Nsid) -> String {
|
||||||
let mut segments: Vec<&str> = nsid.split('.').collect();
|
let mut segments: Vec<&str> = nsid.split('.').collect();
|
||||||
segments.pop();
|
segments.pop();
|
||||||
@@ -123,7 +146,7 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError>
|
|||||||
pub async fn resolve_pds_endpoint(
|
pub async fn resolve_pds_endpoint(
|
||||||
did: &Did,
|
did: &Did,
|
||||||
plc_directory_url: Option<&str>,
|
plc_directory_url: Option<&str>,
|
||||||
) -> Result<String, ResolveError> {
|
) -> Result<SchemaHostUrl, ResolveError> {
|
||||||
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
|
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
|
||||||
|
|
||||||
let url = match did
|
let url = match did
|
||||||
@@ -131,7 +154,20 @@ pub async fn resolve_pds_endpoint(
|
|||||||
.and_then(|(_, rest)| rest.split_once(':'))
|
.and_then(|(_, rest)| rest.split_once(':'))
|
||||||
{
|
{
|
||||||
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
|
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
|
||||||
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
|
Some(("web", domain)) => {
|
||||||
|
let url = format!("https://{}/.well-known/did.json", domain);
|
||||||
|
let permitted = reqwest::Url::parse(&url)
|
||||||
|
.is_ok_and(|u| url_reach_permits(&u, url_kind::SchemaHost::REACH_POLICY));
|
||||||
|
match permitted {
|
||||||
|
true => url,
|
||||||
|
false => {
|
||||||
|
return Err(ResolveError::DidResolution {
|
||||||
|
did: did.clone(),
|
||||||
|
reason: "did:web host is outside the allowed host reach".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(ResolveError::DidResolution {
|
return Err(ResolveError::DidResolution {
|
||||||
did: did.clone(),
|
did: did.clone(),
|
||||||
@@ -162,39 +198,29 @@ pub async fn resolve_pds_endpoint(
|
|||||||
reason: e.to_string(),
|
reason: e.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
extract_pds_endpoint(&doc).ok_or_else(|| ResolveError::NoPdsEndpoint { did: did.clone() })
|
extract_pds_endpoint(&doc).map_err(|_| ResolveError::NoPdsEndpoint { did: did.clone() })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
|
fn is_record_absent(xrpc_error: &str, xrpc_message: &str) -> bool {
|
||||||
doc.get("service")
|
xrpc_error == "RecordNotFound"
|
||||||
.and_then(|s| s.as_array())
|
|| xrpc_error == "InvalidRequest" && xrpc_message.starts_with("Could not locate record")
|
||||||
.and_then(|services| {
|
|
||||||
services.iter().find_map(|svc| {
|
|
||||||
let is_pds = svc
|
|
||||||
.get("type")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.is_some_and(|t| t == "AtprotoPersonalDataServer");
|
|
||||||
is_pds
|
|
||||||
.then(|| svc.get("serviceEndpoint").and_then(|ep| ep.as_str()))?
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_schema_from_pds(
|
pub async fn fetch_schema_from_pds(
|
||||||
pds_endpoint: &str,
|
pds_endpoint: &SchemaHostUrl,
|
||||||
did: &Did,
|
did: &Did,
|
||||||
nsid: &Nsid,
|
nsid: &Nsid,
|
||||||
) -> Result<LexiconDoc, ResolveError> {
|
) -> Result<LexiconDoc, ResolveError> {
|
||||||
let url = format!(
|
let mut request_url = pds_endpoint.endpoint("xrpc/com.atproto.repo.getRecord");
|
||||||
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
|
request_url
|
||||||
pds_endpoint.trim_end_matches('/'),
|
.query_pairs_mut()
|
||||||
urlencoding::encode(did.as_str()),
|
.append_pair("repo", did.as_str())
|
||||||
urlencoding::encode(nsid.as_str())
|
.append_pair("collection", "com.atproto.lexicon.schema")
|
||||||
);
|
.append_pair("rkey", nsid.as_str());
|
||||||
|
let url = request_url.to_string();
|
||||||
|
|
||||||
let resp = client()
|
let resp = client()
|
||||||
.get(&url)
|
.get(request_url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ResolveError::SchemaFetch {
|
.map_err(|e| ResolveError::SchemaFetch {
|
||||||
@@ -204,10 +230,27 @@ pub async fn fetch_schema_from_pds(
|
|||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(ResolveError::SchemaFetch {
|
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||||
url,
|
.await
|
||||||
reason: format!("HTTP {}", status),
|
.ok()
|
||||||
});
|
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
let field = |name: &str| {
|
||||||
|
body.get(name)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
return match is_record_absent(&field("error"), &field("message")) {
|
||||||
|
true => Err(ResolveError::SchemaNotFound {
|
||||||
|
nsid: nsid.clone(),
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
false => Err(ResolveError::SchemaFetch {
|
||||||
|
url,
|
||||||
|
reason: format!("HTTP {}", status),
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
|
||||||
@@ -292,6 +335,27 @@ mod tests {
|
|||||||
s.parse().unwrap()
|
s.parse().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_record_absent_recognizes_only_the_reference_pds_absence_shapes() {
|
||||||
|
assert!(is_record_absent(
|
||||||
|
"RecordNotFound",
|
||||||
|
"Could not locate record: at://did:plc:nel/com.atproto.lexicon.schema/x"
|
||||||
|
));
|
||||||
|
assert!(is_record_absent("RecordNotFound", ""));
|
||||||
|
assert!(is_record_absent(
|
||||||
|
"InvalidRequest",
|
||||||
|
"Could not locate record"
|
||||||
|
));
|
||||||
|
assert!(!is_record_absent(
|
||||||
|
"InvalidRequest",
|
||||||
|
"Error: rkey must be a valid record key"
|
||||||
|
));
|
||||||
|
assert!(!is_record_absent("InvalidRequest", ""));
|
||||||
|
assert!(!is_record_absent("InternalServerError", ""));
|
||||||
|
assert!(!is_record_absent("RateLimitExceeded", ""));
|
||||||
|
assert!(!is_record_absent("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_nsid_to_authority() {
|
fn test_nsid_to_authority() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -316,57 +380,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_pds_endpoint_valid() {
|
|
||||||
let doc = serde_json::json!({
|
|
||||||
"service": [{
|
|
||||||
"type": "AtprotoPersonalDataServer",
|
|
||||||
"serviceEndpoint": "https://pds.example.com"
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
extract_pds_endpoint(&doc),
|
|
||||||
Some("https://pds.example.com".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_pds_endpoint_multiple_services() {
|
|
||||||
let doc = serde_json::json!({
|
|
||||||
"service": [
|
|
||||||
{
|
|
||||||
"type": "AtprotoLabeler",
|
|
||||||
"serviceEndpoint": "https://labeler.example.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "AtprotoPersonalDataServer",
|
|
||||||
"serviceEndpoint": "https://pds.example.com"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
extract_pds_endpoint(&doc),
|
|
||||||
Some("https://pds.example.com".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_pds_endpoint_missing() {
|
|
||||||
let doc = serde_json::json!({
|
|
||||||
"service": [{
|
|
||||||
"type": "AtprotoLabeler",
|
|
||||||
"serviceEndpoint": "https://labeler.example.com"
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_pds_endpoint_no_services() {
|
|
||||||
let doc = serde_json::json!({});
|
|
||||||
assert_eq!(extract_pds_endpoint(&doc), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_fetched_schema_ok() {
|
fn test_validate_fetched_schema_ok() {
|
||||||
let doc = LexiconDoc {
|
let doc = LexiconDoc {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tranquil_types::Nsid;
|
use tranquil_types::Nsid;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LexiconDoc {
|
pub struct LexiconDoc {
|
||||||
pub lexicon: u32,
|
pub lexicon: u32,
|
||||||
pub id: Nsid,
|
pub id: Nsid,
|
||||||
@@ -10,7 +10,7 @@ pub struct LexiconDoc {
|
|||||||
pub defs: HashMap<String, LexDef>,
|
pub defs: HashMap<String, LexDef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum LexDef {
|
pub enum LexDef {
|
||||||
#[serde(rename = "record")]
|
#[serde(rename = "record")]
|
||||||
@@ -35,14 +35,14 @@ pub enum LexDef {
|
|||||||
PermissionSet {},
|
PermissionSet {},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LexRecord {
|
pub struct LexRecord {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
pub record: LexObject,
|
pub record: LexObject,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LexObject {
|
pub struct LexObject {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub required: Vec<String>,
|
pub required: Vec<String>,
|
||||||
@@ -52,7 +52,7 @@ pub struct LexObject {
|
|||||||
pub properties: HashMap<String, LexProperty>,
|
pub properties: HashMap<String, LexProperty>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum LexProperty {
|
pub enum LexProperty {
|
||||||
#[serde(rename = "string")]
|
#[serde(rename = "string")]
|
||||||
@@ -79,7 +79,7 @@ pub enum LexProperty {
|
|||||||
Object(LexObject),
|
Object(LexObject),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexString {
|
pub struct LexString {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -102,7 +102,7 @@ pub struct LexString {
|
|||||||
pub default: Option<String>,
|
pub default: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LexInteger {
|
pub struct LexInteger {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub minimum: Option<i64>,
|
pub minimum: Option<i64>,
|
||||||
@@ -116,7 +116,7 @@ pub struct LexInteger {
|
|||||||
pub const_value: Option<i64>,
|
pub const_value: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexBytes {
|
pub struct LexBytes {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -125,7 +125,7 @@ pub struct LexBytes {
|
|||||||
pub min_length: Option<u64>,
|
pub min_length: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexBlob {
|
pub struct LexBlob {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -134,7 +134,7 @@ pub struct LexBlob {
|
|||||||
pub max_size: Option<u64>,
|
pub max_size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexArray {
|
pub struct LexArray {
|
||||||
pub items: Box<LexProperty>,
|
pub items: Box<LexProperty>,
|
||||||
@@ -144,7 +144,7 @@ pub struct LexArray {
|
|||||||
pub max_length: Option<u64>,
|
pub max_length: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LexUnion {
|
pub struct LexUnion {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub refs: Vec<String>,
|
pub refs: Vec<String>,
|
||||||
@@ -152,14 +152,14 @@ pub struct LexUnion {
|
|||||||
pub closed: bool,
|
pub closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexRef {
|
pub struct LexRef {
|
||||||
#[serde(rename = "ref")]
|
#[serde(rename = "ref")]
|
||||||
pub reference: String,
|
pub reference: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum StringFormat {
|
pub enum StringFormat {
|
||||||
#[serde(rename = "did")]
|
#[serde(rename = "did")]
|
||||||
Did,
|
Did,
|
||||||
@@ -204,6 +204,6 @@ pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LexStringDef {}
|
pub struct LexStringDef {}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async fn test_resolve_pds_endpoint_from_plc() {
|
|||||||
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(endpoint, "https://pds.example.com");
|
assert_eq!(endpoint.as_str(), "https://pds.example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -130,14 +130,17 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
|
|||||||
"id": did,
|
"id": did,
|
||||||
"service": [
|
"service": [
|
||||||
{
|
{
|
||||||
|
"id": "#atproto_labeler",
|
||||||
"type": "AtprotoLabeler",
|
"type": "AtprotoLabeler",
|
||||||
"serviceEndpoint": "https://labeler.example.com"
|
"serviceEndpoint": "https://labeler.example.com"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "#bsky_notif",
|
||||||
"type": "BskyNotificationService",
|
"type": "BskyNotificationService",
|
||||||
"serviceEndpoint": "https://notify.example.com"
|
"serviceEndpoint": "https://notify.example.com"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "#atproto_pds",
|
||||||
"type": "AtprotoPersonalDataServer",
|
"type": "AtprotoPersonalDataServer",
|
||||||
"serviceEndpoint": "https://pds.example.com"
|
"serviceEndpoint": "https://pds.example.com"
|
||||||
}
|
}
|
||||||
@@ -149,7 +152,7 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
|
|||||||
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(endpoint, "https://pds.example.com");
|
assert_eq!(endpoint.as_str(), "https://pds.example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -168,7 +171,7 @@ async fn test_fetch_schema_from_pds_success() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let doc = fetch_schema_from_pds(
|
let doc = fetch_schema_from_pds(
|
||||||
&pds_server.uri(),
|
&pds_server.uri().parse().unwrap(),
|
||||||
&did.parse().unwrap(),
|
&did.parse().unwrap(),
|
||||||
&nsid.parse().unwrap(),
|
&nsid.parse().unwrap(),
|
||||||
)
|
)
|
||||||
@@ -195,7 +198,7 @@ async fn test_fetch_schema_missing_value_field() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = fetch_schema_from_pds(
|
let result = fetch_schema_from_pds(
|
||||||
&pds_server.uri(),
|
&pds_server.uri().parse().unwrap(),
|
||||||
&did.parse().unwrap(),
|
&did.parse().unwrap(),
|
||||||
&nsid.parse().unwrap(),
|
&nsid.parse().unwrap(),
|
||||||
)
|
)
|
||||||
@@ -222,7 +225,7 @@ async fn test_fetch_schema_invalid_lexicon_json() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = fetch_schema_from_pds(
|
let result = fetch_schema_from_pds(
|
||||||
&pds_server.uri(),
|
&pds_server.uri().parse().unwrap(),
|
||||||
&did.parse().unwrap(),
|
&did.parse().unwrap(),
|
||||||
&nsid.parse().unwrap(),
|
&nsid.parse().unwrap(),
|
||||||
)
|
)
|
||||||
@@ -352,7 +355,7 @@ async fn test_pds_trailing_slash_handled() {
|
|||||||
|
|
||||||
let pds_url_with_slash = format!("{}/", pds_server.uri());
|
let pds_url_with_slash = format!("{}/", pds_server.uri());
|
||||||
let doc = fetch_schema_from_pds(
|
let doc = fetch_schema_from_pds(
|
||||||
&pds_url_with_slash,
|
&pds_url_with_slash.parse().unwrap(),
|
||||||
&did.parse().unwrap(),
|
&did.parse().unwrap(),
|
||||||
&nsid.parse().unwrap(),
|
&nsid.parse().unwrap(),
|
||||||
)
|
)
|
||||||
@@ -377,7 +380,7 @@ async fn test_fetch_schema_error_status_gives_meaningful_error() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let result = fetch_schema_from_pds(
|
let result = fetch_schema_from_pds(
|
||||||
&pds_server.uri(),
|
&pds_server.uri().parse().unwrap(),
|
||||||
&did.parse().unwrap(),
|
&did.parse().unwrap(),
|
||||||
&nsid.parse().unwrap(),
|
&nsid.parse().unwrap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ webauthn-rs = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
tranquil-infra = { workspace = true, features = ["testing"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
bsky = []
|
bsky = []
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use tranquil_scopes::{ParsedScope, parse_scope};
|
||||||
use tranquil_types::Nsid;
|
use tranquil_types::Nsid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -10,6 +11,9 @@ pub struct ScopeInfo {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub granted: Option<bool>,
|
pub granted: Option<bool>,
|
||||||
pub restricted: bool,
|
pub restricted: bool,
|
||||||
|
pub superseded: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effective_scope: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -25,6 +29,7 @@ pub struct PermissionSetInfo {
|
|||||||
pub expanded: Vec<ScopeInfo>,
|
pub expanded: Vec<ScopeInfo>,
|
||||||
pub granted: Option<bool>,
|
pub granted: Option<bool>,
|
||||||
pub restricted: bool,
|
pub restricted: bool,
|
||||||
|
pub superseded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -38,6 +43,13 @@ pub struct FailedSetInfo {
|
|||||||
pub reason: tranquil_scopes::ResolveFailure,
|
pub reason: tranquil_scopes::ResolveFailure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RejectedScopeInfo {
|
||||||
|
// The scope exactly as the client requested it, which may be invalid or malformed.
|
||||||
|
pub scope: String,
|
||||||
|
pub reason: tranquil_scopes::ScopeRejection,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ConsentResponse {
|
pub struct ConsentResponse {
|
||||||
pub request_uri: String,
|
pub request_uri: String,
|
||||||
@@ -47,7 +59,9 @@ pub struct ConsentResponse {
|
|||||||
pub logo_uri: Option<String>,
|
pub logo_uri: Option<String>,
|
||||||
pub scopes: Vec<ScopeInfo>,
|
pub scopes: Vec<ScopeInfo>,
|
||||||
pub permission_sets: Vec<PermissionSetInfo>,
|
pub permission_sets: Vec<PermissionSetInfo>,
|
||||||
|
pub transition_supersedes: bool,
|
||||||
pub failed_sets: Vec<FailedSetInfo>,
|
pub failed_sets: Vec<FailedSetInfo>,
|
||||||
|
pub rejected_scopes: Vec<RejectedScopeInfo>,
|
||||||
pub show_consent: bool,
|
pub show_consent: bool,
|
||||||
pub did: Did,
|
pub did: Did,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -118,7 +132,7 @@ pub async fn consent_get(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let did = flow_with_user.did().clone();
|
let did = flow_with_user.did().clone();
|
||||||
let client_cache = ClientMetadataCache::new(3600);
|
let client_cache = &state.client_metadata_cache;
|
||||||
let client_metadata = client_cache
|
let client_metadata = client_cache
|
||||||
.get(&request_data.parameters.client_id)
|
.get(&request_data.parameters.client_id)
|
||||||
.await
|
.await
|
||||||
@@ -150,9 +164,13 @@ pub async fn consent_get(
|
|||||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||||
None => scope_resolution::Authority::FullSelf,
|
None => scope_resolution::Authority::FullSelf,
|
||||||
};
|
};
|
||||||
let effective =
|
let effective = scope_resolution::resolve_effective_scopes(
|
||||||
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
|
&*state.cache,
|
||||||
.await;
|
requested_scope_str,
|
||||||
|
authority,
|
||||||
|
client_metadata.as_ref().and_then(|m| m.scope.as_deref()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
|
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
|
||||||
let preferences = state
|
let preferences = state
|
||||||
.repos
|
.repos
|
||||||
@@ -164,16 +182,7 @@ pub async fn consent_get(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.scope.as_str(), p.granted))
|
.map(|p| (p.scope.as_str(), p.granted))
|
||||||
.collect();
|
.collect();
|
||||||
let presented_item_strings: Vec<String> = effective
|
let presented_item_strings = effective.outcome.unexpanded_scopes();
|
||||||
.outcome
|
|
||||||
.passthrough
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.chain(effective.outcome.sets.iter().map(|g| match &g.aud {
|
|
||||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
|
||||||
None => format!("include:{}", g.nsid),
|
|
||||||
}))
|
|
||||||
.collect();
|
|
||||||
let show_consent = should_show_consent(
|
let show_consent = should_show_consent(
|
||||||
state.repos.oauth.as_ref(),
|
state.repos.oauth.as_ref(),
|
||||||
&did,
|
&did,
|
||||||
@@ -183,23 +192,35 @@ pub async fn consent_get(
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||||
|
let has_transition_generic = requested_scopes
|
||||||
|
.iter()
|
||||||
|
.any(|s| matches!(parse_scope(s), ParsedScope::TransitionGeneric));
|
||||||
|
|
||||||
let grant_scope_str: Option<&str> =
|
let grant_scope_str: Option<&str> =
|
||||||
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
|
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
|
||||||
let is_restricted = |scope: &str| -> bool {
|
let coverage_of = |scope: &str| -> tranquil_pds::delegation::GrantCoverage {
|
||||||
grant_scope_str.is_some_and(|g| !tranquil_pds::delegation::grant_covers(g, scope))
|
match grant_scope_str {
|
||||||
|
Some(g) => tranquil_pds::delegation::grant_coverage(g, scope),
|
||||||
|
None => tranquil_pds::delegation::GrantCoverage::Full,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let make_scope_info = |scope: &str| -> ScopeInfo {
|
let make_scope_info = |scope: &str| -> ScopeInfo {
|
||||||
|
let (restricted, effective_scope) = match coverage_of(scope) {
|
||||||
|
tranquil_pds::delegation::GrantCoverage::Full => (false, None),
|
||||||
|
tranquil_pds::delegation::GrantCoverage::Narrowed(narrowed) => (false, Some(narrowed)),
|
||||||
|
tranquil_pds::delegation::GrantCoverage::Withheld => (true, None),
|
||||||
|
};
|
||||||
|
let described = effective_scope.as_deref().unwrap_or(scope);
|
||||||
let (category, required, description, display_name) =
|
let (category, required, description, display_name) =
|
||||||
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) {
|
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(described) {
|
||||||
let desc = if scope == "atproto" && has_granular_scopes {
|
let desc = if described == "atproto" && has_granular_scopes {
|
||||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
def.description.to_string()
|
def.description.to_string()
|
||||||
};
|
};
|
||||||
let name = if scope == "atproto" && has_granular_scopes {
|
let name = if described == "atproto" && has_granular_scopes {
|
||||||
"AT Protocol Access".to_string()
|
"AT Protocol Access".to_string()
|
||||||
} else {
|
} else {
|
||||||
def.display_name.to_string()
|
def.display_name.to_string()
|
||||||
@@ -210,22 +231,24 @@ pub async fn consent_get(
|
|||||||
desc,
|
desc,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
} else if scope.starts_with("ref:") {
|
} else if described.starts_with("ref:") {
|
||||||
(
|
(
|
||||||
"Reference".to_string(),
|
"Reference".to_string(),
|
||||||
false,
|
false,
|
||||||
"Referenced scope".to_string(),
|
"Referenced scope".to_string(),
|
||||||
scope.to_string(),
|
described.to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
"Other".to_string(),
|
"Other".to_string(),
|
||||||
false,
|
false,
|
||||||
format!("Access to {}", scope),
|
format!("Access to {}", described),
|
||||||
scope.to_string(),
|
described.to_string(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let granted = pref_map.get(scope).copied();
|
let granted = pref_map.get(scope).copied();
|
||||||
|
let superseded = has_transition_generic
|
||||||
|
&& tranquil_scopes::superseded_by_transition_generic(&parse_scope(scope));
|
||||||
ScopeInfo {
|
ScopeInfo {
|
||||||
scope: scope.to_string(),
|
scope: scope.to_string(),
|
||||||
category,
|
category,
|
||||||
@@ -233,7 +256,9 @@ pub async fn consent_get(
|
|||||||
description,
|
description,
|
||||||
display_name,
|
display_name,
|
||||||
granted,
|
granted,
|
||||||
restricted: is_restricted(scope),
|
restricted,
|
||||||
|
superseded,
|
||||||
|
effective_scope,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,12 +274,10 @@ pub async fn consent_get(
|
|||||||
.sets
|
.sets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| {
|
.map(|g| {
|
||||||
let include_scope = match &g.aud {
|
let include_scope = g.include_token();
|
||||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
|
||||||
None => format!("include:{}", g.nsid),
|
|
||||||
};
|
|
||||||
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
|
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
|
||||||
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
|
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
|
||||||
|
let superseded = !expanded.is_empty() && expanded.iter().all(|s| s.superseded);
|
||||||
PermissionSetInfo {
|
PermissionSetInfo {
|
||||||
nsid: g.nsid.clone(),
|
nsid: g.nsid.clone(),
|
||||||
aud: g.aud.clone(),
|
aud: g.aud.clone(),
|
||||||
@@ -264,6 +287,7 @@ pub async fn consent_get(
|
|||||||
include_scope,
|
include_scope,
|
||||||
expanded,
|
expanded,
|
||||||
restricted,
|
restricted,
|
||||||
|
superseded,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -279,6 +303,16 @@ pub async fn consent_get(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let rejected_scopes: Vec<RejectedScopeInfo> = effective
|
||||||
|
.outcome
|
||||||
|
.rejected
|
||||||
|
.iter()
|
||||||
|
.map(|r| RejectedScopeInfo {
|
||||||
|
scope: r.scope.clone(),
|
||||||
|
reason: r.reason,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let account_handle = state
|
let account_handle = state
|
||||||
.repos
|
.repos
|
||||||
.user
|
.user
|
||||||
@@ -320,6 +354,9 @@ pub async fn consent_get(
|
|||||||
(None, None, None, None)
|
(None, None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let transition_supersedes =
|
||||||
|
scopes.iter().any(|s| s.superseded) || permission_sets.iter().any(|s| s.superseded);
|
||||||
|
|
||||||
Json(ConsentResponse {
|
Json(ConsentResponse {
|
||||||
request_uri: query.request_uri.clone(),
|
request_uri: query.request_uri.clone(),
|
||||||
client_id: request_data.parameters.client_id.clone(),
|
client_id: request_data.parameters.client_id.clone(),
|
||||||
@@ -328,7 +365,9 @@ pub async fn consent_get(
|
|||||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||||
scopes,
|
scopes,
|
||||||
permission_sets,
|
permission_sets,
|
||||||
|
transition_supersedes,
|
||||||
failed_sets,
|
failed_sets,
|
||||||
|
rejected_scopes,
|
||||||
show_consent,
|
show_consent,
|
||||||
did: did.clone(),
|
did: did.clone(),
|
||||||
handle: account_handle,
|
handle: account_handle,
|
||||||
@@ -420,9 +459,19 @@ pub async fn consent_post(
|
|||||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||||
None => scope_resolution::Authority::FullSelf,
|
None => scope_resolution::Authority::FullSelf,
|
||||||
};
|
};
|
||||||
let effective =
|
let client_scope = state
|
||||||
scope_resolution::resolve_effective_scopes(&*state.cache, original_scope_str, authority)
|
.client_metadata_cache
|
||||||
.await;
|
.get(&request_data.parameters.client_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.scope);
|
||||||
|
let effective = scope_resolution::resolve_effective_scopes(
|
||||||
|
&*state.cache,
|
||||||
|
original_scope_str,
|
||||||
|
authority,
|
||||||
|
client_scope.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
||||||
match aud {
|
match aud {
|
||||||
Some(a) => format!("include:{}?aud={}", nsid, a),
|
Some(a) => format!("include:{}?aud={}", nsid, a),
|
||||||
@@ -449,19 +498,7 @@ pub async fn consent_post(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let presented_items: Vec<String> = effective
|
let presented_items = effective.outcome.unexpanded_scopes();
|
||||||
.outcome
|
|
||||||
.passthrough
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.chain(
|
|
||||||
effective
|
|
||||||
.outcome
|
|
||||||
.sets
|
|
||||||
.iter()
|
|
||||||
.map(|g| include_token(&g.nsid, &g.aud)),
|
|
||||||
)
|
|
||||||
.collect();
|
|
||||||
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
|
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
|
||||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||||
return json_error(
|
return json_error(
|
||||||
@@ -480,14 +517,6 @@ pub async fn consent_post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let approved_scope_str = final_approved.join(" ");
|
let approved_scope_str = final_approved.join(" ");
|
||||||
let has_valid_scope = final_approved.iter().all(|s| is_valid_scope(s));
|
|
||||||
if !has_valid_scope {
|
|
||||||
return json_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"invalid_request",
|
|
||||||
"Invalid scope format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if form.remember {
|
if form.remember {
|
||||||
let preferences: Vec<ScopePreference> = presented_items
|
let preferences: Vec<ScopePreference> = presented_items
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub async fn authorize_get(
|
|||||||
"Authorization request has expired. Please start a new request.",
|
"Authorization request has expired. Please start a new request.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let client_cache = ClientMetadataCache::new(3600);
|
let client_cache = &state.client_metadata_cache;
|
||||||
let client_name = client_cache
|
let client_name = client_cache
|
||||||
.get(&request_data.parameters.client_id)
|
.get(&request_data.parameters.client_id)
|
||||||
.await
|
.await
|
||||||
@@ -572,20 +572,31 @@ pub async fn authorize_post(
|
|||||||
{
|
{
|
||||||
Ok(challenge) => {
|
Ok(challenge) => {
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = enqueue_2fa_code(
|
match enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user.id,
|
user.id,
|
||||||
&challenge.code,
|
Notice::TwoFactorCode {
|
||||||
|
code: &challenge.code,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
Ok(Some(_)) => {}
|
||||||
did = %user.did,
|
Ok(None) => {
|
||||||
error = %e,
|
return show_login_error(
|
||||||
"Failed to enqueue 2FA notification"
|
"We couldn't deliver this verification code to your notification channels. Please contact the PDS owner.",
|
||||||
);
|
json_response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
did = %user.did,
|
||||||
|
error = %e,
|
||||||
|
"Failed to enqueue 2FA notification"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let channel_name = user.preferred_comms_channel.display_name();
|
let channel_name = user.preferred_comms_channel.display_name();
|
||||||
if json_response {
|
if json_response {
|
||||||
@@ -907,20 +918,32 @@ pub async fn authorize_select(
|
|||||||
{
|
{
|
||||||
Ok(challenge) => {
|
Ok(challenge) => {
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = enqueue_2fa_code(
|
match enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user.id,
|
user.id,
|
||||||
&challenge.code,
|
Notice::TwoFactorCode {
|
||||||
|
code: &challenge.code,
|
||||||
|
},
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
Ok(Some(_)) => {}
|
||||||
did = %form.did,
|
Ok(None) => {
|
||||||
error = %e,
|
return json_error(
|
||||||
"Failed to enqueue 2FA notification"
|
StatusCode::BAD_REQUEST,
|
||||||
);
|
"invalid_request",
|
||||||
|
"We couldn't deliver this verification code to your notification chanels. Please contact the PDS owner.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
did = %form.did,
|
||||||
|
error = %e,
|
||||||
|
"Failed to enqueue 2FA notification"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let channel_name = user.preferred_comms_channel.display_name();
|
let channel_name = user.preferred_comms_channel.display_name();
|
||||||
return Json(serde_json::json!({
|
return Json(serde_json::json!({
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
|
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
|
||||||
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||||
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
|
use tranquil_pds::comms::Notice;
|
||||||
|
use tranquil_pds::comms::comms_repo::enqueue_notice;
|
||||||
use tranquil_pds::oauth::{
|
use tranquil_pds::oauth::{
|
||||||
AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
|
AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
|
||||||
db::should_show_consent,
|
|
||||||
};
|
};
|
||||||
use tranquil_pds::rate_limit::{
|
use tranquil_pds::rate_limit::{
|
||||||
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
||||||
@@ -78,15 +78,6 @@ fn is_granular_scope(s: &str) -> bool {
|
|||||||
|| s.starts_with("identity:")
|
|| s.starts_with("identity:")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_valid_scope(s: &str) -> bool {
|
|
||||||
s == "atproto"
|
|
||||||
|| s == "transition:generic"
|
|
||||||
|| s == "transition:chat.bsky"
|
|
||||||
|| s == "transition:email"
|
|
||||||
|| is_granular_scope(s)
|
|
||||||
|| s.starts_with("include:")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
|
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
|
||||||
headers
|
headers
|
||||||
.get("cookie")
|
.get("cookie")
|
||||||
|
|||||||
@@ -1273,16 +1273,31 @@ pub async fn authorize_passkey_finish(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(challenge) => {
|
Ok(challenge) => {
|
||||||
if let Err(e) = enqueue_2fa_code(
|
match enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user.id,
|
user.id,
|
||||||
&challenge.code,
|
Notice::TwoFactorCode {
|
||||||
|
code: &challenge.code,
|
||||||
|
},
|
||||||
pds_hostname,
|
pds_hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "We couldn't deliver the verification code to your notification channels. Please contact the PDS owner! <3"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let channel_name = user.preferred_comms_channel.display_name();
|
let channel_name = user.preferred_comms_channel.display_name();
|
||||||
let redirect_url = format!(
|
let redirect_url = format!(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use tranquil_db_traits::DbScope;
|
use tranquil_db_traits::DbScope;
|
||||||
use tranquil_pds::cache::Cache;
|
use tranquil_pds::cache::Cache;
|
||||||
use tranquil_pds::delegation::intersect_scopes;
|
use tranquil_pds::delegation::{GrantCoverage, grant_coverage, intersect_scopes};
|
||||||
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
|
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
|
||||||
use tranquil_scopes::ExpansionOutcome;
|
use tranquil_scopes::{
|
||||||
|
ExpansionOutcome, ParsedScope, RejectedScope, RepoScope, ScopeRejection, parse_scope,
|
||||||
|
};
|
||||||
|
|
||||||
pub enum Authority<'a> {
|
pub enum Authority<'a> {
|
||||||
FullSelf,
|
FullSelf,
|
||||||
@@ -20,8 +22,12 @@ pub async fn resolve_effective_scopes(
|
|||||||
cache: &dyn Cache,
|
cache: &dyn Cache,
|
||||||
requested: &str,
|
requested: &str,
|
||||||
authority: Authority<'_>,
|
authority: Authority<'_>,
|
||||||
|
client_scope: Option<&str>,
|
||||||
) -> EffectiveScopes {
|
) -> EffectiveScopes {
|
||||||
let outcome = expand_scopes(cache, requested).await;
|
let mut outcome = expand_scopes(cache, requested).await;
|
||||||
|
if let Some(registered) = client_scope.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
reject_unregistered(&mut outcome, registered);
|
||||||
|
}
|
||||||
let expanded = outcome.to_scope_string();
|
let expanded = outcome.to_scope_string();
|
||||||
let permitted = match authority {
|
let permitted = match authority {
|
||||||
Authority::FullSelf => expanded,
|
Authority::FullSelf => expanded,
|
||||||
@@ -30,39 +36,69 @@ pub async fn resolve_effective_scopes(
|
|||||||
EffectiveScopes { permitted, outcome }
|
EffectiveScopes { permitted, outcome }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_unregistered(outcome: &mut ExpansionOutcome, registered: &str) {
|
||||||
|
let mut rejected = Vec::new();
|
||||||
|
let mut keep = |scope: String| match grant_coverage(registered, &scope) {
|
||||||
|
GrantCoverage::Full => Some(scope),
|
||||||
|
GrantCoverage::Narrowed(narrowed) => {
|
||||||
|
rejected.extend(narrowed_out(&scope, &narrowed).map(|scope| RejectedScope {
|
||||||
|
scope,
|
||||||
|
reason: ScopeRejection::NotRegistered,
|
||||||
|
}));
|
||||||
|
Some(narrowed)
|
||||||
|
}
|
||||||
|
GrantCoverage::Withheld => {
|
||||||
|
rejected.push(RejectedScope {
|
||||||
|
scope,
|
||||||
|
reason: ScopeRejection::NotRegistered,
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
outcome.passthrough = std::mem::take(&mut outcome.passthrough)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(&mut keep)
|
||||||
|
.collect();
|
||||||
|
outcome.sets = std::mem::take(&mut outcome.sets)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|group| keep(group.include_token()).is_some())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
outcome.rejected.extend(rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repo actions dropped when `requested` was narrowed to `narrowed`, as a scope of their own.
|
||||||
|
/// Only repo scopes are ever narrowed; anything else yields `None`.
|
||||||
|
fn narrowed_out(requested: &str, narrowed: &str) -> Option<String> {
|
||||||
|
let (ParsedScope::Repo(requested), ParsedScope::Repo(narrowed)) =
|
||||||
|
(parse_scope(requested), parse_scope(narrowed))
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let actions: std::collections::HashSet<_> = requested
|
||||||
|
.actions
|
||||||
|
.difference(&narrowed.actions)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
(!actions.is_empty()).then(|| {
|
||||||
|
RepoScope {
|
||||||
|
collection: requested.collection,
|
||||||
|
actions,
|
||||||
|
}
|
||||||
|
.to_scope_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tranquil_pds::cache::{Cache, CacheError};
|
use tranquil_infra::MemoryCache;
|
||||||
|
use tranquil_pds::cache::Cache;
|
||||||
|
|
||||||
#[derive(Default)]
|
async fn cache_with(nsid: &str, scopes: &str) -> MemoryCache {
|
||||||
struct MapCache(Mutex<HashMap<String, String>>);
|
let c = MemoryCache::new();
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Cache for MapCache {
|
|
||||||
async fn get(&self, k: &str) -> Option<String> {
|
|
||||||
self.0.lock().unwrap().get(k).cloned()
|
|
||||||
}
|
|
||||||
async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> {
|
|
||||||
self.0.lock().unwrap().insert(k.into(), v.into());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn delete(&self, k: &str) -> Result<(), CacheError> {
|
|
||||||
self.0.lock().unwrap().remove(k);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn get_bytes(&self, _k: &str) -> Option<Vec<u8>> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cache_with(nsid: &str, scopes: &str) -> MapCache {
|
|
||||||
let c = MapCache::default();
|
|
||||||
let key = tranquil_pds::cache_keys::permission_set_key(
|
let key = tranquil_pds::cache_keys::permission_set_key(
|
||||||
&tranquil_types::Nsid::new(nsid).unwrap(),
|
&tranquil_types::Nsid::new(nsid).unwrap(),
|
||||||
None,
|
None,
|
||||||
@@ -74,7 +110,7 @@ mod tests {
|
|||||||
"refreshed_at": chrono::Utc::now().timestamp(),
|
"refreshed_at": chrono::Utc::now().timestamp(),
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
c.0.lock().unwrap().insert(key, json);
|
let _ = c.set(&key, &json, Duration::from_secs(3600)).await;
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +119,13 @@ mod tests {
|
|||||||
let c = cache_with(
|
let c = cache_with(
|
||||||
"io.atcr.authFullApp",
|
"io.atcr.authFullApp",
|
||||||
"repo:io.atcr.manifest?action=create identity:*",
|
"repo:io.atcr.manifest?action=create identity:*",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
let eff = resolve_effective_scopes(
|
let eff = resolve_effective_scopes(
|
||||||
&c,
|
&c,
|
||||||
"atproto include:io.atcr.authFullApp",
|
"atproto include:io.atcr.authFullApp",
|
||||||
Authority::FullSelf,
|
Authority::FullSelf,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(eff.permitted.contains("atproto"));
|
assert!(eff.permitted.contains("atproto"));
|
||||||
@@ -104,12 +142,14 @@ mod tests {
|
|||||||
let c = cache_with(
|
let c = cache_with(
|
||||||
"io.atcr.authFullApp",
|
"io.atcr.authFullApp",
|
||||||
"repo:io.atcr.manifest?action=create identity:*",
|
"repo:io.atcr.manifest?action=create identity:*",
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
|
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
|
||||||
let eff = resolve_effective_scopes(
|
let eff = resolve_effective_scopes(
|
||||||
&c,
|
&c,
|
||||||
"atproto include:io.atcr.authFullApp",
|
"atproto include:io.atcr.authFullApp",
|
||||||
Authority::Delegated(&granted),
|
Authority::Delegated(&granted),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(eff.permitted.contains("atproto"));
|
assert!(eff.permitted.contains("atproto"));
|
||||||
@@ -119,4 +159,106 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!eff.permitted.contains("identity"));
|
assert!(!eff.permitted.contains("identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unrecognized_scopes_never_reach_permitted() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
let eff = resolve_effective_scopes(&c, "atproto chat", Authority::FullSelf, None).await;
|
||||||
|
assert!(eff.permitted.split_whitespace().any(|s| s == "atproto"));
|
||||||
|
assert!(
|
||||||
|
!eff.permitted.split_whitespace().any(|s| s == "chat"),
|
||||||
|
"permitted was {:?}",
|
||||||
|
eff.permitted
|
||||||
|
);
|
||||||
|
assert_eq!(eff.outcome.rejected.len(), 1);
|
||||||
|
assert_eq!(eff.outcome.rejected[0].reason, ScopeRejection::Unrecognized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scopes_absent_from_client_metadata_are_rejected() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
let eff = resolve_effective_scopes(
|
||||||
|
&c,
|
||||||
|
"atproto identity:*",
|
||||||
|
Authority::FullSelf,
|
||||||
|
Some("atproto"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!eff.permitted.split_whitespace().any(|s| s == "identity:*"));
|
||||||
|
assert_eq!(eff.outcome.rejected.len(), 1);
|
||||||
|
assert_eq!(eff.outcome.rejected[0].scope, "identity:*");
|
||||||
|
assert_eq!(
|
||||||
|
eff.outcome.rejected[0].reason,
|
||||||
|
ScopeRejection::NotRegistered
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wildcard_client_registration_covers_narrower_request() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
let eff = resolve_effective_scopes(
|
||||||
|
&c,
|
||||||
|
"atproto repo:app.bsky.feed.post?action=create",
|
||||||
|
Authority::FullSelf,
|
||||||
|
Some("atproto repo:*"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(eff.outcome.rejected.is_empty());
|
||||||
|
assert!(
|
||||||
|
eff.permitted
|
||||||
|
.contains("repo:app.bsky.feed.post?action=create")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn absent_client_metadata_scope_constrains_nothing() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
let eff =
|
||||||
|
resolve_effective_scopes(&c, "atproto identity:*", Authority::FullSelf, None).await;
|
||||||
|
assert!(eff.outcome.rejected.is_empty());
|
||||||
|
assert!(eff.permitted.contains("identity:*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_expanded_scopes_bypass_the_client_registration_check() {
|
||||||
|
let c = cache_with("io.atcr.authFullApp", "identity:*").await;
|
||||||
|
let eff = resolve_effective_scopes(
|
||||||
|
&c,
|
||||||
|
"atproto include:io.atcr.authFullApp",
|
||||||
|
Authority::FullSelf,
|
||||||
|
Some("atproto include:io.atcr.authFullApp"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
eff.outcome.rejected.is_empty(),
|
||||||
|
"a permission set legitimately expands to scopes the client never registered"
|
||||||
|
);
|
||||||
|
assert!(eff.permitted.contains("identity:*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn actions_narrowed_out_by_client_metadata_are_reported_as_rejected() {
|
||||||
|
let c = MemoryCache::new();
|
||||||
|
let eff = resolve_effective_scopes(
|
||||||
|
&c,
|
||||||
|
"atproto repo:app.bsky.feed.post?action=create&action=delete",
|
||||||
|
Authority::FullSelf,
|
||||||
|
Some("atproto repo:*?action=create"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
eff.permitted
|
||||||
|
.split_whitespace()
|
||||||
|
.any(|s| s == "repo:app.bsky.feed.post?action=create"),
|
||||||
|
"permitted was {:?}",
|
||||||
|
eff.permitted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eff.outcome.rejected,
|
||||||
|
vec![RejectedScope {
|
||||||
|
scope: "repo:app.bsky.feed.post?action=delete".to_string(),
|
||||||
|
reason: ScopeRejection::NotRegistered,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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::ClientIp;
|
use tranquil_pds::util::ClientIp;
|
||||||
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
|
use tranquil_types::did_doc::{PdsEndpointError, extract_handle, extract_pds_endpoint};
|
||||||
|
use tranquil_types::url_kind;
|
||||||
use tranquil_types::{Did, RequestId};
|
use tranquil_types::{Did, RequestId};
|
||||||
|
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
@@ -231,11 +232,17 @@ pub async fn delegation_auth(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let pds_url = match extract_pds_endpoint(&did_doc) {
|
let pds_url = match extract_pds_endpoint::<url_kind::Pds>(&did_doc) {
|
||||||
Some(url) => url,
|
Ok(url) => url,
|
||||||
None => {
|
Err(PdsEndpointError::Missing) => {
|
||||||
return DelegationAuthResponse::err("Controller has no PDS endpoint");
|
return DelegationAuthResponse::err("Controller has no PDS endpoint");
|
||||||
}
|
}
|
||||||
|
Err(PdsEndpointError::Invalid(e)) => {
|
||||||
|
tracing::warn!(controller = %controller_did, error = %e, "Controller PDS endpoint rejected");
|
||||||
|
return DelegationAuthResponse::err(
|
||||||
|
"Controller PDS endpoint isn't a usable https URL",
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
@@ -447,7 +454,7 @@ pub async fn delegation_auth_token(
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CrossPdsCallbackParams {
|
pub struct CrossPdsCallbackParams {
|
||||||
pub code: tranquil_types::AuthorizationCode,
|
pub code: tranquil_types::AuthorizationCode,
|
||||||
pub state: String,
|
pub state: tranquil_types::CrossPdsState,
|
||||||
pub iss: Option<String>,
|
pub iss: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +481,7 @@ pub async fn delegation_callback(
|
|||||||
|
|
||||||
if let Some(ref expected_issuer) = auth_state.expected_issuer {
|
if let Some(ref expected_issuer) = auth_state.expected_issuer {
|
||||||
match ¶ms.iss {
|
match ¶ms.iss {
|
||||||
Some(iss) if iss != expected_issuer => {
|
Some(iss) if iss.as_str() != expected_issuer.as_str() => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Cross-PDS issuer mismatch: expected {}, got {}",
|
"Cross-PDS issuer mismatch: expected {}, got {}",
|
||||||
expected_issuer,
|
expected_issuer,
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use axum::{Json, extract::State, http::HeaderMap};
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tranquil_pds::oauth::{
|
use tranquil_pds::oauth::{
|
||||||
AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod,
|
AuthorizationRequestParameters, ClientAuth, CodeChallengeMethod, OAuthError, Prompt,
|
||||||
OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType,
|
RequestData, RequestId, ResponseMode, ResponseType,
|
||||||
scopes::{ParsedScope, parse_scope},
|
|
||||||
};
|
};
|
||||||
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
|
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
|
||||||
use tranquil_pds::state::AppState;
|
use tranquil_pds::state::AppState;
|
||||||
@@ -80,11 +79,11 @@ pub async fn pushed_authorization_request(
|
|||||||
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
|
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
|
||||||
let code_challenge_method =
|
let code_challenge_method =
|
||||||
parse_code_challenge_method(request.code_challenge_method.as_deref())?;
|
parse_code_challenge_method(request.code_challenge_method.as_deref())?;
|
||||||
let client_cache = ClientMetadataCache::new(3600);
|
let client_cache = &state.client_metadata_cache;
|
||||||
let client_metadata = client_cache.get(&request.client_id).await?;
|
let client_metadata = client_cache.get(&request.client_id).await?;
|
||||||
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
|
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
|
||||||
let client_auth = determine_client_auth(&request)?;
|
let client_auth = determine_client_auth(&request)?;
|
||||||
let validated_scope = validate_scope(&request.scope, &client_metadata)?;
|
let validated_scope = normalize_scope(&request.scope)?;
|
||||||
let request_id = RequestId::generate();
|
let request_id = RequestId::generate();
|
||||||
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
|
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
|
||||||
let response_mode = parse_response_mode(request.response_mode.as_deref())?;
|
let response_mode = parse_response_mode(request.response_mode.as_deref())?;
|
||||||
@@ -165,10 +164,7 @@ fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError>
|
|||||||
Ok(ClientAuth::None)
|
Ok(ClientAuth::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_scope(
|
fn normalize_scope(requested_scope: &Option<String>) -> Result<Option<String>, OAuthError> {
|
||||||
requested_scope: &Option<String>,
|
|
||||||
client_metadata: &tranquil_pds::oauth::ClientMetadata,
|
|
||||||
) -> Result<Option<String>, OAuthError> {
|
|
||||||
let scope_str = match requested_scope {
|
let scope_str = match requested_scope {
|
||||||
Some(s) if !s.is_empty() => s,
|
Some(s) if !s.is_empty() => s,
|
||||||
_ => return Ok(Some("atproto".to_string())),
|
_ => return Ok(Some("atproto".to_string())),
|
||||||
@@ -177,80 +173,14 @@ fn validate_scope(
|
|||||||
if requested_scopes.is_empty() {
|
if requested_scopes.is_empty() {
|
||||||
return Ok(Some("atproto".to_string()));
|
return Ok(Some("atproto".to_string()));
|
||||||
}
|
}
|
||||||
if let Some(unknown) = requested_scopes
|
if !requested_scopes.contains(&"atproto") {
|
||||||
.iter()
|
|
||||||
.find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_)))
|
|
||||||
{
|
|
||||||
return Err(OAuthError::InvalidScope(format!(
|
|
||||||
"Unsupported scope: {}",
|
|
||||||
unknown
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_transition = requested_scopes.iter().any(|s| {
|
|
||||||
matches!(
|
|
||||||
parse_scope(s),
|
|
||||||
ParsedScope::TransitionGeneric
|
|
||||||
| ParsedScope::TransitionChat
|
|
||||||
| ParsedScope::TransitionEmail
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let has_granular = requested_scopes.iter().any(|s| {
|
|
||||||
matches!(
|
|
||||||
parse_scope(s),
|
|
||||||
ParsedScope::Repo(_)
|
|
||||||
| ParsedScope::Blob(_)
|
|
||||||
| ParsedScope::Rpc(_)
|
|
||||||
| ParsedScope::Account(_)
|
|
||||||
| ParsedScope::Identity(_)
|
|
||||||
| ParsedScope::Include(_)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_transition && has_granular {
|
|
||||||
return Err(OAuthError::InvalidScope(
|
return Err(OAuthError::InvalidScope(
|
||||||
"Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
|
"The atproto scope is required".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(client_scope) = &client_metadata.scope {
|
|
||||||
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
|
|
||||||
if let Some(unregistered) = requested_scopes
|
|
||||||
.iter()
|
|
||||||
.find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope)))
|
|
||||||
{
|
|
||||||
return Err(OAuthError::InvalidScope(format!(
|
|
||||||
"Scope '{}' not registered for this client",
|
|
||||||
unregistered
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Some(requested_scopes.join(" ")))
|
Ok(Some(requested_scopes.join(" ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
|
|
||||||
if client_scope == requested_scope {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_resource_type(scope: &str) -> &str {
|
|
||||||
let base = scope.split('?').next().unwrap_or(scope);
|
|
||||||
base.split(':').next().unwrap_or(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
let client_type = get_resource_type(client_scope);
|
|
||||||
let requested_type = get_resource_type(requested_scope);
|
|
||||||
|
|
||||||
if client_type == requested_type {
|
|
||||||
let client_base = client_scope.split('?').next().unwrap_or(client_scope);
|
|
||||||
if client_base.contains('*') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> {
|
fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> {
|
||||||
match value {
|
match value {
|
||||||
"code" => Ok(ResponseType::Code),
|
"code" => Ok(ResponseType::Code),
|
||||||
@@ -300,3 +230,45 @@ fn parse_prompt(value: Option<&str>) -> Result<Option<Prompt>, OAuthError> {
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn normalized(scope: Option<&str>) -> Result<Option<String>, OAuthError> {
|
||||||
|
normalize_scope(&scope.map(str::to_string))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_or_blank_scope_defaults_to_atproto() {
|
||||||
|
assert_eq!(normalized(None).unwrap().as_deref(), Some("atproto"));
|
||||||
|
assert_eq!(normalized(Some("")).unwrap().as_deref(), Some("atproto"));
|
||||||
|
assert_eq!(normalized(Some(" ")).unwrap().as_deref(), Some("atproto"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_without_atproto_is_invalid() {
|
||||||
|
assert!(matches!(
|
||||||
|
normalized(Some("repo:*?action=create blob:*/*")),
|
||||||
|
Err(OAuthError::InvalidScope(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atproto_need_not_come_first() {
|
||||||
|
assert_eq!(
|
||||||
|
normalized(Some("repo:*?action=create atproto"))
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("repo:*?action=create atproto")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrecognized_scopes_still_pass_par() {
|
||||||
|
assert_eq!(
|
||||||
|
normalized(Some("atproto chat")).unwrap().as_deref(),
|
||||||
|
Some("atproto chat")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use chrono::{Duration, Utc};
|
|||||||
use tranquil_db_traits::RefreshTokenLookup;
|
use tranquil_db_traits::RefreshTokenLookup;
|
||||||
use tranquil_pds::config::AuthConfig;
|
use tranquil_pds::config::AuthConfig;
|
||||||
use tranquil_pds::oauth::{
|
use tranquil_pds::oauth::{
|
||||||
AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData,
|
AuthFlow, ClientAuth, DPoPVerifier, OAuthError, RefreshToken, TokenData, TokenId,
|
||||||
TokenId,
|
|
||||||
db::{enforce_token_limit_for_user, lookup_refresh_token},
|
db::{enforce_token_limit_for_user, lookup_refresh_token},
|
||||||
verify_client_auth,
|
verify_client_auth,
|
||||||
};
|
};
|
||||||
@@ -63,7 +62,7 @@ pub async fn handle_authorization_code_grant(
|
|||||||
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
|
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
|
||||||
}
|
}
|
||||||
let did = authorized.did.clone();
|
let did = authorized.did.clone();
|
||||||
let client_metadata_cache = ClientMetadataCache::new(3600);
|
let client_metadata_cache = &state.client_metadata_cache;
|
||||||
let client_metadata = client_metadata_cache.get(&authorized.client_id).await?;
|
let client_metadata = client_metadata_cache.get(&authorized.client_id).await?;
|
||||||
let client_auth = match &request.client_auth {
|
let client_auth = match &request.client_auth {
|
||||||
RequestClientAuth::PrivateKeyJwt {
|
RequestClientAuth::PrivateKeyJwt {
|
||||||
@@ -85,7 +84,7 @@ pub async fn handle_authorization_code_grant(
|
|||||||
},
|
},
|
||||||
RequestClientAuth::None { .. } => ClientAuth::None,
|
RequestClientAuth::None { .. } => ClientAuth::None,
|
||||||
};
|
};
|
||||||
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
|
verify_client_auth(client_metadata_cache, &client_metadata, &client_auth).await?;
|
||||||
verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?;
|
verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?;
|
||||||
if let Some(req_redirect_uri) = &redirect_uri
|
if let Some(req_redirect_uri) = &redirect_uri
|
||||||
&& req_redirect_uri != &authorized.parameters.redirect_uri
|
&& req_redirect_uri != &authorized.parameters.redirect_uri
|
||||||
@@ -158,6 +157,7 @@ pub async fn handle_authorization_code_grant(
|
|||||||
&*state.cache,
|
&*state.cache,
|
||||||
requested_for_resolve,
|
requested_for_resolve,
|
||||||
authority,
|
authority,
|
||||||
|
client_metadata.scope.as_deref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if !effective.outcome.failures.is_empty() {
|
if !effective.outcome.failures.is_empty() {
|
||||||
@@ -202,7 +202,10 @@ pub async fn handle_authorization_code_grant(
|
|||||||
details: None,
|
details: None,
|
||||||
code: None,
|
code: None,
|
||||||
current_refresh_token: Some(refresh_token.clone()),
|
current_refresh_token: Some(refresh_token.clone()),
|
||||||
scope: requested_scope.clone(),
|
// Filtered but unexpanded: a remembered consent skips the consent screen, so the raw
|
||||||
|
// request can still hold scopes the client no longer registers. Sets stay as `include:`
|
||||||
|
// tokens so refresh re-resolves them.
|
||||||
|
scope: Some(effective.outcome.unexpanded_scopes().join(" ")),
|
||||||
controller_did: controller_did.clone(),
|
controller_did: controller_did.clone(),
|
||||||
};
|
};
|
||||||
state
|
state
|
||||||
@@ -275,10 +278,13 @@ async fn recompute_resolved_scope(
|
|||||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||||
};
|
};
|
||||||
|
// No client metadata check here: `token_data.scope` was already filtered against it when
|
||||||
|
// the token was issued, so there is nothing for a re-check to remove.
|
||||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||||
&*state.cache,
|
&*state.cache,
|
||||||
requested,
|
requested,
|
||||||
authority,
|
authority,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if !effective.outcome.failures.is_empty() {
|
if !effective.outcome.failures.is_empty() {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ pub fn create_access_token_with_delegation(
|
|||||||
let issuer = format!("https://{}", pds_hostname);
|
let issuer = format!("https://{}", pds_hostname);
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
|
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
|
||||||
let actual_scope = scope.unwrap_or("atproto");
|
let actual_scope = tranquil_pds::auth::encode_scope(scope.unwrap_or("atproto"))
|
||||||
|
.map_err(|_| OAuthError::InvalidScope("Scope too large".to_string()))?;
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"iss": issuer,
|
"iss": issuer,
|
||||||
"sub": sub.as_str(),
|
"sub": sub.as_str(),
|
||||||
|
|||||||
@@ -789,13 +789,16 @@ pub async fn check_handle_available(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let available_domains = tranquil_config::get().server.available_user_domain_list();
|
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
|
||||||
if let Some(ref d) = query.domain
|
if let Some(d) = &query.domain
|
||||||
&& !available_domains.iter().any(|ad| ad == d)
|
&& !available_domains.contains(d.as_str())
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
|
return Err(ApiError::InvalidRequest("Unknown user domain".into()));
|
||||||
}
|
}
|
||||||
let domain = query.domain.as_deref().unwrap_or(&available_domains[0]);
|
let domain = query
|
||||||
|
.domain
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| available_domains.primary().as_str());
|
||||||
let full_handle = format!("{}.{}", validated, domain);
|
let full_handle = format!("{}.{}", validated, domain);
|
||||||
let handle: tranquil_pds::types::Handle = match full_handle.parse() {
|
let handle: tranquil_pds::types::Handle = match full_handle.parse() {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
@@ -882,87 +885,57 @@ pub async fn complete_registration(
|
|||||||
|
|
||||||
let cfg = tranquil_config::get();
|
let cfg = tranquil_config::get();
|
||||||
let hostname = &cfg.server.hostname;
|
let hostname = &cfg.server.hostname;
|
||||||
let available_domains = cfg.server.available_user_domain_list();
|
let available_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
|
||||||
|
|
||||||
let matched_domain = available_domains
|
let split = available_domains.split_handle(&input.handle);
|
||||||
.iter()
|
|
||||||
.filter(|d| input.handle.ends_with(&format!(".{}", d)))
|
|
||||||
.max_by_key(|d| d.len());
|
|
||||||
|
|
||||||
let handle: tranquil_pds::types::Handle =
|
let handle: tranquil_pds::types::Handle = if !input.handle.contains('.') || split.is_some() {
|
||||||
if !input.handle.contains('.') || matched_domain.is_some() {
|
let handle_to_validate = match split {
|
||||||
let handle_to_validate = match matched_domain {
|
Some((_domain, short)) => short,
|
||||||
Some(domain) => input
|
None => input.handle.as_str(),
|
||||||
.handle
|
|
||||||
.strip_suffix(&format!(".{}", domain))
|
|
||||||
.unwrap_or(&input.handle),
|
|
||||||
None => &input.handle,
|
|
||||||
};
|
|
||||||
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
|
|
||||||
Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0]))
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ApiError::InvalidHandle(None))?,
|
|
||||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
match tranquil_pds::api::validation::validate_short_handle(handle_to_validate) {
|
||||||
|
Ok(h) => format!(
|
||||||
|
"{}.{}",
|
||||||
|
h,
|
||||||
|
split
|
||||||
|
.map(|(d, _)| d)
|
||||||
|
.unwrap_or_else(|| available_domains.primary())
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::InvalidHandle(None))?,
|
||||||
|
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match tranquil_pds::api::validation::validate_full_domain_handle(&input.handle) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return Err(ApiError::InvalidHandle(None)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let verification_channel = input
|
let verification_channel = input
|
||||||
.verification_channel
|
.verification_channel
|
||||||
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
||||||
let verification_recipient = match verification_channel {
|
let effective_email = input
|
||||||
tranquil_db_traits::CommsChannel::Email => {
|
.email
|
||||||
let email = input
|
.clone()
|
||||||
.email
|
.or_else(|| {
|
||||||
|
pending_preview
|
||||||
|
.provider_email
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| {
|
.map(|e| e.into_inner())
|
||||||
pending_preview
|
})
|
||||||
.provider_email
|
.map(|e| e.trim().to_string())
|
||||||
.clone()
|
.filter(|e| !e.is_empty());
|
||||||
.map(|e| e.into_inner())
|
let target = tranquil_api::common::extract_verification_recipient(
|
||||||
})
|
verification_channel,
|
||||||
.map(|e| e.trim().to_string())
|
&tranquil_api::common::ChannelInput {
|
||||||
.filter(|e| !e.is_empty());
|
email: effective_email.as_deref(),
|
||||||
match email {
|
discord_username: input.discord_username.as_deref(),
|
||||||
Some(e) if !e.is_empty() => e,
|
telegram_username: input.telegram_username.as_deref(),
|
||||||
_ => return Err(ApiError::MissingEmail),
|
signal_username: input.signal_username.as_deref(),
|
||||||
}
|
|
||||||
}
|
|
||||||
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
|
|
||||||
Some(username) if !username.trim().is_empty() => {
|
|
||||||
let clean = username.trim().to_lowercase();
|
|
||||||
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
|
|
||||||
return Err(ApiError::InvalidRequest(
|
|
||||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
clean
|
|
||||||
}
|
|
||||||
_ => return Err(ApiError::MissingDiscordId),
|
|
||||||
},
|
},
|
||||||
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
|
)?;
|
||||||
Some(username) if !username.trim().is_empty() => {
|
|
||||||
let clean = username.trim().trim_start_matches('@');
|
|
||||||
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
|
|
||||||
return Err(ApiError::InvalidRequest(
|
|
||||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
clean.to_string()
|
|
||||||
}
|
|
||||||
_ => return Err(ApiError::MissingTelegramUsername),
|
|
||||||
},
|
|
||||||
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
|
|
||||||
Some(username) if !username.trim().is_empty() => {
|
|
||||||
username.trim().trim_start_matches('@').to_lowercase()
|
|
||||||
}
|
|
||||||
_ => return Err(ApiError::MissingSignalNumber),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let email = input
|
let email = input
|
||||||
.email
|
.email
|
||||||
@@ -976,18 +949,11 @@ pub async fn complete_registration(
|
|||||||
.map(|e| e.trim().to_string())
|
.map(|e| e.trim().to_string())
|
||||||
.filter(|e| !e.is_empty());
|
.filter(|e| !e.is_empty());
|
||||||
|
|
||||||
let email = match &email {
|
if let Some(e) = &email
|
||||||
Some(e) => {
|
&& tranquil_types::EmailAddress::new(e).is_err()
|
||||||
if e.len() > 254 {
|
{
|
||||||
return Err(ApiError::InvalidEmail);
|
return Err(ApiError::InvalidEmail);
|
||||||
}
|
}
|
||||||
if !tranquil_pds::api::validation::is_valid_email(e) {
|
|
||||||
return Err(ApiError::InvalidEmail);
|
|
||||||
}
|
|
||||||
Some(e.clone())
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let invite_registration =
|
let invite_registration =
|
||||||
check_registration_invite(&state, input.invite_code.as_deref()).await?;
|
check_registration_invite(&state, input.invite_code.as_deref()).await?;
|
||||||
@@ -1334,10 +1300,11 @@ pub async fn complete_registration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hostname = &tranquil_config::get().server.hostname;
|
let hostname = &tranquil_config::get().server.hostname;
|
||||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_welcome(
|
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice(
|
||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
user_id.unwrap_or(uuid::Uuid::nil()),
|
user_id.unwrap_or(uuid::Uuid::nil()),
|
||||||
|
tranquil_pds::comms::Notice::Welcome,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1374,7 +1341,7 @@ pub async fn complete_registration(
|
|||||||
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
|
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
|
||||||
&did,
|
&did,
|
||||||
verification_channel,
|
verification_channel,
|
||||||
&verification_recipient,
|
&target.id,
|
||||||
);
|
);
|
||||||
let formatted_token =
|
let formatted_token =
|
||||||
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
|
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
|
||||||
@@ -1382,8 +1349,7 @@ pub async fn complete_registration(
|
|||||||
state.repos.user.as_ref(),
|
state.repos.user.as_ref(),
|
||||||
state.repos.infra.as_ref(),
|
state.repos.infra.as_ref(),
|
||||||
uid,
|
uid,
|
||||||
verification_channel,
|
&target,
|
||||||
&verification_recipient,
|
|
||||||
&formatted_token,
|
&formatted_token,
|
||||||
hostname,
|
hostname,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tranquil-types = { workspace = true }
|
tranquil-types = { workspace = true }
|
||||||
|
tranquil-infra = { workspace = true, features = ["cache-keys"] }
|
||||||
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::OAuthError;
|
use crate::OAuthError;
|
||||||
use crate::types::ClientAuth;
|
use crate::types::ClientAuth;
|
||||||
use tranquil_types::ClientId;
|
use tranquil_infra::cache_keys::{
|
||||||
|
oauth_client_jwks_cooldown_key, oauth_client_jwks_key, oauth_client_meta_key,
|
||||||
|
};
|
||||||
|
use tranquil_infra::{Cache, cached_json, write_json};
|
||||||
|
use tranquil_types::{
|
||||||
|
ClientId, JwksUri, ReachPolicy, dns_guard, redirect_policy, url_reach_permits,
|
||||||
|
};
|
||||||
|
|
||||||
|
const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClientMetadata {
|
pub struct ClientMetadata {
|
||||||
@@ -30,8 +37,12 @@ pub struct ClientMetadata {
|
|||||||
pub dpop_bound_access_tokens: Option<bool>,
|
pub dpop_bound_access_tokens: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub jwks: Option<serde_json::Value>,
|
pub jwks: Option<serde_json::Value>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(
|
||||||
pub jwks_uri: Option<String>,
|
default,
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
deserialize_with = "tranquil_types::http_url::deserialize_optional"
|
||||||
|
)]
|
||||||
|
pub jwks_uri: Option<JwksUri>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub application_type: Option<String>,
|
pub application_type: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -58,33 +69,24 @@ impl Default for ClientMetadata {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ClientMetadataCache {
|
pub struct ClientMetadataCache {
|
||||||
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
|
cache: Arc<dyn Cache>,
|
||||||
jwks_cache: Arc<RwLock<HashMap<String, CachedJwks>>>,
|
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
cache_ttl_secs: u64,
|
cache_ttl: Duration,
|
||||||
}
|
fetch_policy: ReachPolicy,
|
||||||
|
|
||||||
struct CachedMetadata {
|
|
||||||
metadata: ClientMetadata,
|
|
||||||
cached_at: std::time::Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CachedJwks {
|
|
||||||
jwks: serde_json::Value,
|
|
||||||
cached_at: std::time::Instant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientMetadataCache {
|
impl ClientMetadataCache {
|
||||||
pub fn new(cache_ttl_secs: u64) -> Self {
|
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration, fetch_policy: ReachPolicy) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
cache,
|
||||||
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
http_client: {
|
http_client: {
|
||||||
let builder = Client::builder()
|
let builder = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.connect_timeout(std::time::Duration::from_secs(10))
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
.pool_max_idle_per_host(10)
|
.pool_max_idle_per_host(10)
|
||||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||||
|
.redirect(redirect_policy(fetch_policy))
|
||||||
|
.dns_resolver(dns_guard(fetch_policy))
|
||||||
.user_agent(concat!(
|
.user_agent(concat!(
|
||||||
"Tranquil-PDS/",
|
"Tranquil-PDS/",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
@@ -92,9 +94,12 @@ impl ClientMetadataCache {
|
|||||||
));
|
));
|
||||||
#[cfg(feature = "native-tls-roots")]
|
#[cfg(feature = "native-tls-roots")]
|
||||||
let builder = builder.danger_accept_invalid_certs(true);
|
let builder = builder.danger_accept_invalid_certs(true);
|
||||||
builder.build().unwrap_or_else(|_| Client::new())
|
builder
|
||||||
|
.build()
|
||||||
|
.expect("failed to build client metadata HTTP client")
|
||||||
},
|
},
|
||||||
cache_ttl_secs,
|
cache_ttl,
|
||||||
|
fetch_policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +144,7 @@ impl ClientMetadataCache {
|
|||||||
response_types: vec!["code".into()],
|
response_types: vec!["code".into()],
|
||||||
scope,
|
scope,
|
||||||
token_endpoint_auth_method: Some("none".into()),
|
token_endpoint_auth_method: Some("none".into()),
|
||||||
dpop_bound_access_tokens: Some(false),
|
dpop_bound_access_tokens: Some(true),
|
||||||
jwks: None,
|
jwks: None,
|
||||||
jwks_uri: None,
|
jwks_uri: None,
|
||||||
application_type: Some("native".into()),
|
application_type: Some("native".into()),
|
||||||
@@ -150,26 +155,13 @@ impl ClientMetadataCache {
|
|||||||
if Self::is_loopback_client(client_id) {
|
if Self::is_loopback_client(client_id) {
|
||||||
return Self::build_loopback_metadata(client_id);
|
return Self::build_loopback_metadata(client_id);
|
||||||
}
|
}
|
||||||
{
|
cached_json(
|
||||||
let cache = self.cache.read().await;
|
self.cache.as_ref(),
|
||||||
if let Some(cached) = cache.get(client_id.as_str())
|
&oauth_client_meta_key(client_id),
|
||||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
|
self.cache_ttl,
|
||||||
{
|
|| self.fetch_metadata(client_id),
|
||||||
return Ok(cached.metadata.clone());
|
)
|
||||||
}
|
.await
|
||||||
}
|
|
||||||
let metadata = self.fetch_metadata(client_id).await?;
|
|
||||||
{
|
|
||||||
let mut cache = self.cache.write().await;
|
|
||||||
cache.insert(
|
|
||||||
client_id.to_string(),
|
|
||||||
CachedMetadata {
|
|
||||||
metadata: metadata.clone(),
|
|
||||||
cached_at: std::time::Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_jwks(
|
pub async fn get_jwks(
|
||||||
@@ -181,43 +173,57 @@ impl ClientMetadataCache {
|
|||||||
}
|
}
|
||||||
let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
|
let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
|
||||||
OAuthError::InvalidClient(
|
OAuthError::InvalidClient(
|
||||||
"Client using private_key_jwt must have jwks or jwks_uri".to_string(),
|
"Client using private_key_jwt must have jwks or a usable jwks_uri".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
{
|
cached_json(
|
||||||
let cache = self.jwks_cache.read().await;
|
self.cache.as_ref(),
|
||||||
if let Some(cached) = cache.get(jwks_uri)
|
&oauth_client_jwks_key(jwks_uri),
|
||||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
|
self.cache_ttl,
|
||||||
{
|
|| self.fetch_jwks(jwks_uri),
|
||||||
return Ok(cached.jwks.clone());
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_jwks(
|
||||||
|
&self,
|
||||||
|
metadata: &ClientMetadata,
|
||||||
|
) -> Result<Option<serde_json::Value>, OAuthError> {
|
||||||
|
match (&metadata.jwks, &metadata.jwks_uri) {
|
||||||
|
(None, Some(jwks_uri)) => {
|
||||||
|
let cooldown_key = oauth_client_jwks_cooldown_key(jwks_uri);
|
||||||
|
if self.cache.get(&cooldown_key).await.is_some() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let _ = self
|
||||||
|
.cache
|
||||||
|
.set(&cooldown_key, "1", JWKS_REFRESH_COOLDOWN)
|
||||||
|
.await;
|
||||||
|
self.fetch_and_store_jwks(jwks_uri).await.map(Some)
|
||||||
}
|
}
|
||||||
|
_ => Ok(None),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_and_store_jwks(
|
||||||
|
&self,
|
||||||
|
jwks_uri: &JwksUri,
|
||||||
|
) -> Result<serde_json::Value, OAuthError> {
|
||||||
let jwks = self.fetch_jwks(jwks_uri).await?;
|
let jwks = self.fetch_jwks(jwks_uri).await?;
|
||||||
{
|
write_json(
|
||||||
let mut cache = self.jwks_cache.write().await;
|
self.cache.as_ref(),
|
||||||
cache.insert(
|
&oauth_client_jwks_key(jwks_uri),
|
||||||
jwks_uri.clone(),
|
&jwks,
|
||||||
CachedJwks {
|
self.cache_ttl,
|
||||||
jwks: jwks.clone(),
|
)
|
||||||
cached_at: std::time::Instant::now(),
|
.await;
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(jwks)
|
Ok(jwks)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
|
async fn fetch_jwks(&self, jwks_uri: &JwksUri) -> Result<serde_json::Value, OAuthError> {
|
||||||
if !jwks_uri.starts_with("https://")
|
|
||||||
&& (!jwks_uri.starts_with("http://")
|
|
||||||
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
|
|
||||||
{
|
|
||||||
return Err(OAuthError::InvalidClient(
|
|
||||||
"jwks_uri must use https (except for localhost)".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.get(jwks_uri)
|
.get(jwks_uri.as_str())
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -243,22 +249,16 @@ impl ClientMetadataCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
|
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
|
||||||
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
|
let url = reqwest::Url::parse(client_id)
|
||||||
|
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
|
||||||
|
if !url_reach_permits(&url, self.fetch_policy) {
|
||||||
return Err(OAuthError::InvalidClient(
|
return Err(OAuthError::InvalidClient(
|
||||||
"client_id must be a URL".to_string(),
|
"client_id must be an https URL inside the allowed host reach".to_string(),
|
||||||
));
|
|
||||||
}
|
|
||||||
if client_id.starts_with("http://")
|
|
||||||
&& !client_id.contains("localhost")
|
|
||||||
&& !client_id.contains("127.0.0.1")
|
|
||||||
{
|
|
||||||
return Err(OAuthError::InvalidClient(
|
|
||||||
"Non-localhost client_id must use https".to_string(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.get(client_id.as_str())
|
.get(url)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -514,7 +514,29 @@ async fn verify_private_key_jwt_async(
|
|||||||
"client_assertion iat is in the future".to_string(),
|
"client_assertion iat is in the future".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
||||||
|
let signature_bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(parts[2])
|
||||||
|
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
|
||||||
let jwks = cache.get_jwks(metadata).await?;
|
let jwks = cache.get_jwks(metadata).await?;
|
||||||
|
match verify_assertion_signature(&jwks, kid, alg, &signing_input, &signature_bytes) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(cached_failure) => match cache.refresh_jwks(metadata).await {
|
||||||
|
Ok(Some(fresh)) => {
|
||||||
|
verify_assertion_signature(&fresh, kid, alg, &signing_input, &signature_bytes)
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => Err(cached_failure),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_assertion_signature(
|
||||||
|
jwks: &serde_json::Value,
|
||||||
|
kid: Option<&str>,
|
||||||
|
alg: &str,
|
||||||
|
signing_input: &str,
|
||||||
|
signature: &[u8],
|
||||||
|
) -> Result<(), OAuthError> {
|
||||||
let keys = jwks
|
let keys = jwks
|
||||||
.get("keys")
|
.get("keys")
|
||||||
.and_then(|k| k.as_array())
|
.and_then(|k| k.as_array())
|
||||||
@@ -531,10 +553,6 @@ async fn verify_private_key_jwt_async(
|
|||||||
"No matching key found in client JWKS".to_string(),
|
"No matching key found in client JWKS".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
|
||||||
let signature_bytes = URL_SAFE_NO_PAD
|
|
||||||
.decode(parts[2])
|
|
||||||
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
|
|
||||||
matching_keys
|
matching_keys
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|key| {
|
.filter(|key| {
|
||||||
@@ -544,12 +562,12 @@ async fn verify_private_key_jwt_async(
|
|||||||
.find_map(|key| {
|
.find_map(|key| {
|
||||||
let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
|
let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
|
||||||
match (alg, kty) {
|
match (alg, kty) {
|
||||||
("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes).ok(),
|
("ES256", "EC") => verify_es256(key, signing_input, signature).ok(),
|
||||||
("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes).ok(),
|
("ES384", "EC") => verify_es384(key, signing_input, signature).ok(),
|
||||||
("RS256" | "RS384" | "RS512", "RSA") => {
|
("RS256" | "RS384" | "RS512", "RSA") => {
|
||||||
verify_rsa(alg, key, &signing_input, &signature_bytes).ok()
|
verify_rsa(alg, key, signing_input, signature).ok()
|
||||||
}
|
}
|
||||||
("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes).ok(),
|
("EdDSA", "OKP") => verify_eddsa(key, signing_input, signature).ok(),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use tranquil_types::{ClientId, Did};
|
use tranquil_types::{AuthServerEndpoint, ClientId, Did, Issuer};
|
||||||
|
|
||||||
pub use tranquil_types::{AuthorizationCode, DeviceId, RefreshToken, RequestId, TokenId};
|
pub use tranquil_types::{AuthorizationCode, DeviceId, RefreshToken, RequestId, TokenId};
|
||||||
|
|
||||||
@@ -195,9 +195,9 @@ pub struct ProtectedResourceMetadata {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AuthorizationServerMetadata {
|
pub struct AuthorizationServerMetadata {
|
||||||
pub issuer: String,
|
pub issuer: Issuer,
|
||||||
pub authorization_endpoint: String,
|
pub authorization_endpoint: AuthServerEndpoint,
|
||||||
pub token_endpoint: String,
|
pub token_endpoint: AuthServerEndpoint,
|
||||||
pub jwks_uri: String,
|
pub jwks_uri: String,
|
||||||
pub registration_endpoint: Option<String>,
|
pub registration_endpoint: Option<String>,
|
||||||
pub scopes_supported: Option<Vec<String>>,
|
pub scopes_supported: Option<Vec<String>>,
|
||||||
@@ -206,7 +206,7 @@ pub struct AuthorizationServerMetadata {
|
|||||||
pub grant_types_supported: Option<Vec<String>>,
|
pub grant_types_supported: Option<Vec<String>>,
|
||||||
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
|
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
|
||||||
pub code_challenge_methods_supported: Option<Vec<String>>,
|
pub code_challenge_methods_supported: Option<Vec<String>>,
|
||||||
pub pushed_authorization_request_endpoint: Option<String>,
|
pub pushed_authorization_request_endpoint: Option<AuthServerEndpoint>,
|
||||||
pub require_pushed_authorization_requests: Option<bool>,
|
pub require_pushed_authorization_requests: Option<bool>,
|
||||||
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
|
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
|
||||||
pub authorization_response_iss_parameter_supported: Option<bool>,
|
pub authorization_response_iss_parameter_supported: Option<bool>,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ tranquil-auth = { workspace = true }
|
|||||||
tranquil-oauth = { workspace = true }
|
tranquil-oauth = { workspace = true }
|
||||||
tranquil-comms = { workspace = true }
|
tranquil-comms = { workspace = true }
|
||||||
tranquil-signal = { workspace = true }
|
tranquil-signal = { workspace = true }
|
||||||
tranquil-db = { workspace = true }
|
tranquil-db = { workspace = true, features = ["postgres"] }
|
||||||
tranquil-db-traits = { workspace = true }
|
tranquil-db-traits = { workspace = true }
|
||||||
tranquil-store = { workspace = true }
|
tranquil-store = { workspace = true }
|
||||||
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
||||||
@@ -86,6 +86,7 @@ frontend = []
|
|||||||
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tranquil-infra = { workspace = true, features = ["testing"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
ciborium = { workspace = true }
|
ciborium = { workspace = true }
|
||||||
ctor = { workspace = true }
|
ctor = { workspace = true }
|
||||||
|
|||||||
@@ -763,8 +763,7 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
|
|||||||
HandleValidationError::BannedWord => {
|
HandleValidationError::BannedWord => {
|
||||||
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
|
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
|
||||||
}
|
}
|
||||||
HandleValidationError::UnusableHandleDomain
|
HandleValidationError::UnusableHandleDomain => Self::InternalError(Some(e.to_string())),
|
||||||
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
|
|
||||||
_ => Self::InvalidHandle(Some(e.to_string())),
|
_ => Self::InvalidHandle(Some(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -776,6 +775,31 @@ impl From<jacquard_common::types::string::AtStrError> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<tranquil_types::InvalidEmailAddress> for ApiError {
|
||||||
|
fn from(_: tranquil_types::InvalidEmailAddress) -> Self {
|
||||||
|
Self::InvalidEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! invalid_request_from {
|
||||||
|
($($err:ty),* $(,)?) => {
|
||||||
|
$(
|
||||||
|
impl From<$err> for ApiError {
|
||||||
|
fn from(e: $err) -> Self {
|
||||||
|
Self::InvalidRequest(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid_request_from!(
|
||||||
|
tranquil_types::InvalidSignalUsername,
|
||||||
|
tranquil_types::InvalidTelegramUsername,
|
||||||
|
tranquil_types::InvalidDiscordUsername,
|
||||||
|
tranquil_types::InvalidRecipient,
|
||||||
|
);
|
||||||
|
|
||||||
impl From<crate::plc::PlcError> for ApiError {
|
impl From<crate::plc::PlcError> for ApiError {
|
||||||
fn from(e: crate::plc::PlcError) -> Self {
|
fn from(e: crate::plc::PlcError) -> Self {
|
||||||
use crate::plc::PlcError;
|
use crate::plc::PlcError;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user