mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 02:36:06 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fa88d79a | ||
|
|
596b9b15fd | ||
|
|
59934cc184 | ||
|
|
34a47e6e5a | ||
|
|
aca78bb8d3 | ||
|
|
25d7d24d4e | ||
|
|
b6274bb3c4 | ||
|
|
2a96a8f420 | ||
|
|
9ad70bda9e | ||
|
|
c3a8240154 | ||
|
|
1e2311f8fc | ||
|
|
17905115d8 | ||
|
|
6ed568dbfb | ||
|
|
d00d72895a | ||
|
|
2f1e22a950 | ||
|
|
946cb9740f | ||
|
|
0c7cccb14c | ||
|
|
4f37ac26cd | ||
|
|
bbe9f6f3b3 | ||
|
|
4e3b2f2af4 | ||
|
|
932b0c07d4 | ||
|
|
01a71ece7c | ||
|
|
8abb6cc741 | ||
|
|
2bfea64ffc | ||
|
|
e931268f7e | ||
|
|
14a086cb13 | ||
|
|
7244551ae1 | ||
|
|
01d93e44e7 | ||
|
|
fa50d7d54e | ||
|
|
19e7ec29f5 | ||
|
|
311fbfcb86 | ||
|
|
515c058006 | ||
|
|
aca6dd926e | ||
|
|
9c6730579e | ||
|
|
348ac887fc | ||
|
|
ecdda4c555 | ||
|
|
f17adc6f88 | ||
|
|
00ca223b5f | ||
|
|
9ccec274aa | ||
|
|
f2754efeb2 | ||
|
|
4416f50c87 |
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT r.record_cid AS \"record_cid!\"\n FROM records r\n WHERE r.repo_id = $1\n AND r.record_cid = ANY($2)\n AND NOT EXISTS (\n SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)\n WHERE k.collection = r.collection AND k.rkey = r.rkey\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record_cid!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "105807a41c7337e7aa46bace29ab613030fd4fbf6845baddab9c0b2009972c02"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1 AND repo_rev > $2\n ORDER BY repo_rev ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "block_cid!",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "30570ed3866840d1258c8768a5c8a23ade40700c05ddbbf7fc4f64bfa95b1ed4"
|
||||
}
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, email, email_verified, handle\n FROM users\n WHERE LOWER(email) = $1",
|
||||
"query": "SELECT id, did, email, email_verified\n FROM users\n WHERE LOWER(email) = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,11 +22,6 @@
|
||||
"ordinal": 3,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -38,9 +33,8 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b230a27fce54d4f79de4ffcc754bf6c7e5a889623e64cdd7aaec791b3553ed83"
|
||||
"hash": "55b5e5cb13e09c4261e0452ef7e21240b504096666692c8fbd5c7dcd58607bc7"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT block_cid AS \"block_cid!\" FROM user_blocks\n WHERE user_id = $1\n ORDER BY repo_rev ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "block_cid!",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "85cc0cd1e62a30fa67d415b7a01164f962a422513e8f0737553321fd9987a56c"
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
In order of importance:
|
||||
|
||||
- If your change involves how Tranquil implements atproto make sure its correct! See more below.
|
||||
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
|
||||
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
|
||||
- Run cargo fmt :P
|
||||
@@ -16,6 +17,60 @@ Things that would also be nice but aren't like, a pain in our side:
|
||||
|
||||
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
|
||||
|
||||
### How we define a "correct" PDS implementation
|
||||
|
||||
The atproto specs are notoriously imprecise, ambiguious,
|
||||
lacks specifications for large parts of the protocol and network (even including what implementing a PDS entails!)
|
||||
and is generally none specific.
|
||||
This is bad.
|
||||
We won't waste time here describing all the ways in which that is problematic,
|
||||
the important thing for Tranquil is that this means that "follows spec" is not sufficient to describe a "correct" PDS implementation.
|
||||
Thus we need to come up with a description of "correct".
|
||||
In order of importance the following rules describe what "correct" means for Tranquil:
|
||||
|
||||
- The specs take precedence.
|
||||
If the spec *is* specific enough then follow it.
|
||||
Even if the reference implementation doesn't.
|
||||
- If the specs aren't sufficiently specific
|
||||
rely on the reference implementation, potential supporting documents or discussions,
|
||||
and/or community sentiment or common sense.
|
||||
If the matter is still debated and/or PBCs opinion differs from community sentiment we generally side with the community.
|
||||
- Examples here include what features and APIs to implement,
|
||||
here we look at what the reference implementation implements
|
||||
as well as https://github.com/bluesky-social/atproto/discussions/2350 as a supporting document.
|
||||
Another example is whether `include` scopes are allowed to use a `*` `aud` parameter.
|
||||
Discussion here has happened in https://github.com/bluesky-social/atproto/issues/4490.
|
||||
PBC has voiced an opinion that this should be disallowed,
|
||||
community sentiment seems to strongly lean to allowing it. Tranquil allows it.
|
||||
- Please mark locations like this with a `// SPECAMB: ...` comment explaining the ambiguity
|
||||
and what parts of the reference implementation and/or supporting documents have been used as reference.
|
||||
- If the reference implementation has behaviour that is only ever relevant for the Bluesky application.
|
||||
Implementions of such behaviour **must** be gated behind a `bsky-support` cargo feature of the implementing crate.
|
||||
- Examples here include bluesky feedgen specific service proxying behaviour,
|
||||
the `app.bsky.actor.getPreferences` and `app.bsky.actor.putPreferences` APIs,
|
||||
and special handling of the `X-BSKY-TOPICS` HTTP header during service proxying.
|
||||
- Please add a comment next to these implementations with an explanation of the behaviour.
|
||||
- Most of these behaviours are required for proper functioning of the official Bluesky client, though not all.
|
||||
If the behaviour isn't required for the official client consider not implementing it.
|
||||
- One such behaviour that we have a *hard rule* to never implement is default proxying to a configured Bluesky appview
|
||||
for `app.bsky.*` APIs and as fallback for `com.atproto.repo.getRecord`.
|
||||
Many third-party Bluesky clients rely on this behaviour, the official client used to do the same but does not anymore.
|
||||
Third-party clients breaking because they don't specify an `atproto-proxy` header is thus *not* a Tranquil bug but a bug in said clients.
|
||||
- Bluesky is the only application that will ever recieve application specific behaviour like this.
|
||||
It does so only because such a big section of atproto usage is Bluesky
|
||||
and because Bluesky is the only application that can practically rely on application specific behaviour.
|
||||
Application specific behaviour for other applications may still be added to Tranquil if such behaviour is a Tranquil feature,
|
||||
for example for Tranquils rudimentary banned content moderation feature,
|
||||
and not something said application relies on for proper functioning.
|
||||
|
||||
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.
|
||||
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.
|
||||
Tranquil is a community atproto PDS, *not* a company-led Bluesky (or other atproto app) PDS.
|
||||
See also "Tranquil & the world" in docs/1_WELCOME_TO_TRANQUIL_PDS.md.
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Generated
+30
-75
@@ -2486,7 +2486,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7630,9 +7629,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"backon",
|
||||
"base32",
|
||||
@@ -7666,22 +7664,19 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-config",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-lexicon",
|
||||
"tranquil-pds",
|
||||
"tranquil-scopes",
|
||||
"tranquil-signal",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -7698,13 +7693,14 @@ dependencies = [
|
||||
"totp-rs",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7719,7 +7715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7733,7 +7729,6 @@ dependencies = [
|
||||
"rsa",
|
||||
"secrecy",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7745,15 +7740,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -7769,7 +7763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -7786,7 +7780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7802,18 +7796,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"thiserror 2.0.18",
|
||||
"tranquil-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -7825,6 +7818,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-types",
|
||||
"unicode-segmentation",
|
||||
"urlencoding",
|
||||
"wiremock",
|
||||
@@ -7832,7 +7826,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -7855,8 +7849,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
@@ -7880,6 +7875,7 @@ dependencies = [
|
||||
"tranquil-crypto",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-pds",
|
||||
"tranquil-scopes",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -7888,7 +7884,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -7900,7 +7896,6 @@ dependencies = [
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
@@ -7916,7 +7911,6 @@ dependencies = [
|
||||
"hmac",
|
||||
"http 1.4.0",
|
||||
"image",
|
||||
"infer",
|
||||
"ipld-core",
|
||||
"iroh-car",
|
||||
"jacquard-common",
|
||||
@@ -7958,7 +7952,6 @@ dependencies = [
|
||||
"tranquil-cache",
|
||||
"tranquil-comms",
|
||||
"tranquil-config",
|
||||
"tranquil-crypto",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-lexicon",
|
||||
@@ -7977,12 +7970,11 @@ dependencies = [
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -7994,7 +7986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -8023,7 +8015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -8034,12 +8026,13 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tranquil-types",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
@@ -8076,7 +8069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-signal"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8084,7 +8077,6 @@ dependencies = [
|
||||
"futures",
|
||||
"presage",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
@@ -8092,14 +8084,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tranquil-signal",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -8116,7 +8107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-store"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -8156,7 +8147,6 @@ dependencies = [
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-oauth",
|
||||
"tranquil-repo",
|
||||
"tranquil-store",
|
||||
"tranquil-types",
|
||||
"uuid",
|
||||
@@ -8165,7 +8155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -8187,15 +8177,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cid",
|
||||
"jacquard-common",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8247,12 +8240,6 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -9454,20 +9441,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zkcredential"
|
||||
version = "0.1.0"
|
||||
@@ -9516,30 +9489,12 @@ dependencies = [
|
||||
"zkcredential",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+1
-3
@@ -26,7 +26,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -47,7 +47,6 @@ tranquil-db = { path = "crates/tranquil-db" }
|
||||
tranquil-ripple = { path = "crates/tranquil-ripple" }
|
||||
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
|
||||
tranquil-pds = { path = "crates/tranquil-pds" }
|
||||
tranquil-server = { path = "crates/tranquil-server" }
|
||||
tranquil-sync = { path = "crates/tranquil-sync" }
|
||||
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
|
||||
tranquil-api = { path = "crates/tranquil-api" }
|
||||
@@ -142,7 +141,6 @@ 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-proto = "0.5"
|
||||
x509-parser = "0.18"
|
||||
zip = { version = "7.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
ciborium = "0.2"
|
||||
ctor = "0.6"
|
||||
|
||||
@@ -8,13 +8,10 @@ license.workspace = true
|
||||
tranquil-pds = { workspace = true }
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-db = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-lexicon = { workspace = true, features = ["resolve"] }
|
||||
tranquil-scopes = { workspace = true }
|
||||
tranquil-signal = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
backon = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
@@ -50,4 +47,7 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[features]
|
||||
bsky = ["bsky-support"]
|
||||
bsky-support = []
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
mod preferences;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
@@ -200,7 +200,8 @@ pub fn create_self_hosted_did_web(handle: &str) -> Result<Did, ApiError> {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(Did::from(format!("did:web:{}", encoded_handle)))
|
||||
Did::new(format!("did:web:{}", encoded_handle))
|
||||
.map_err(|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())))
|
||||
}
|
||||
|
||||
pub enum CredentialMatch {
|
||||
|
||||
@@ -392,7 +392,7 @@ pub async fn create_delegated_account(
|
||||
controller_scopes: input.controller_scopes.as_str().to_string(),
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: CidLink::from(repo.commit_cid.to_string()),
|
||||
commit_cid: CidLink::from(&repo.commit_cid),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
};
|
||||
|
||||
@@ -149,17 +149,21 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
}
|
||||
};
|
||||
|
||||
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
|
||||
|
||||
if let Some(ref h) = handle
|
||||
&& Handle::new(h).is_err()
|
||||
let handle = match parse_start_handle(
|
||||
interaction.data.as_ref().and_then(|d| d.options.as_deref()),
|
||||
)
|
||||
.map(Handle::new)
|
||||
.transpose()
|
||||
{
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: nel.oyster.cafe", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
discord_username = %discord_username,
|
||||
@@ -168,7 +172,6 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
"Received /start from Discord user"
|
||||
);
|
||||
|
||||
let handle = handle.map(Handle::from);
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
|
||||
@@ -65,7 +65,7 @@ async fn try_reactivate_migration(
|
||||
.await
|
||||
{
|
||||
Ok(reactivated) => {
|
||||
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
|
||||
info!(did = %did, old_handle = ?reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
|
||||
let secret_key_bytes = match state
|
||||
.repos
|
||||
.user
|
||||
@@ -202,7 +202,8 @@ pub async fn create_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
|
||||
.expect("com.atproto.server.createAccount is a valid NSID");
|
||||
match verifier
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
@@ -448,7 +449,7 @@ pub async fn create_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let commit_cid = CidLink::from(repo.commit_cid.to_string());
|
||||
let commit_cid = CidLink::from(&repo.commit_cid);
|
||||
let repo_rev = repo.repo_rev.clone();
|
||||
|
||||
let birthdate_pref = if tranquil_config::get().server.age_assurance_override {
|
||||
|
||||
@@ -51,7 +51,9 @@ pub async fn resolve_handle(
|
||||
}
|
||||
};
|
||||
let cache_key = tranquil_pds::cache_keys::handle_key(&handle);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
if let Some(cached) = state.cache.get(&cache_key).await
|
||||
&& let Ok(did) = tranquil_pds::Did::new(cached)
|
||||
{
|
||||
return DidResponse::response(did).into_response();
|
||||
}
|
||||
let user = state.repos.user.get_by_handle(&handle).await;
|
||||
@@ -425,10 +427,15 @@ pub async fn verify_did_web(
|
||||
let expected_multibase = expected_signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.ok_or(DidWebVerifyError::InvalidSigningKey)?;
|
||||
let did_prefixed_key_id = format!("{}#atproto", did);
|
||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
||||
vm["publicKeyMultibase"]
|
||||
let is_atproto_method = vm["id"]
|
||||
.as_str()
|
||||
.is_some_and(|pk| pk == expected_multibase)
|
||||
.is_some_and(|id| id == "#atproto" || id == did_prefixed_key_id);
|
||||
is_atproto_method
|
||||
&& vm["publicKeyMultibase"]
|
||||
.as_str()
|
||||
.is_some_and(|pk| pk == expected_multibase)
|
||||
});
|
||||
if !has_matching_key {
|
||||
return Err(DidWebVerifyError::KeyMismatch(
|
||||
|
||||
@@ -109,7 +109,7 @@ pub async fn init_genesis_repo(
|
||||
encrypted_key_bytes,
|
||||
commit_cid,
|
||||
mst_root_cid: mst_root,
|
||||
repo_rev: Tid::from(rev.as_ref().to_string()),
|
||||
repo_rev: Tid::from(rev.clone()),
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
@@ -198,27 +198,31 @@ pub async fn sequence_new_account(
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
state,
|
||||
did,
|
||||
&CidLink::from(repo.commit_cid.to_string()),
|
||||
&CidLink::from(&repo.commit_cid),
|
||||
Some(&repo.repo_rev),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": display_name
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
state,
|
||||
did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
|
||||
#[cfg(feature = "bsky")]
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": display_name
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
state,
|
||||
did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
pub mod actor;
|
||||
// BSKY: Bluesky requires PDSs to implement its private preferences API
|
||||
#[cfg(feature = "bsky-support")]
|
||||
pub mod actor {
|
||||
mod preferences;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
}
|
||||
pub mod admin;
|
||||
#[cfg(feature = "bsky")]
|
||||
pub mod age_assurance;
|
||||
pub mod common;
|
||||
pub mod delegation;
|
||||
@@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
let blob_body_limit =
|
||||
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
|
||||
|
||||
axum::Router::new()
|
||||
let router = axum::Router::new()
|
||||
.route("/_health", get(server::health))
|
||||
.route(
|
||||
"/com.atproto.server.describeServer",
|
||||
@@ -373,14 +380,6 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
post(admin::update_subject_status),
|
||||
)
|
||||
.route("/com.atproto.admin.sendEmail", post(admin::send_email))
|
||||
.route(
|
||||
"/app.bsky.actor.getPreferences",
|
||||
get(actor::get_preferences),
|
||||
)
|
||||
.route(
|
||||
"/app.bsky.actor.putPreferences",
|
||||
post(actor::put_preferences),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.temp.checkSignupQueue",
|
||||
get(temp::check_signup_queue),
|
||||
@@ -438,7 +437,21 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route(
|
||||
"/_delegation.resolveController",
|
||||
get(delegation::resolve_controller),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bsky-support")]
|
||||
let router = router
|
||||
.route(
|
||||
"/app.bsky.actor.getPreferences",
|
||||
get(actor::get_preferences),
|
||||
)
|
||||
.route(
|
||||
"/app.bsky.actor.putPreferences",
|
||||
post(actor::put_preferences),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bsky")]
|
||||
let router = router
|
||||
.route(
|
||||
"/app.bsky.ageassurance.getState",
|
||||
get(age_assurance::get_state),
|
||||
@@ -446,7 +459,9 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
.route(
|
||||
"/app.bsky.unspecced.getAgeAssuranceState",
|
||||
get(age_assurance::get_age_assurance_state),
|
||||
)
|
||||
);
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
pub fn well_known_api_routes() -> axum::Router<AppState> {
|
||||
|
||||
@@ -6,6 +6,7 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_pds::api::ApiError;
|
||||
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
|
||||
@@ -13,6 +14,9 @@ use tranquil_pds::auth::{AnyUser, Auth};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Nsid};
|
||||
|
||||
static CREATE_REPORT_NSID: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReportReasonType {
|
||||
#[serde(rename = "com.atproto.moderation.defs#reasonSpam")]
|
||||
@@ -145,11 +149,10 @@ async fn proxy_to_report_service(
|
||||
},
|
||||
};
|
||||
|
||||
let report_lxm = Nsid::from("com.atproto.moderation.createReport".to_string());
|
||||
let service_token = match tranquil_pds::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
service_did,
|
||||
Some(&report_lxm),
|
||||
Some(&CREATE_REPORT_NSID),
|
||||
&key_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
|
||||
@@ -147,12 +147,14 @@ pub async fn request_channel_verification(
|
||||
match channel {
|
||||
CommsChannel::Email => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let fallback_handle = Handle::from("user".to_string());
|
||||
let handle = handle.ok_or_else(|| {
|
||||
ApiError::InternalError(Some("Email verification requires a handle".into()))
|
||||
})?;
|
||||
tranquil_pds::comms::comms_repo::enqueue_email_update(
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
identifier,
|
||||
handle.unwrap_or(&fallback_handle),
|
||||
handle,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ use multihash::Multihash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::pin::Pin;
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
|
||||
@@ -19,6 +20,9 @@ use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{CidLink, Did, Nsid};
|
||||
use tranquil_pds::util::get_header_str;
|
||||
|
||||
static UPLOAD_BLOB_NSID: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "com.atproto.repo.uploadBlob".parse().unwrap());
|
||||
|
||||
fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
|
||||
if let Some(kind) = infer::get(data) {
|
||||
let detected = kind.mime_type().to_string();
|
||||
@@ -48,7 +52,7 @@ pub async fn upload_blob(
|
||||
) -> Result<Response, ApiError> {
|
||||
let (did, controller_did): (Did, Option<Did>) = match &auth {
|
||||
AuthAny::Service(service) => {
|
||||
service.require_lxm(&Nsid::from("com.atproto.repo.uploadBlob".to_string()))?;
|
||||
service.require_lxm(&UPLOAD_BLOB_NSID)?;
|
||||
(service.did.clone(), None)
|
||||
}
|
||||
AuthAny::User(user) => {
|
||||
|
||||
@@ -233,7 +233,7 @@ pub async fn import_repo(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev_str.clone());
|
||||
let new_rev_tid = tranquil_pds::types::Tid::from(new_rev.clone());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
@@ -243,17 +243,40 @@ pub async fn import_repo(
|
||||
error!("Failed to update repo root: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
|
||||
all_block_cids.push(new_root_cid.to_bytes());
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &all_block_cids, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
match tranquil_pds::scheduled::collect_current_repo_blocks(
|
||||
&state.block_store,
|
||||
&new_root_cid,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reachable) => {
|
||||
if !reachable.is_complete() {
|
||||
error!(
|
||||
unreadable = reachable.unreadable,
|
||||
"scheduling a structural repair because the imported repo walk could \
|
||||
not read every block"
|
||||
);
|
||||
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
|
||||
}
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &reachable.block_cids, &new_rev_tid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to walk the imported repo: {:?}. The root is already updated and \
|
||||
a scheduled structural repair will rebuild user_blocks",
|
||||
e
|
||||
);
|
||||
tranquil_pds::repo_ops::schedule_repo_repair(&state, user_id);
|
||||
}
|
||||
}
|
||||
let new_root_str = new_root_cid.to_string();
|
||||
info!(
|
||||
"Created new commit for imported repo: cid={}, rev={}",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub use tranquil_pds::repo_ops::*;
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::str::FromStr;
|
||||
use tracing::warn;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
|
||||
@@ -181,8 +182,15 @@ async fn create_record_inner(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let conflict_rkey = Rkey::from(conflict_rkey_str.to_string());
|
||||
let conflict_collection = Nsid::from(conflict_col_str.to_string());
|
||||
let (Ok(conflict_rkey), Ok(conflict_collection)) =
|
||||
(Rkey::new(conflict_rkey_str), Nsid::new(conflict_col_str))
|
||||
else {
|
||||
warn!(
|
||||
uri = %conflict_uri,
|
||||
"skipping a backlink conflict whose stored URI doesn't parse"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let conflict_key = format!("{}/{}", conflict_collection, conflict_rkey);
|
||||
|
||||
let prev_cid = match mst.get(&conflict_key).await {
|
||||
@@ -373,11 +381,8 @@ async fn put_record_inner(
|
||||
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
|
||||
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
|
||||
let record_cid = ctx
|
||||
.tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
let record_cid = jacquard_repo::mst::util::compute_cid(&record_bytes)
|
||||
.map_err(|_| ApiError::InvalidRecord("Failed to compute record CID".into()))?;
|
||||
|
||||
if existing_cid == Some(record_cid) {
|
||||
return Ok(PutRecordOutput {
|
||||
@@ -388,6 +393,11 @@ async fn put_record_inner(
|
||||
});
|
||||
}
|
||||
|
||||
ctx.tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
|
||||
|
||||
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
|
||||
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
|
||||
Some(prev_cid) => {
|
||||
|
||||
@@ -27,7 +27,8 @@ pub struct CheckAccountStatusOutput {
|
||||
pub activated: bool,
|
||||
pub valid_did: bool,
|
||||
pub repo_commit: String,
|
||||
pub repo_rev: Tid,
|
||||
#[serde(serialize_with = "serialize_optional_rev")]
|
||||
pub repo_rev: Option<Tid>,
|
||||
pub repo_blocks: i64,
|
||||
pub indexed_records: i64,
|
||||
pub private_state_values: i64,
|
||||
@@ -35,6 +36,13 @@ pub struct CheckAccountStatusOutput {
|
||||
pub imported_blobs: i64,
|
||||
}
|
||||
|
||||
fn serialize_optional_rev<S: serde::Serializer>(
|
||||
rev: &Option<Tid>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(rev.as_ref().map_or("", Tid::as_str))
|
||||
}
|
||||
|
||||
pub async fn check_account_status(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
@@ -65,23 +73,20 @@ pub async fn check_account_status(
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let repo_rev = if let Some(rev) = repo_rev_from_db {
|
||||
rev
|
||||
} else if !repo_commit.is_empty() {
|
||||
if let Ok(cid) = Cid::from_str(&repo_commit) {
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
let repo_rev = match (repo_rev_from_db, Cid::from_str(&repo_commit)) {
|
||||
(Some(rev), _) => Some(rev),
|
||||
(None, Ok(cid)) => state
|
||||
.block_store
|
||||
.get(&cid)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|block| {
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| Tid::from(c.rev().to_string()))
|
||||
.unwrap_or_else(|| Tid::from(String::new()))
|
||||
} else {
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
Tid::from(String::new())
|
||||
}
|
||||
} else {
|
||||
Tid::from(String::new())
|
||||
.map(|commit| Tid::from(commit.rev().clone()))
|
||||
}),
|
||||
(None, Err(_)) => None,
|
||||
};
|
||||
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
|
||||
let imported_blobs: i64 = state
|
||||
@@ -449,7 +454,7 @@ pub async fn activate_account(
|
||||
if let Ok(Some(block)) = state.block_store.get(&cid).await {
|
||||
Commit::from_cbor(&block)
|
||||
.ok()
|
||||
.map(|c| Tid::from(c.rev().to_string()))
|
||||
.map(|c| Tid::from(c.rev().clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub async fn create_invite_code(
|
||||
match state
|
||||
.repos
|
||||
.infra
|
||||
.create_invite_code(&code, input.use_count, Some(&for_account))
|
||||
.create_invite_code(&code, input.use_count, &for_account)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(Json(CreateInviteCodeOutput { code })),
|
||||
@@ -115,7 +115,7 @@ pub async fn create_invite_codes(
|
||||
async move {
|
||||
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
|
||||
infra_repo
|
||||
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
|
||||
.create_invite_codes_batch(&codes, use_count, admin_user_id, &account)
|
||||
.await
|
||||
.map(|_| AccountCodes { account, codes })
|
||||
}
|
||||
@@ -188,27 +188,24 @@ pub async fn get_account_invite_codes(
|
||||
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
|
||||
let infra_repo = state.repos.infra.clone();
|
||||
async move {
|
||||
let uses = infra_repo
|
||||
let uses: Vec<InviteCodeUse> = infra_repo
|
||||
.get_invite_code_uses(&info.code)
|
||||
.await
|
||||
.map(|use_rows| {
|
||||
use_rows
|
||||
.into_iter()
|
||||
.map(|u| InviteCodeUse {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.log_db_err("fetching invite code uses")?
|
||||
.into_iter()
|
||||
.map(|u| InviteCodeUse {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.collect();
|
||||
|
||||
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
|
||||
if !include_used && use_count >= info.available_uses {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Some(InviteCode {
|
||||
Ok(Some(InviteCode {
|
||||
code: info.code,
|
||||
available: info.available_uses,
|
||||
disabled: false,
|
||||
@@ -219,11 +216,16 @@ pub async fn get_account_invite_codes(
|
||||
.unwrap_or_else(|| "admin".to_string()),
|
||||
created_at: info.created_at.to_rfc3339(),
|
||||
uses,
|
||||
})
|
||||
}))
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
|
||||
let codes: Vec<InviteCode> = codes
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<Option<InviteCode>>, ApiError>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
Ok(Json(GetAccountInviteCodesOutput { codes }))
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ pub async fn create_passkey_account(
|
||||
let token = extracted.token;
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
let create_account_lxm = Nsid::from("com.atproto.server.createAccount".to_string());
|
||||
let create_account_lxm = Nsid::new("com.atproto.server.createAccount")
|
||||
.expect("com.atproto.server.createAccount is a valid NSID");
|
||||
match verifier
|
||||
.verify_service_token(&token, Some(&create_account_lxm))
|
||||
.await
|
||||
@@ -313,7 +314,7 @@ pub async fn create_passkey_account(
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
reserved_key_id,
|
||||
commit_cid: tranquil_types::CidLink::from(repo.commit_cid.to_string()),
|
||||
commit_cid: tranquil_types::CidLink::from(&repo.commit_cid),
|
||||
repo_rev: repo.repo_rev.clone(),
|
||||
genesis_block_cids: repo.genesis_block_cids,
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
|
||||
@@ -45,15 +45,23 @@ pub async fn request_password_reset(
|
||||
None
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(
|
||||
normalized,
|
||||
&Handle::from(normalized_handle.as_str().to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let lookup = match Handle::new(normalized_handle.as_str()) {
|
||||
Ok(handle) => {
|
||||
state
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_email_or_handle(normalized, &handle)
|
||||
.await
|
||||
}
|
||||
Err(_) => state
|
||||
.repos
|
||||
.user
|
||||
.get_by_email(normalized)
|
||||
.await
|
||||
.map(|user| user.map(|user| user.id)),
|
||||
};
|
||||
|
||||
let user_id = match lookup {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
info!("Password reset requested for unknown identifier");
|
||||
|
||||
@@ -21,7 +21,8 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> Did {
|
||||
multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX);
|
||||
multicodec_key.extend_from_slice(&compressed_pubkey);
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
Did::from(format!("did:key:{}", encoded))
|
||||
Did::new(format!("did:key:{}", encoded))
|
||||
.expect("did:key with a multibase-encoded secp256k1 key is a valid DID")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -62,8 +62,20 @@ pub async fn handle_telegram_webhook(
|
||||
&& let Some(from) = message.from
|
||||
&& let Some(username) = from.username
|
||||
{
|
||||
let handle =
|
||||
parse_start_handle(message.text.as_deref()).map(tranquil_types::Handle::from);
|
||||
let handle = match parse_start_handle(message.text.as_deref())
|
||||
.map(tranquil_types::Handle::new)
|
||||
.transpose()
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
telegram_username = %username,
|
||||
error = %e,
|
||||
"Ignoring /start with an invalid handle"
|
||||
);
|
||||
return StatusCode::OK;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
telegram_username = %username,
|
||||
|
||||
@@ -58,9 +58,16 @@ pub async fn dereference_scope(
|
||||
|
||||
for part in scope_parts {
|
||||
if let Some(cid_str) = part.strip_prefix("ref:") {
|
||||
let cache_key = tranquil_pds::cache_keys::scope_ref_key(
|
||||
&tranquil_types::CidLink::from(cid_str.to_string()),
|
||||
);
|
||||
let cid = match Cid::from_str(cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let cache_key =
|
||||
tranquil_pds::cache_keys::scope_ref_key(&tranquil_types::CidLink::from(&cid));
|
||||
if let Some(cached) = state.cache.get(&cache_key).await {
|
||||
for s in cached.split_whitespace() {
|
||||
if !resolved_scopes.contains(&s.to_string()) {
|
||||
@@ -70,14 +77,6 @@ pub async fn dereference_scope(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cid = match Cid::from_str(cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let block_bytes = match state.block_store.get(&cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tranquil_pds::api::SuccessResponse;
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmChannelVerificationInput {
|
||||
pub channel: tranquil_db_traits::CommsChannel,
|
||||
pub identifier: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn confirm_channel_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ConfirmChannelVerificationInput>,
|
||||
) -> Response {
|
||||
let token_input = crate::server::VerifyTokenInput {
|
||||
token: input.code,
|
||||
identifier: input.identifier,
|
||||
};
|
||||
|
||||
match crate::server::verify_token_internal(&state, token_input).await {
|
||||
Ok(_output) => SuccessResponse::ok().into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
valkey = ["dep:redis"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -19,7 +19,6 @@ reqwest = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -6,4 +6,3 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
confique = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -853,18 +853,27 @@ pub struct FirehoseConfig {
|
||||
pub max_concurrent_repo_exports: usize,
|
||||
|
||||
/// List of relay / crawler notification URLs.
|
||||
#[config(env = "CRAWLERS", parse_env = split_comma_list)]
|
||||
pub crawlers: Option<Vec<String>>,
|
||||
}
|
||||
#[config(env = "CRAWLERS", parse_env = split_comma_list, default = [
|
||||
// If you know of more relays it makes sense to have here by all means make a PR!
|
||||
// All we request is that you only add "major" relays.
|
||||
// What exactly "major" means is up to interpretation and we will make the final call,
|
||||
// but a good rule of thumb is "most people are likely to add the relay if they know about it"
|
||||
|
||||
impl FirehoseConfig {
|
||||
/// Returns the list of crawler URLs, falling back to `["https://bsky.network"]`
|
||||
/// when none are configured.
|
||||
pub fn crawler_list(&self) -> Vec<String> {
|
||||
self.crawlers
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["https://bsky.network".to_string()])
|
||||
}
|
||||
// Microcosm relays
|
||||
"https://relay.fire.hose.cam",
|
||||
"https://relay3.fr.hose.cam",
|
||||
// PBC relay
|
||||
"https://bsky.network",
|
||||
// firehose.network relays
|
||||
"https://northamerica.firehose.network",
|
||||
"https://europe.firehose.network",
|
||||
"https://asia.firehose.network",
|
||||
// Blacksky relay
|
||||
"https://atproto.africa",
|
||||
// UpCloud relay
|
||||
"https://relay.upcloud.world",
|
||||
])]
|
||||
pub crawlers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
|
||||
@@ -23,7 +23,8 @@ pub struct DelegationGrant {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<Handle>,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -32,6 +33,7 @@ pub struct DelegatedAccountInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInfo {
|
||||
pub did: Did,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<Handle>,
|
||||
pub granted_scopes: DbScope,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ColumnRef {
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
}
|
||||
|
||||
impl ColumnRef {
|
||||
pub const fn new(table: &'static str, column: &'static str) -> Self {
|
||||
Self { table, column }
|
||||
}
|
||||
|
||||
pub const fn table(&self) -> &'static str {
|
||||
self.table
|
||||
}
|
||||
|
||||
pub const fn column(&self) -> &'static str {
|
||||
self.column
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColumnRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}.{}", self.table, self.column)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
#[error("Database query error: {0}")]
|
||||
@@ -29,6 +55,9 @@ pub enum DbError {
|
||||
#[error("Corrupt data in column: {0}")]
|
||||
CorruptData(&'static str),
|
||||
|
||||
#[error("Column {0} has a value that isn't valid for its type")]
|
||||
InvalidColumn(ColumnRef),
|
||||
|
||||
#[error("Other database error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
use_count: i32,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<bool, DbError>;
|
||||
|
||||
async fn create_invite_codes_batch(
|
||||
@@ -258,7 +258,7 @@ pub trait InfraRepository: Send + Sync {
|
||||
codes: &[InviteCode],
|
||||
use_count: i32,
|
||||
created_by_user: Uuid,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn get_invite_code_available_uses(
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use delegation::{
|
||||
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
|
||||
DelegationRepository,
|
||||
};
|
||||
pub use error::DbError;
|
||||
pub use error::{ColumnRef, DbError};
|
||||
pub use infra::{
|
||||
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
|
||||
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
|
||||
|
||||
@@ -410,6 +410,13 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_record_by_cid(&self, cid: &CidLink)
|
||||
-> Result<Option<RecordWithTakedown>, DbError>;
|
||||
|
||||
async fn referenced_record_cids(
|
||||
&self,
|
||||
repo_id: Uuid,
|
||||
cids: &[CidLink],
|
||||
excluded_keys: &[(&Nsid, &Rkey)],
|
||||
) -> Result<Vec<CidLink>, DbError>;
|
||||
|
||||
async fn set_record_takedown(
|
||||
&self,
|
||||
cid: &CidLink,
|
||||
@@ -432,7 +439,7 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &Tid,
|
||||
since_rev: Option<&Tid>,
|
||||
) -> Result<Vec<Vec<u8>>, DbError>;
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
@@ -670,7 +670,6 @@ pub struct UserForVerification {
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1119,7 +1118,7 @@ pub struct MigrationReactivationInput {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactivatedAccountInfo {
|
||||
pub user_id: Uuid,
|
||||
pub old_handle: Handle,
|
||||
pub old_handle: Option<Handle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -5,9 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["postgres"]
|
||||
postgres = []
|
||||
sqlite = []
|
||||
|
||||
[dependencies]
|
||||
tranquil-db-traits = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,8 @@ use tranquil_db_traits::{Backlink, BacklinkRepository, DbError};
|
||||
use tranquil_types::{AtUri, Nsid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::column_vec;
|
||||
use super::user::map_sqlx_error;
|
||||
|
||||
pub struct PostgresBacklinkRepository {
|
||||
@@ -49,7 +51,7 @@ impl BacklinkRepository for PostgresBacklinkRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(Into::into).collect())
|
||||
column_vec(results, col::BACKLINKS_URI)
|
||||
}
|
||||
|
||||
async fn add_backlinks(&self, repo_id: Uuid, backlinks: &[Backlink]) -> Result<(), DbError> {
|
||||
|
||||
@@ -6,7 +6,9 @@ use tranquil_db_traits::{
|
||||
use tranquil_types::{AtUri, CidLink, Did, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, column_vec, opt_column};
|
||||
|
||||
pub struct PostgresBlobRepository {
|
||||
pool: PgPool,
|
||||
@@ -42,7 +44,7 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::BLOBS_CID)
|
||||
}
|
||||
|
||||
async fn get_blob_metadata(&self, cid: &CidLink) -> Result<Option<BlobMetadata>, DbError> {
|
||||
@@ -73,10 +75,14 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| BlobWithTakedown {
|
||||
cid: CidLink::from(r.cid),
|
||||
takedown_ref: r.takedown_ref,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(BlobWithTakedown {
|
||||
cid: column(r.cid, col::BLOBS_CID)?,
|
||||
takedown_ref: r.takedown_ref,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_blob_storage_key(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
|
||||
@@ -109,7 +115,7 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(CidLink::from).collect())
|
||||
column_vec(results, col::BLOBS_CID)
|
||||
}
|
||||
|
||||
async fn list_blobs_since_rev(&self, did: &Did, since: &Tid) -> Result<Vec<CidLink>, DbError> {
|
||||
@@ -124,7 +130,7 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results.into_iter().map(CidLink::from).collect())
|
||||
column_vec(results, col::REPO_SEQ_BLOBS)
|
||||
}
|
||||
|
||||
async fn count_blobs_by_user(&self, user_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -244,13 +250,15 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| MissingBlobInfo {
|
||||
blob_cid: CidLink::from(r.blob_cid),
|
||||
record_uri: AtUri::from(r.record_uri),
|
||||
.map(|r| {
|
||||
Ok(MissingBlobInfo {
|
||||
blob_cid: column(r.blob_cid, col::RECORD_BLOBS_BLOB_CID)?,
|
||||
record_uri: column(r.record_uri, col::RECORD_BLOBS_RECORD_URI)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_distinct_record_blobs(&self, repo_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -277,13 +285,15 @@ impl BlobRepository for PostgresBlobRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| BlobForExport {
|
||||
cid: CidLink::from(r.cid),
|
||||
storage_key: r.storage_key,
|
||||
mime_type: r.mime_type,
|
||||
.map(|r| {
|
||||
Ok(BlobForExport {
|
||||
cid: column(r.cid, col::BLOBS_CID)?,
|
||||
storage_key: r.storage_key,
|
||||
mime_type: r.mime_type,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use tranquil_db_traits::{
|
||||
use tranquil_types::Did;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")]
|
||||
@@ -166,16 +168,19 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| DelegationGrant {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
controller_did: r.controller_did.into(),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
granted_by: r.granted_by.into(),
|
||||
revoked_at: r.revoked_at,
|
||||
revoked_by: r.revoked_by.map(Into::into),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(DelegationGrant {
|
||||
id: r.id,
|
||||
delegated_did: column(r.delegated_did, col::ACCOUNT_DELEGATIONS_DELEGATED_DID)?,
|
||||
controller_did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
granted_by: column(r.granted_by, col::ACCOUNT_DELEGATIONS_GRANTED_BY)?,
|
||||
revoked_at: r.revoked_at,
|
||||
revoked_by: opt_column(r.revoked_by, col::ACCOUNT_DELEGATIONS_REVOKED_BY)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_delegations_for_account(
|
||||
@@ -205,17 +210,18 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ControllerInfo {
|
||||
did: r.controller_did.into(),
|
||||
handle: r.handle.map(Into::into),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
is_local: r.is_local,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(ControllerInfo {
|
||||
did: column(r.controller_did, col::ACCOUNT_DELEGATIONS_CONTROLLER_DID)?,
|
||||
handle: r.handle.and_then(|h| legacy_column(h, col::USERS_HANDLE)),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
is_active: r.is_active,
|
||||
is_local: r.is_local,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_accounts_controlled_by(
|
||||
@@ -243,15 +249,16 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DelegatedAccountInfo {
|
||||
did: r.did.into(),
|
||||
handle: r.handle.into(),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(DelegatedAccountInfo {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE),
|
||||
granted_scopes: DbScope::from_db(r.granted_scopes),
|
||||
granted_at: r.granted_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_active_controllers(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
@@ -353,20 +360,27 @@ impl DelegationRepository for PostgresDelegationRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AuditLogEntry {
|
||||
id: r.id,
|
||||
delegated_did: r.delegated_did.into(),
|
||||
actor_did: r.actor_did.into(),
|
||||
controller_did: r.controller_did.map(Into::into),
|
||||
action_type: r.action_type.into(),
|
||||
action_details: r.action_details,
|
||||
ip_address: r.ip_address,
|
||||
user_agent: r.user_agent,
|
||||
created_at: r.created_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AuditLogEntry {
|
||||
id: r.id,
|
||||
delegated_did: column(
|
||||
r.delegated_did,
|
||||
col::DELEGATION_AUDIT_LOG_DELEGATED_DID,
|
||||
)?,
|
||||
actor_did: column(r.actor_did, col::DELEGATION_AUDIT_LOG_ACTOR_DID)?,
|
||||
controller_did: opt_column(
|
||||
r.controller_did,
|
||||
col::DELEGATION_AUDIT_LOG_CONTROLLER_DID,
|
||||
)?,
|
||||
action_type: r.action_type.into(),
|
||||
action_details: r.action_details,
|
||||
ip_address: r.ip_address,
|
||||
user_agent: r.user_agent,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn count_audit_log_entries(&self, delegated_did: &Did) -> Result<i64, DbError> {
|
||||
|
||||
@@ -7,10 +7,12 @@ use tranquil_db_traits::{
|
||||
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
|
||||
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
|
||||
};
|
||||
use tranquil_types::{CidLink, Did, Handle, InviteCode};
|
||||
use tranquil_types::{CidLink, Did, InviteCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
|
||||
pub struct PostgresInfraRepository {
|
||||
pool: PgPool,
|
||||
@@ -153,9 +155,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
&self,
|
||||
code: &InviteCode,
|
||||
use_count: i32,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<bool, DbError> {
|
||||
let for_account_str = for_account.map(|d| d.as_str());
|
||||
let for_account_str = for_account.as_str();
|
||||
let result = sqlx::query!(
|
||||
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
|
||||
SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#,
|
||||
@@ -175,9 +177,9 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
codes: &[InviteCode],
|
||||
use_count: i32,
|
||||
created_by_user: Uuid,
|
||||
for_account: Option<&Did>,
|
||||
for_account: &Did,
|
||||
) -> Result<(), DbError> {
|
||||
let for_account_str = for_account.map(|d| d.as_str());
|
||||
let for_account_str = for_account.as_str();
|
||||
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
|
||||
@@ -250,17 +252,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: None,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
|
||||
@@ -278,11 +282,13 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: code.clone(),
|
||||
used_by_did: Did::from(r.did),
|
||||
used_by_handle: Some(Handle::from(r.handle)),
|
||||
used_at: r.used_at,
|
||||
.filter_map(|r| {
|
||||
Some(InviteCodeUse {
|
||||
code: code.clone(),
|
||||
used_by_did: legacy_column(r.did, col::USERS_DID)?,
|
||||
used_by_handle: legacy_column(r.handle, col::USERS_HANDLE),
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -436,10 +442,10 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| (r.id, Did::from(r.did)))
|
||||
.collect())
|
||||
.map(|r| Ok((r.id, column(r.did, col::USERS_DID)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_uses_batch(
|
||||
@@ -459,15 +465,17 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeUse {
|
||||
code: InviteCode::from(r.code),
|
||||
used_by_did: Did::from(r.did),
|
||||
used_by_handle: None,
|
||||
used_at: r.used_at,
|
||||
.map(|r| {
|
||||
Ok(InviteCodeUse {
|
||||
code: InviteCode::from(r.code),
|
||||
used_by_did: column(r.did, col::USERS_DID)?,
|
||||
used_by_handle: None,
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invites_created_by_user(
|
||||
@@ -485,17 +493,19 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_info(
|
||||
@@ -513,14 +523,18 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_invite_codes_by_users(
|
||||
@@ -539,22 +553,22 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(results
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
Ok((
|
||||
r.created_by_user,
|
||||
InviteCodeInfo {
|
||||
code: InviteCode::from(r.code),
|
||||
available_uses: r.available_uses,
|
||||
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
|
||||
for_account: Some(Did::from(r.for_account)),
|
||||
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
|
||||
created_at: r.created_at,
|
||||
created_by: Some(Did::from(r.created_by)),
|
||||
created_by: Some(column(r.created_by, col::USERS_DID)?),
|
||||
},
|
||||
)
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_invite_code_used_by_user(
|
||||
@@ -683,10 +697,14 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| DeletionRequest {
|
||||
did: Did::from(r.did),
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(DeletionRequest {
|
||||
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> {
|
||||
@@ -1027,16 +1045,20 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(|r| AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
}))
|
||||
result
|
||||
.map(|r| {
|
||||
Ok(AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_admin_account_infos_by_dids(
|
||||
@@ -1058,15 +1080,17 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|r| AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
.filter_map(|r| {
|
||||
Some(AdminAccountInfo {
|
||||
id: r.id,
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
invites_disabled: r.invites_disabled.unwrap_or(false),
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1105,11 +1129,14 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| DeletionRequestWithToken {
|
||||
token: r.token,
|
||||
did: Did::new(r.did).expect("valid DID in database"),
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(DeletionRequestWithToken {
|
||||
token: r.token,
|
||||
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_latest_comms_for_user(
|
||||
@@ -1202,14 +1229,20 @@ impl InfraRepository for PostgresInfraRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| ReservedSigningKeyFull {
|
||||
id: r.id,
|
||||
did: r.did.map(|d| Did::new(d).expect("valid DID in database")),
|
||||
public_key_did_key: Did::from(r.public_key_did_key),
|
||||
private_key_bytes: r.private_key_bytes,
|
||||
expires_at: r.expires_at,
|
||||
used_at: r.used_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(ReservedSigningKeyFull {
|
||||
id: r.id,
|
||||
did: opt_column(r.did, col::RESERVED_SIGNING_KEYS_DID)?,
|
||||
public_key_did_key: column(
|
||||
r.public_key_did_key,
|
||||
col::RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY,
|
||||
)?,
|
||||
private_key_bytes: r.private_key_bytes,
|
||||
expires_at: r.expires_at,
|
||||
used_at: r.used_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
|
||||
|
||||
@@ -10,7 +10,106 @@ mod sso;
|
||||
mod user;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tranquil_db_traits::{ColumnRef, DbError};
|
||||
|
||||
pub(crate) mod col {
|
||||
use tranquil_db_traits::ColumnRef;
|
||||
|
||||
pub const ACCOUNT_DELEGATIONS_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "controller_did");
|
||||
pub const ACCOUNT_DELEGATIONS_DELEGATED_DID: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "delegated_did");
|
||||
pub const ACCOUNT_DELEGATIONS_GRANTED_BY: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "granted_by");
|
||||
pub const ACCOUNT_DELEGATIONS_REVOKED_BY: ColumnRef =
|
||||
ColumnRef::new("account_delegations", "revoked_by");
|
||||
pub const ACCOUNT_DELETION_REQUESTS_DID: ColumnRef =
|
||||
ColumnRef::new("account_deletion_requests", "did");
|
||||
pub const APP_PASSWORDS_CREATED_BY_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("app_passwords", "created_by_controller_did");
|
||||
pub const BACKLINKS_URI: ColumnRef = ColumnRef::new("backlinks", "uri");
|
||||
pub const BLOBS_CID: ColumnRef = ColumnRef::new("blobs", "cid");
|
||||
pub const DELEGATION_AUDIT_LOG_ACTOR_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "actor_did");
|
||||
pub const DELEGATION_AUDIT_LOG_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "controller_did");
|
||||
pub const DELEGATION_AUDIT_LOG_DELEGATED_DID: ColumnRef =
|
||||
ColumnRef::new("delegation_audit_log", "delegated_did");
|
||||
pub const INVITE_CODES_FOR_ACCOUNT: ColumnRef = ColumnRef::new("invite_codes", "for_account");
|
||||
pub const OAUTH_2FA_CHALLENGE_DID: ColumnRef = ColumnRef::new("oauth_2fa_challenge", "did");
|
||||
pub const OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_authorization_request", "controller_did");
|
||||
pub const OAUTH_AUTHORIZATION_REQUEST_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_authorization_request", "did");
|
||||
pub const OAUTH_TOKEN_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("oauth_token", "controller_did");
|
||||
pub const OAUTH_TOKEN_DID: ColumnRef = ColumnRef::new("oauth_token", "did");
|
||||
pub const PASSKEYS_DID: ColumnRef = ColumnRef::new("passkeys", "did");
|
||||
pub const RECORD_BLOBS_BLOB_CID: ColumnRef = ColumnRef::new("record_blobs", "blob_cid");
|
||||
pub const RECORD_BLOBS_RECORD_URI: ColumnRef = ColumnRef::new("record_blobs", "record_uri");
|
||||
pub const RECORDS_COLLECTION: ColumnRef = ColumnRef::new("records", "collection");
|
||||
pub const RECORDS_RECORD_CID: ColumnRef = ColumnRef::new("records", "record_cid");
|
||||
pub const RECORDS_RKEY: ColumnRef = ColumnRef::new("records", "rkey");
|
||||
pub const REPO_SEQ_BLOBS: ColumnRef = ColumnRef::new("repo_seq", "blobs");
|
||||
pub const REPO_SEQ_BLOCKS_CIDS: ColumnRef = ColumnRef::new("repo_seq", "blocks_cids");
|
||||
pub const REPO_SEQ_COMMIT_CID: ColumnRef = ColumnRef::new("repo_seq", "commit_cid");
|
||||
pub const REPO_SEQ_DID: ColumnRef = ColumnRef::new("repo_seq", "did");
|
||||
pub const REPO_SEQ_HANDLE: ColumnRef = ColumnRef::new("repo_seq", "handle");
|
||||
pub const REPO_SEQ_PREV_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_cid");
|
||||
pub const REPO_SEQ_PREV_DATA_CID: ColumnRef = ColumnRef::new("repo_seq", "prev_data_cid");
|
||||
pub const REPO_SEQ_REV: ColumnRef = ColumnRef::new("repo_seq", "rev");
|
||||
pub const REPOS_REPO_REV: ColumnRef = ColumnRef::new("repos", "repo_rev");
|
||||
pub const REPOS_REPO_ROOT_CID: ColumnRef = ColumnRef::new("repos", "repo_root_cid");
|
||||
pub const RESERVED_SIGNING_KEYS_DID: ColumnRef = ColumnRef::new("reserved_signing_keys", "did");
|
||||
pub const RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY: ColumnRef =
|
||||
ColumnRef::new("reserved_signing_keys", "public_key_did_key");
|
||||
pub const SESSION_TOKENS_CONTROLLER_DID: ColumnRef =
|
||||
ColumnRef::new("session_tokens", "controller_did");
|
||||
pub const SESSION_TOKENS_DID: ColumnRef = ColumnRef::new("session_tokens", "did");
|
||||
pub const USERS_DID: ColumnRef = ColumnRef::new("users", "did");
|
||||
pub const USERS_HANDLE: ColumnRef = ColumnRef::new("users", "handle");
|
||||
}
|
||||
|
||||
pub(crate) fn column<T: FromStr>(value: String, name: ColumnRef) -> Result<T, DbError> {
|
||||
T::from_str(&value).map_err(|_| {
|
||||
tracing::error!(
|
||||
column = %name,
|
||||
value = %value,
|
||||
"column has a value that isn't valid for its type"
|
||||
);
|
||||
DbError::InvalidColumn(name)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn opt_column<T: FromStr>(
|
||||
value: Option<String>,
|
||||
name: ColumnRef,
|
||||
) -> Result<Option<T>, DbError> {
|
||||
value.map(|v| column(v, name)).transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_column<T: FromStr>(value: String, name: ColumnRef) -> Option<T> {
|
||||
match T::from_str(&value) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
column = %name,
|
||||
value = %value,
|
||||
"ignoring a column value that isn't valid for its type"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn column_vec<T: FromStr>(
|
||||
values: Vec<String>,
|
||||
name: ColumnRef,
|
||||
) -> Result<Vec<T>, DbError> {
|
||||
values.into_iter().map(|v| column(v, name)).collect()
|
||||
}
|
||||
|
||||
pub use backlink::PostgresBacklinkRepository;
|
||||
pub use blob::PostgresBlobRepository;
|
||||
|
||||
@@ -11,11 +11,12 @@ use tranquil_oauth::{
|
||||
SessionId as OAuthSessionId, TokenData,
|
||||
};
|
||||
use tranquil_types::{
|
||||
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
|
||||
TokenId,
|
||||
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, RefreshToken, RequestId, TokenId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::column;
|
||||
use super::user::map_sqlx_error;
|
||||
|
||||
const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600;
|
||||
@@ -98,7 +99,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
@@ -115,7 +116,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
})),
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -144,7 +145,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
@@ -161,7 +162,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -193,7 +194,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
@@ -210,7 +211,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
},
|
||||
))),
|
||||
None => Ok(None),
|
||||
@@ -326,7 +327,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
did: r
|
||||
.did
|
||||
.parse()
|
||||
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_DID))?,
|
||||
token_id: TokenId::from(r.token_id),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
@@ -343,7 +344,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
|
||||
.map_err(|_| DbError::InvalidColumn(col::OAUTH_TOKEN_CONTROLLER_DID))?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -476,18 +477,14 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
|
||||
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
|
||||
})?,
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -570,18 +567,14 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
client_auth,
|
||||
parameters,
|
||||
expires_at: r.expires_at,
|
||||
did: r
|
||||
.did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
|
||||
did: r.did.map(|s| s.parse()).transpose().map_err(|_| {
|
||||
DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_DID)
|
||||
})?,
|
||||
device_id: r.device_id.map(DeviceId::from),
|
||||
code: r.code.map(AuthorizationCode::from),
|
||||
controller_did: r
|
||||
.controller_did
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
|
||||
controller_did: r.controller_did.map(|s| s.parse()).transpose().map_err(
|
||||
|_| DbError::InvalidColumn(col::OAUTH_AUTHORIZATION_REQUEST_CONTROLLER_DID),
|
||||
)?,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -813,15 +806,16 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DeviceAccountRow {
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
last_used_at: r.last_used_at,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(DeviceAccountRow {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
last_used_at: r.last_used_at,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn verify_account_on_device(
|
||||
@@ -904,7 +898,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(TwoFactorChallenge {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::OAUTH_2FA_CHALLENGE_DID)?,
|
||||
request_uri: RequestId::from(row.request_uri),
|
||||
code: row.code,
|
||||
attempts: row.attempts,
|
||||
@@ -928,15 +922,18 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| TwoFactorChallenge {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
request_uri: RequestId::from(r.request_uri),
|
||||
code: r.code,
|
||||
attempts: r.attempts,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(TwoFactorChallenge {
|
||||
id: r.id,
|
||||
did: column(r.did, col::OAUTH_2FA_CHALLENGE_DID)?,
|
||||
request_uri: RequestId::from(r.request_uri),
|
||||
code: r.code,
|
||||
attempts: r.attempts,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn increment_2fa_attempts(&self, id: Uuid) -> Result<i32, DbError> {
|
||||
|
||||
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
|
||||
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, column_vec, legacy_column, opt_column};
|
||||
|
||||
struct RecordRow {
|
||||
rkey: String,
|
||||
@@ -43,7 +45,7 @@ fn row_to_event_blocks(
|
||||
) -> Result<Option<EventBlocks>, DbError> {
|
||||
match (block_cids, block_data) {
|
||||
(Some(cids), Some(data)) if cids.len() == data.len() => match cids.is_empty() {
|
||||
true => Ok(legacy_fallback(legacy_blocks_cids)),
|
||||
true => legacy_fallback(legacy_blocks_cids),
|
||||
false => Ok(Some(EventBlocks::Inline(
|
||||
cids.into_iter()
|
||||
.zip(data)
|
||||
@@ -57,16 +59,19 @@ fn row_to_event_blocks(
|
||||
(Some(_), None) | (None, Some(_)) => Err(DbError::CorruptData(
|
||||
"repo_seq.block_cids/block_data partially populated",
|
||||
)),
|
||||
(None, None) => Ok(legacy_fallback(legacy_blocks_cids)),
|
||||
(None, None) => legacy_fallback(legacy_blocks_cids),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_fallback(legacy_blocks_cids: Option<Vec<String>>) -> Option<EventBlocks> {
|
||||
fn legacy_fallback(
|
||||
legacy_blocks_cids: Option<Vec<String>>,
|
||||
) -> Result<Option<EventBlocks>, DbError> {
|
||||
match legacy_blocks_cids {
|
||||
Some(cids) if !cids.is_empty() => Some(EventBlocks::LegacyCids(
|
||||
cids.into_iter().map(CidLink::from).collect(),
|
||||
)),
|
||||
_ => None,
|
||||
Some(cids) if !cids.is_empty() => Ok(Some(EventBlocks::LegacyCids(column_vec(
|
||||
cids,
|
||||
col::REPO_SEQ_BLOCKS_CIDS,
|
||||
)?))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,24 +102,40 @@ fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
|
||||
let blocks = row_to_event_blocks(r.block_cids, r.block_data, r.blocks_cids)?;
|
||||
Ok(SequencedEvent {
|
||||
seq: r.seq.into(),
|
||||
did: Did::from(r.did),
|
||||
did: column(r.did, col::REPO_SEQ_DID)?,
|
||||
created_at: r.created_at,
|
||||
event_type: r.event_type,
|
||||
commit_cid: r.commit_cid.map(CidLink::from),
|
||||
prev_cid: r.prev_cid.map(CidLink::from),
|
||||
prev_data_cid: r.prev_data_cid.map(CidLink::from),
|
||||
commit_cid: opt_column(r.commit_cid, col::REPO_SEQ_COMMIT_CID)?,
|
||||
prev_cid: opt_column(r.prev_cid, col::REPO_SEQ_PREV_CID)?,
|
||||
prev_data_cid: opt_column(r.prev_data_cid, col::REPO_SEQ_PREV_DATA_CID)?,
|
||||
ops: r.ops,
|
||||
blobs: r
|
||||
.blobs
|
||||
.map(|blobs| blobs.into_iter().map(CidLink::from).collect()),
|
||||
.map(|blobs| column_vec(blobs, col::REPO_SEQ_BLOBS))
|
||||
.transpose()?,
|
||||
blocks,
|
||||
handle: r.handle.map(Handle::from),
|
||||
handle: r
|
||||
.handle
|
||||
.and_then(|h| legacy_column(h, col::REPO_SEQ_HANDLE)),
|
||||
active: r.active,
|
||||
status,
|
||||
rev: r.rev.map(Tid::from),
|
||||
rev: opt_column(r.rev, col::REPO_SEQ_REV)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_sequenced_rows(rows: Vec<SequencedEventRow>) -> Vec<SequencedEvent> {
|
||||
rows.into_iter()
|
||||
.filter_map(|r| {
|
||||
let seq = r.seq;
|
||||
map_sequenced_row(r)
|
||||
.inspect_err(|e| {
|
||||
tracing::error!(seq, error = %e, "skipping a repo_seq row that doesn't decode");
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const SEQUENCER_LOCK_KEY: i64 = 0x0074_7261_6e73_6571;
|
||||
const SEQUENCER_BATCH_SIZE: i64 = 1000;
|
||||
|
||||
@@ -266,7 +287,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn get_repo(&self, user_id: Uuid) -> Result<Option<RepoInfo>, DbError> {
|
||||
@@ -278,11 +299,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| RepoInfo {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
repo_rev: r.repo_rev.map(Tid::from),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(RepoInfo {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_repo_root_by_did(&self, did: &Did) -> Result<Option<CidLink>, DbError> {
|
||||
@@ -294,7 +318,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn count_repos(&self) -> Result<i64, DbError> {
|
||||
@@ -312,13 +336,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RepoWithoutRev {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(RepoWithoutRev {
|
||||
user_id: r.user_id,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn upsert_records(
|
||||
@@ -405,7 +430,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(result.map(CidLink::from))
|
||||
opt_column(result, col::RECORDS_RECORD_CID)
|
||||
}
|
||||
|
||||
async fn list_records(
|
||||
@@ -418,13 +443,16 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
rkey_start: Option<&Rkey>,
|
||||
rkey_end: Option<&Rkey>,
|
||||
) -> Result<Vec<RecordInfo>, DbError> {
|
||||
let to_record_info = |rows: Vec<RecordRow>| {
|
||||
rows.into_iter()
|
||||
.map(|r| RecordInfo {
|
||||
rkey: Rkey::from(r.rkey),
|
||||
record_cid: CidLink::from(r.record_cid),
|
||||
let to_record_info = |rows: Vec<RecordRow>| -> Result<Vec<RecordInfo>, DbError> {
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
Some(RecordInfo {
|
||||
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
|
||||
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
};
|
||||
|
||||
let collection_str = collection.as_str();
|
||||
@@ -446,7 +474,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -462,7 +490,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -486,7 +514,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -503,7 +531,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -525,7 +553,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -541,7 +569,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -563,7 +591,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -579,7 +607,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -598,7 +626,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
true => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -613,7 +641,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(to_record_info(rows))
|
||||
to_record_info(rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -629,10 +657,12 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| FullRecordInfo {
|
||||
collection: Nsid::from(r.collection),
|
||||
rkey: Rkey::from(r.rkey),
|
||||
record_cid: CidLink::from(r.record_cid),
|
||||
.filter_map(|r| {
|
||||
Some(FullRecordInfo {
|
||||
collection: legacy_column(r.collection, col::RECORDS_COLLECTION)?,
|
||||
rkey: legacy_column(r.rkey, col::RECORDS_RKEY)?,
|
||||
record_cid: legacy_column(r.record_cid, col::RECORDS_RECORD_CID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -646,7 +676,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows.into_iter().map(Nsid::from).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|c| legacy_column(c, col::RECORDS_COLLECTION))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_records(&self, repo_id: Uuid) -> Result<i64, DbError> {
|
||||
@@ -688,6 +721,45 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn referenced_record_cids(
|
||||
&self,
|
||||
repo_id: Uuid,
|
||||
cids: &[CidLink],
|
||||
excluded_keys: &[(&Nsid, &Rkey)],
|
||||
) -> Result<Vec<CidLink>, DbError> {
|
||||
if cids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let cid_strs: Vec<String> = cids.iter().map(|c| c.as_str().to_owned()).collect();
|
||||
let (excluded_collections, excluded_rkeys): (Vec<String>, Vec<String>) = excluded_keys
|
||||
.iter()
|
||||
.map(|(collection, rkey)| (collection.as_str().to_owned(), rkey.as_str().to_owned()))
|
||||
.unzip();
|
||||
|
||||
let rows = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT DISTINCT r.record_cid AS "record_cid!"
|
||||
FROM records r
|
||||
WHERE r.repo_id = $1
|
||||
AND r.record_cid = ANY($2)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM UNNEST($3::text[], $4::text[]) AS k(collection, rkey)
|
||||
WHERE k.collection = r.collection AND k.rkey = r.rkey
|
||||
)
|
||||
"#,
|
||||
repo_id,
|
||||
&cid_strs,
|
||||
&excluded_collections,
|
||||
&excluded_rkeys
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
column_vec(rows, col::RECORDS_RECORD_CID)
|
||||
}
|
||||
|
||||
async fn set_record_takedown(
|
||||
&self,
|
||||
cid: &CidLink,
|
||||
@@ -760,22 +832,37 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
since_rev: &Tid,
|
||||
since_rev: Option<&Tid>,
|
||||
) -> Result<Vec<Vec<u8>>, DbError> {
|
||||
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT block_cid FROM user_blocks
|
||||
WHERE user_id = $1 AND repo_rev > $2
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(since_rev.as_str())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
let rows = match since_rev {
|
||||
None => {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT block_cid AS "block_cid!" FROM user_blocks
|
||||
WHERE user_id = $1
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
Some(rev) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT block_cid AS "block_cid!" FROM user_blocks
|
||||
WHERE user_id = $1 AND repo_rev > $2
|
||||
ORDER BY repo_rev ASC
|
||||
"#,
|
||||
user_id,
|
||||
rev.as_str()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
Ok(rows.into_iter().map(|(cid,)| cid).collect())
|
||||
rows.map_err(map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<(), DbError> {
|
||||
@@ -1024,13 +1111,16 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| RepoAccountInfo {
|
||||
user_id: r.id,
|
||||
did: Did::from(r.did),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: r.repo_root_cid.map(CidLink::from),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(RepoAccountInfo {
|
||||
user_id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: opt_column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_events_since_seq(
|
||||
@@ -1054,7 +1144,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
None => {
|
||||
let rows = sqlx::query_as!(
|
||||
@@ -1069,7 +1159,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1092,7 +1182,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
|
||||
async fn get_event_by_seq(
|
||||
@@ -1132,7 +1222,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
rows.into_iter().map(map_sequenced_row).collect()
|
||||
Ok(collect_sequenced_rows(rows))
|
||||
}
|
||||
|
||||
async fn list_repos_paginated(
|
||||
@@ -1155,16 +1245,17 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| RepoListItem {
|
||||
did: Did::from(r.did),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: CidLink::from(r.repo_root_cid),
|
||||
repo_rev: r.repo_rev.map(Tid::from),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(RepoListItem {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
repo_root_cid: column(r.repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(r.repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_repo_root_cid_by_user_id(
|
||||
@@ -1178,7 +1269,7 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(cid.map(CidLink::from))
|
||||
opt_column(cid, col::REPOS_REPO_ROOT_CID)
|
||||
}
|
||||
|
||||
async fn import_repo_data(
|
||||
@@ -1520,14 +1611,15 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(user_id, repo_root_cid, repo_rev)| UserWithoutBlocks {
|
||||
user_id,
|
||||
repo_root_cid: CidLink::from(repo_root_cid),
|
||||
repo_rev: repo_rev.map(Tid::from),
|
||||
rows.into_iter()
|
||||
.map(|(user_id, repo_root_cid, repo_rev)| {
|
||||
Ok(UserWithoutBlocks {
|
||||
user_id,
|
||||
repo_root_cid: column(repo_root_cid, col::REPOS_REPO_ROOT_CID)?,
|
||||
repo_rev: opt_column(repo_rev, col::REPOS_REPO_REV)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_users_needing_record_blobs_backfill(
|
||||
@@ -1548,13 +1640,14 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| UserNeedingRecordBlobsBackfill {
|
||||
user_id: r.user_id,
|
||||
did: Did::from(r.did),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(UserNeedingRecordBlobsBackfill {
|
||||
user_id: r.user_id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn insert_record_blobs(
|
||||
|
||||
@@ -10,7 +10,9 @@ use tranquil_db_traits::{
|
||||
use tranquil_types::{Did, Jti, PasswordHash};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::user::map_sqlx_error;
|
||||
use super::{column, opt_column};
|
||||
|
||||
pub struct PostgresSessionRepository {
|
||||
pool: PgPool,
|
||||
@@ -69,21 +71,24 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionToken {
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
refresh_jti: Jti::from(r.refresh_jti),
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
app_password_name: r.app_password_name,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(SessionToken {
|
||||
id: SessionId::new(r.id),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
refresh_jti: Jti::from(r.refresh_jti),
|
||||
access_expires_at: r.access_expires_at,
|
||||
refresh_expires_at: r.refresh_expires_at,
|
||||
login_type: LoginType::from_legacy_flag(r.legacy_login),
|
||||
mfa_verified: r.mfa_verified,
|
||||
scope: r.scope,
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
app_password_name: r.app_password_name,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_session_for_refresh(
|
||||
@@ -104,14 +109,17 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| SessionForRefresh {
|
||||
id: SessionId::new(r.id),
|
||||
did: Did::from(r.did),
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(SessionForRefresh {
|
||||
id: SessionId::new(r.id),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
scope: r.scope,
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn delete_session_by_access_jti(
|
||||
@@ -274,9 +282,9 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
|
||||
if r.used_at > grace_cutoff {
|
||||
Ok(RefreshGraceLookup::Replay(RefreshGraceReplay {
|
||||
did: Did::from(r.did),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
scope: r.scope,
|
||||
controller_did: r.controller_did.map(Did::from),
|
||||
controller_did: opt_column(r.controller_did, col::SESSION_TOKENS_CONTROLLER_DID)?,
|
||||
access_jti: Jti::from(r.access_jti),
|
||||
refresh_jti: Jti::from(r.refresh_jti),
|
||||
access_expires_at: r.access_expires_at,
|
||||
@@ -286,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
}))
|
||||
} else {
|
||||
Ok(RefreshGraceLookup::Compromised {
|
||||
did: Did::from(r.did),
|
||||
did: column(r.did, col::SESSION_TOKENS_DID)?,
|
||||
session_id: SessionId::new(r.session_id),
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version.unwrap_or(0),
|
||||
@@ -308,19 +316,23 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_app_passwords_for_login(
|
||||
@@ -341,19 +353,23 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_app_password_by_name(
|
||||
@@ -374,16 +390,22 @@ impl SessionRepository for PostgresSessionRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: r.created_by_controller_did.map(Did::from),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(AppPasswordRecord {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name: r.name,
|
||||
password_hash: PasswordHash::new(r.password_hash),
|
||||
created_at: r.created_at,
|
||||
privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged),
|
||||
scopes: r.scopes,
|
||||
created_by_controller_did: opt_column(
|
||||
r.created_by_controller_did,
|
||||
col::APP_PASSWORDS_CREATED_BY_CONTROLLER_DID,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn create_app_password(&self, data: &AppPasswordCreate) -> Result<Uuid, DbError> {
|
||||
|
||||
@@ -4,6 +4,8 @@ use sqlx::PgPool;
|
||||
use tranquil_types::{AtIdentifier, Did, Handle, Jti, PasswordHash, TokenId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::col;
|
||||
use super::{column, legacy_column, opt_column};
|
||||
use tranquil_db_traits::{
|
||||
AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError,
|
||||
DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType,
|
||||
@@ -87,17 +89,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserRow {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserRow {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_by_handle(&self, handle: &Handle) -> Result<Option<UserRow>, DbError> {
|
||||
@@ -110,17 +115,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserRow {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserRow {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
inbound_migration: r.inbound_migration,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_with_key_by_did(&self, did: &Did) -> Result<Option<UserWithKey>, DbError> {
|
||||
@@ -136,17 +144,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserWithKey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserWithKey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_status_by_did(&self, did: &Did) -> Result<Option<UserStatus>, DbError> {
|
||||
@@ -207,15 +218,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| OAuthTokenWithUser {
|
||||
did: Did::from(r.did),
|
||||
expires_at: r.expires_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(OAuthTokenWithUser {
|
||||
did: column(r.did, col::OAUTH_TOKEN_DID)?,
|
||||
expires_at: r.expires_at,
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
is_admin: r.is_admin,
|
||||
key_bytes: r.key_bytes,
|
||||
encryption_version: r.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_info_by_did(&self, did: &Did) -> Result<Option<UserInfoForAuth>, DbError> {
|
||||
@@ -288,14 +302,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AccountSearchResult {
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
invites_disabled: r.invites_disabled,
|
||||
.filter_map(|r| {
|
||||
Some(AccountSearchResult {
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
email_verified: r.email_verified,
|
||||
deactivated_at: r.deactivated_at,
|
||||
invites_disabled: r.invites_disabled,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -311,24 +327,27 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserAuthInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserAuthInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
deactivated_at: r.deactivated_at,
|
||||
takedown_ref: r.takedown_ref,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_by_email(&self, email: &str) -> Result<Option<UserForVerification>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT id, did, email, email_verified, handle
|
||||
r#"SELECT id, did, email, email_verified
|
||||
FROM users
|
||||
WHERE LOWER(email) = $1"#,
|
||||
email
|
||||
@@ -336,13 +355,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserForVerification {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
handle: Handle::from(r.handle),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForVerification {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
|
||||
@@ -354,15 +375,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserCommsPrefs {
|
||||
email: r.email,
|
||||
handle: Handle::from(r.handle),
|
||||
preferred_channel: r.preferred_channel,
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_username: r.signal_username,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserCommsPrefs {
|
||||
email: r.email,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
preferred_channel: r.preferred_channel,
|
||||
preferred_locale: r.preferred_locale,
|
||||
telegram_chat_id: r.telegram_chat_id,
|
||||
discord_id: r.discord_id,
|
||||
signal_username: r.signal_username,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_id_by_did(&self, did: &Did) -> Result<Option<Uuid>, DbError> {
|
||||
@@ -395,10 +419,13 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserIdAndHandle {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserIdAndHandle {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_did_web_info_by_handle(
|
||||
@@ -412,11 +439,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserDidWebInfo {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserDidWebInfo {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_did_web_overrides(
|
||||
@@ -441,7 +471,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(handle.map(Handle::from))
|
||||
opt_column(handle, col::USERS_HANDLE)
|
||||
}
|
||||
|
||||
async fn check_handle_exists(
|
||||
@@ -538,12 +568,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserEmailInfo {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserEmailInfo {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
email_verified: r.email_verified,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn check_email_exists(
|
||||
@@ -726,11 +759,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserIdHandleEmail {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserIdHandleEmail {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update_preferred_comms_channel(
|
||||
@@ -794,17 +830,20 @@ impl UserRepository for PostgresUserRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.map(|r| UserVerificationInfo {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
email: r.email,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserVerificationInfo {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
email: r.email,
|
||||
channel_verification: ChannelVerificationStatus::from_db_row(
|
||||
r.email_verified,
|
||||
r.discord_verified,
|
||||
r.telegram_verified,
|
||||
r.signal_verified,
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result<bool, DbError> {
|
||||
@@ -954,21 +993,22 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| StoredPasskey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Ok(StoredPasskey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::PASSKEYS_DID)?,
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_passkey_by_credential_id(
|
||||
@@ -985,18 +1025,21 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| StoredPasskey {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(StoredPasskey {
|
||||
id: r.id,
|
||||
did: column(r.did, col::PASSKEYS_DID)?,
|
||||
credential_id: r.credential_id,
|
||||
public_key: r.public_key,
|
||||
sign_count: r.sign_count,
|
||||
created_at: r.created_at,
|
||||
last_used: r.last_used,
|
||||
friendly_name: r.friendly_name,
|
||||
aaguid: r.aaguid,
|
||||
transports: r.transports,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn save_passkey(
|
||||
@@ -1431,13 +1474,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|r| UserLoginCheck {
|
||||
did: Did::from(r.did),
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|r| {
|
||||
Ok(UserLoginCheck {
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
password_hash: r.password_hash.map(PasswordHash::new),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_login_info_by_identifier(
|
||||
@@ -1458,11 +1502,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserLoginInfo {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserLoginInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
password_required: row.password_required,
|
||||
@@ -1479,6 +1523,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
account_type: row.account_type,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_2fa_status_by_did(&self, did: &Did) -> Result<Option<User2faStatus>, DbError> {
|
||||
@@ -1527,10 +1572,10 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserSessionInfo {
|
||||
handle: Handle::from(row.handle),
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserSessionInfo {
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
is_admin: row.is_admin,
|
||||
deactivated_at: row.deactivated_at,
|
||||
@@ -1549,6 +1594,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_2fa_enabled: row.email_2fa_enabled,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_legacy_login_pref(
|
||||
@@ -1620,12 +1666,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserLoginFull {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserLoginFull {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
handle: Handle::from(row.handle),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
email: row.email,
|
||||
deactivated_at: row.deactivated_at,
|
||||
@@ -1645,6 +1691,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
email_2fa_enabled: row.email_2fa_enabled,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_confirm_signup_by_did(
|
||||
@@ -1664,12 +1711,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserConfirmSignup {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserConfirmSignup {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
handle: Handle::from(row.handle),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_username: row.discord_username,
|
||||
@@ -1679,6 +1726,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
encryption_version: row.encryption_version,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_resend_verification_by_did(
|
||||
@@ -1697,11 +1745,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResendVerification {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserResendVerification {
|
||||
id: row.id,
|
||||
handle: Handle::from(row.handle),
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
email: row.email,
|
||||
channel: row.channel,
|
||||
discord_username: row.discord_username,
|
||||
@@ -1715,6 +1763,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn set_channel_verified(&self, did: &Did, channel: CommsChannel) -> Result<(), DbError> {
|
||||
@@ -1760,14 +1809,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
}
|
||||
|
||||
async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> {
|
||||
sqlx::query_scalar!(
|
||||
let handles = sqlx::query_scalar!(
|
||||
"SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
|
||||
email
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map(|handles| handles.into_iter().map(Handle::from).collect())
|
||||
.map_err(map_sqlx_error)
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(handles
|
||||
.into_iter()
|
||||
.filter_map(|h| legacy_column(h, col::USERS_HANDLE))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn set_password_reset_code(
|
||||
@@ -1798,15 +1851,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserResetCodeInfo {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserResetCodeInfo {
|
||||
id: row.id,
|
||||
did: Did::from(row.did),
|
||||
did: column(row.did, col::USERS_DID)?,
|
||||
preferred_comms_channel: row.preferred_comms_channel,
|
||||
expires_at: row.password_reset_code_expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn clear_password_reset_code(&self, user_id: Uuid) -> Result<(), DbError> {
|
||||
@@ -1894,12 +1948,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
let did = column(user_did, col::USERS_DID)?;
|
||||
|
||||
tx.commit().await.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(PasswordResetResult {
|
||||
did: Did::from(user_did),
|
||||
session_jtis,
|
||||
})
|
||||
Ok(PasswordResetResult { did, session_jtis })
|
||||
}
|
||||
|
||||
async fn activate_account(&self, did: &Did) -> Result<bool, DbError> {
|
||||
@@ -2004,14 +2057,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)
|
||||
.map(|opt| {
|
||||
opt.map(|row| UserForDeletion {
|
||||
.map_err(map_sqlx_error)?
|
||||
.map(|row| {
|
||||
Ok(UserForDeletion {
|
||||
id: row.id,
|
||||
password_hash: row.password_hash.map(PasswordHash::new),
|
||||
handle: Handle::from(row.handle),
|
||||
handle: column(row.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_key_by_did(&self, did: &Did) -> Result<Option<UserKeyInfo>, DbError> {
|
||||
@@ -2154,11 +2208,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForDidDoc {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
deactivated_at: r.deactivated_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForDidDoc {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
deactivated_at: r.deactivated_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_for_did_doc_build(
|
||||
@@ -2173,11 +2230,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForDidDocBuild {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForDidDocBuild {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
migrated_to_pds: r.migrated_to_pds,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn upsert_did_web_overrides(
|
||||
@@ -2234,13 +2294,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForPasskeySetup {
|
||||
id: r.id,
|
||||
handle: Handle::from(r.handle),
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
password_required: r.password_required,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForPasskeySetup {
|
||||
id: r.id,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
password_required: r.password_required,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_user_for_passkey_recovery(
|
||||
@@ -2257,12 +2320,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForPasskeyRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
password_required: r.password_required,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForPasskeyRecovery {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
handle: column(r.handle, col::USERS_HANDLE)?,
|
||||
password_required: r.password_required,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn set_recovery_token(
|
||||
@@ -2292,13 +2358,16 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| UserForRecovery {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
}))
|
||||
row.map(|r| {
|
||||
Ok(UserForRecovery {
|
||||
id: r.id,
|
||||
did: column(r.did, col::USERS_DID)?,
|
||||
preferred_comms_channel: r.preferred_comms_channel,
|
||||
recovery_token: r.recovery_token,
|
||||
recovery_token_expires_at: r.recovery_token_expires_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_accounts_scheduled_for_deletion(
|
||||
@@ -2322,10 +2391,12 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| tranquil_db_traits::ScheduledDeletionAccount {
|
||||
id: r.id,
|
||||
did: Did::from(r.did),
|
||||
handle: Handle::from(r.handle),
|
||||
.filter_map(|r| {
|
||||
Some(tranquil_db_traits::ScheduledDeletionAccount {
|
||||
id: r.id,
|
||||
did: legacy_column(r.did, col::USERS_DID)?,
|
||||
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -3029,13 +3100,15 @@ impl UserRepository for PostgresUserRepository {
|
||||
));
|
||||
}
|
||||
|
||||
let old_handle = legacy_column(old_handle, col::USERS_HANDLE);
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| tranquil_db_traits::MigrationReactivationError::Database(e.to_string()))?;
|
||||
|
||||
Ok(tranquil_db_traits::ReactivatedAccountInfo {
|
||||
user_id: account_id,
|
||||
old_handle: Handle::from(old_handle),
|
||||
old_handle,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tranquil-config = { workspace = true }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -5,7 +5,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -11,6 +11,7 @@ tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-db-traits = { workspace = true }
|
||||
tranquil-scopes = { workspace = true }
|
||||
|
||||
axum = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
@@ -33,3 +34,9 @@ tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
|
||||
[features]
|
||||
bsky = []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScopeInfo {
|
||||
@@ -8,6 +9,33 @@ pub struct ScopeInfo {
|
||||
pub description: String,
|
||||
pub display_name: String,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PermissionSetInfo {
|
||||
pub nsid: Nsid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
pub include_scope: String,
|
||||
pub expanded: Vec<ScopeInfo>,
|
||||
pub granted: Option<bool>,
|
||||
pub restricted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FailedSetInfo {
|
||||
// `given_*` is the value as requested by the client.
|
||||
// Left as strings because the value from the client could be malformed.
|
||||
#[serde(rename = "nsid")]
|
||||
pub given_nsid: String,
|
||||
#[serde(rename = "aud", skip_serializing_if = "Option::is_none")]
|
||||
pub given_aud: Option<String>,
|
||||
pub reason: tranquil_scopes::ResolveFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -18,6 +46,8 @@ pub struct ConsentResponse {
|
||||
pub client_uri: Option<String>,
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub permission_sets: Vec<PermissionSetInfo>,
|
||||
pub failed_sets: Vec<FailedSetInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -116,26 +146,14 @@ pub async fn consent_get(
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
requested_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
requested_scope_str.to_string()
|
||||
let authority = match delegation_grant.as_ref() {
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
|
||||
let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Failed to expand permission set: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, requested_scope_str, authority)
|
||||
.await;
|
||||
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
|
||||
let preferences = state
|
||||
.repos
|
||||
.oauth
|
||||
@@ -146,30 +164,42 @@ pub async fn consent_get(
|
||||
.iter()
|
||||
.map(|p| (p.scope.as_str(), p.granted))
|
||||
.collect();
|
||||
let requested_scope_strings: Vec<String> =
|
||||
requested_scopes.iter().map(|s| s.to_string()).collect();
|
||||
let presented_item_strings: Vec<String> = effective
|
||||
.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(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&request_data.parameters.client_id,
|
||||
&requested_scope_strings,
|
||||
&presented_item_strings,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let scopes: Vec<ScopeInfo> = requested_scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
let (category, required, description, display_name) = if let Some(def) =
|
||||
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
|
||||
{
|
||||
let desc = if *scope == "atproto" && has_granular_scopes {
|
||||
|
||||
let grant_scope_str: Option<&str> =
|
||||
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
|
||||
let is_restricted = |scope: &str| -> bool {
|
||||
grant_scope_str.is_some_and(|g| !tranquil_pds::delegation::grant_covers(g, scope))
|
||||
};
|
||||
|
||||
let make_scope_info = |scope: &str| -> ScopeInfo {
|
||||
let (category, required, description, display_name) =
|
||||
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) {
|
||||
let desc = if scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
.to_string()
|
||||
} else {
|
||||
def.description.to_string()
|
||||
};
|
||||
let name = if *scope == "atproto" && has_granular_scopes {
|
||||
let name = if scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol Access".to_string()
|
||||
} else {
|
||||
def.display_name.to_string()
|
||||
@@ -195,18 +225,60 @@ pub async fn consent_get(
|
||||
scope.to_string(),
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(*scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
let granted = pref_map.get(scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
restricted: is_restricted(scope),
|
||||
}
|
||||
};
|
||||
|
||||
let scopes: Vec<ScopeInfo> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.map(|s| make_scope_info(s))
|
||||
.collect();
|
||||
|
||||
let permission_sets: Vec<PermissionSetInfo> = effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let include_scope = match &g.aud {
|
||||
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 restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
|
||||
PermissionSetInfo {
|
||||
nsid: g.nsid.clone(),
|
||||
aud: g.aud.clone(),
|
||||
title: g.title.clone(),
|
||||
detail: g.detail.clone(),
|
||||
granted: pref_map.get(include_scope.as_str()).copied(),
|
||||
include_scope,
|
||||
expanded,
|
||||
restricted,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let failed_sets: Vec<FailedSetInfo> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| FailedSetInfo {
|
||||
given_nsid: f.given_nsid.clone(),
|
||||
given_aud: f.given_aud.clone(),
|
||||
reason: f.reason.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos
|
||||
.user
|
||||
@@ -255,6 +327,8 @@ pub async fn consent_get(
|
||||
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
permission_sets,
|
||||
failed_sets,
|
||||
show_consent,
|
||||
did: did.clone(),
|
||||
handle: account_handle,
|
||||
@@ -342,16 +416,53 @@ pub async fn consent_post(
|
||||
None => None,
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
original_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
let authority = match delegation_grant.as_ref() {
|
||||
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
|
||||
None => scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
let effective =
|
||||
scope_resolution::resolve_effective_scopes(&*state.cache, original_scope_str, authority)
|
||||
.await;
|
||||
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
||||
match aud {
|
||||
Some(a) => format!("include:{}?aud={}", nsid, a),
|
||||
None => format!("include:{}", nsid),
|
||||
}
|
||||
};
|
||||
let approved_failed_sets: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
form.approved_scopes
|
||||
.contains(&include_token(&f.given_nsid, &f.given_aud))
|
||||
})
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
if !approved_failed_sets.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!(
|
||||
"Could not resolve approved permission set(s): {}",
|
||||
approved_failed_sets.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
let presented_items: Vec<String> = effective
|
||||
.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");
|
||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -359,7 +470,8 @@ pub async fn consent_post(
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
let mut final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto");
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -377,11 +489,11 @@ pub async fn consent_post(
|
||||
);
|
||||
}
|
||||
if form.remember {
|
||||
let preferences: Vec<ScopePreference> = requested_scopes
|
||||
let preferences: Vec<ScopePreference> = presented_items
|
||||
.iter()
|
||||
.map(|s| ScopePreference {
|
||||
scope: s.to_string(),
|
||||
granted: form.approved_scopes.contains(&s.to_string()),
|
||||
scope: s.clone(),
|
||||
granted: form.approved_scopes.contains(s),
|
||||
})
|
||||
.collect();
|
||||
let _ = state
|
||||
|
||||
@@ -15,7 +15,7 @@ use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
|
||||
db::should_show_consent, scopes::expand_include_scopes,
|
||||
db::should_show_consent,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{
|
||||
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
||||
@@ -300,6 +300,7 @@ mod consent;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod registration;
|
||||
pub mod scope_resolution;
|
||||
mod two_factor;
|
||||
|
||||
pub use consent::*;
|
||||
|
||||
@@ -12,7 +12,16 @@ pub async fn register_complete(
|
||||
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
|
||||
Json(form): Json<RegisterCompleteInput>,
|
||||
) -> Response {
|
||||
let did = Did::from(form.did.clone());
|
||||
let Ok(did) = Did::new(form.did.as_str()) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid DID."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
@@ -97,12 +106,12 @@ pub async fn register_complete(
|
||||
}
|
||||
|
||||
if let Some(existing_did) = &request_data.did
|
||||
&& existing_did != &form.did
|
||||
&& existing_did != &did
|
||||
{
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
existing_did = %existing_did,
|
||||
attempted_did = %form.did,
|
||||
attempted_did = %did,
|
||||
"register_complete attempted with different DID than already bound"
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use tranquil_db_traits::DbScope;
|
||||
use tranquil_pds::cache::Cache;
|
||||
use tranquil_pds::delegation::intersect_scopes;
|
||||
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
|
||||
use tranquil_scopes::ExpansionOutcome;
|
||||
|
||||
pub enum Authority<'a> {
|
||||
FullSelf,
|
||||
Delegated(&'a DbScope),
|
||||
}
|
||||
|
||||
pub struct EffectiveScopes {
|
||||
// The expanded set of scopes, minus any denied by delegation
|
||||
pub permitted: String,
|
||||
// The expanded set of scopes, before delegation processing
|
||||
pub outcome: ExpansionOutcome,
|
||||
}
|
||||
|
||||
pub async fn resolve_effective_scopes(
|
||||
cache: &dyn Cache,
|
||||
requested: &str,
|
||||
authority: Authority<'_>,
|
||||
) -> EffectiveScopes {
|
||||
let outcome = expand_scopes(cache, requested).await;
|
||||
let expanded = outcome.to_scope_string();
|
||||
let permitted = match authority {
|
||||
Authority::FullSelf => expanded,
|
||||
Authority::Delegated(granted) => intersect_scopes(&expanded, granted.as_str()),
|
||||
};
|
||||
EffectiveScopes { permitted, outcome }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tranquil_pds::cache::{Cache, CacheError};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MapCache(Mutex<HashMap<String, String>>);
|
||||
#[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(
|
||||
&tranquil_types::Nsid::new(nsid).unwrap(),
|
||||
None,
|
||||
);
|
||||
let json = serde_json::json!({
|
||||
"scope": scopes,
|
||||
"title": null,
|
||||
"detail": null,
|
||||
"refreshed_at": chrono::Utc::now().timestamp(),
|
||||
})
|
||||
.to_string();
|
||||
c.0.lock().unwrap().insert(key, json);
|
||||
c
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_self_keeps_all_expanded() {
|
||||
let c = cache_with(
|
||||
"io.atcr.authFullApp",
|
||||
"repo:io.atcr.manifest?action=create identity:*",
|
||||
);
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::FullSelf,
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
assert!(
|
||||
eff.permitted
|
||||
.contains("repo:io.atcr.manifest?action=create")
|
||||
);
|
||||
assert!(eff.permitted.contains("identity:*"));
|
||||
assert!(eff.outcome.failures.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_intersects_expanded_against_grant() {
|
||||
let c = cache_with(
|
||||
"io.atcr.authFullApp",
|
||||
"repo:io.atcr.manifest?action=create identity:*",
|
||||
);
|
||||
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
|
||||
let eff = resolve_effective_scopes(
|
||||
&c,
|
||||
"atproto include:io.atcr.authFullApp",
|
||||
Authority::Delegated(&granted),
|
||||
)
|
||||
.await;
|
||||
assert!(eff.permitted.contains("atproto"));
|
||||
assert!(
|
||||
eff.permitted
|
||||
.contains("repo:io.atcr.manifest?action=create")
|
||||
);
|
||||
assert!(!eff.permitted.contains("identity"));
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,10 @@ use axum::http::{HeaderMap, Method};
|
||||
use chrono::{Duration, Utc};
|
||||
use tranquil_db_traits::RefreshTokenLookup;
|
||||
use tranquil_pds::config::AuthConfig;
|
||||
use tranquil_pds::delegation::intersect_scopes;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData,
|
||||
TokenId,
|
||||
db::{enforce_token_limit_for_user, lookup_refresh_token},
|
||||
scopes::expand_include_scopes,
|
||||
verify_client_auth,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
@@ -132,46 +130,55 @@ pub async fn handle_authorization_code_grant(
|
||||
let refresh_token = RefreshToken::generate();
|
||||
let now = Utc::now();
|
||||
|
||||
let (raw_scope, controller_did) = if let Some(ref controller) = authorized.controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let granted_scopes = match grant {
|
||||
Some(g) => g.granted_scopes,
|
||||
None => {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Delegation grant not found or revoked".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto");
|
||||
let intersected = intersect_scopes(requested, granted_scopes.as_str());
|
||||
(Some(intersected), Some(controller.clone()))
|
||||
} else {
|
||||
(authorized.parameters.scope.clone(), None)
|
||||
};
|
||||
let controller_did = authorized.controller_did.clone();
|
||||
let requested_scope = authorized.parameters.scope.clone();
|
||||
|
||||
let final_scope = if let Some(ref scope) = raw_scope {
|
||||
if scope.contains("include:") {
|
||||
Some(expand_include_scopes(scope).await.map_err(|e| {
|
||||
OAuthError::InvalidScope(format!("Failed to expand permission set: {e}"))
|
||||
})?)
|
||||
let granted_scopes: Option<tranquil_db_traits::DbScope> =
|
||||
if let Some(ref controller) = controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
|
||||
})?;
|
||||
Some(grant.granted_scopes.clone())
|
||||
} else {
|
||||
raw_scope
|
||||
}
|
||||
} else {
|
||||
raw_scope
|
||||
None
|
||||
};
|
||||
let authority = match granted_scopes.as_ref() {
|
||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let requested_for_resolve = requested_scope.as_deref().unwrap_or("atproto");
|
||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested_for_resolve,
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Could not resolve permission set(s): {}",
|
||||
names.join(", ")
|
||||
)));
|
||||
}
|
||||
let resolved_scope = effective.permitted;
|
||||
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_id,
|
||||
&did,
|
||||
dpop_jkt.as_ref(),
|
||||
final_scope.as_deref(),
|
||||
Some(resolved_scope.as_str()),
|
||||
controller_did.as_ref(),
|
||||
)?;
|
||||
let stored_client_auth = authorized.client_auth.unwrap_or(ClientAuth::None);
|
||||
@@ -195,7 +202,7 @@ pub async fn handle_authorization_code_grant(
|
||||
details: None,
|
||||
code: None,
|
||||
current_refresh_token: Some(refresh_token.clone()),
|
||||
scope: final_scope.clone(),
|
||||
scope: requested_scope.clone(),
|
||||
controller_did: controller_did.clone(),
|
||||
};
|
||||
state
|
||||
@@ -237,12 +244,58 @@ pub async fn handle_authorization_code_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: Some(refresh_token),
|
||||
scope: final_scope,
|
||||
scope: Some(resolved_scope.clone()),
|
||||
sub: Some(did),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn recompute_resolved_scope(
|
||||
state: &AppState,
|
||||
token_data: &TokenData,
|
||||
) -> Result<String, OAuthError> {
|
||||
let requested = token_data.scope.as_deref().unwrap_or("atproto");
|
||||
let granted_scopes: Option<tranquil_db_traits::DbScope> =
|
||||
if let Some(ref controller) = token_data.controller_did {
|
||||
let grant = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&token_data.did, controller)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
|
||||
})?;
|
||||
Some(grant.granted_scopes.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let authority = match granted_scopes.as_ref() {
|
||||
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
|
||||
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
|
||||
};
|
||||
let effective = crate::endpoints::authorize::scope_resolution::resolve_effective_scopes(
|
||||
&*state.cache,
|
||||
requested,
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.given_nsid.clone())
|
||||
.collect();
|
||||
return Err(OAuthError::InvalidScope(format!(
|
||||
"Permission set(s) expired and unresolvable: {}",
|
||||
names.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(effective.permitted)
|
||||
}
|
||||
|
||||
pub async fn handle_refresh_token_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
@@ -282,11 +335,12 @@ pub async fn handle_refresh_token_grant(
|
||||
"Refresh token reuse within grace period, returning existing tokens"
|
||||
);
|
||||
let dpop_jkt = token_data.parameters.dpop_jkt.as_ref();
|
||||
let resolved = recompute_resolved_scope(&state, &token_data).await?;
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_data.token_id,
|
||||
&token_data.did,
|
||||
dpop_jkt,
|
||||
token_data.scope.as_deref(),
|
||||
Some(resolved.as_str()),
|
||||
token_data.controller_did.as_ref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
@@ -307,7 +361,7 @@ pub async fn handle_refresh_token_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: token_data.current_refresh_token,
|
||||
scope: token_data.scope,
|
||||
scope: Some(resolved),
|
||||
sub: Some(token_data.did),
|
||||
}),
|
||||
));
|
||||
@@ -396,11 +450,12 @@ pub async fn handle_refresh_token_grant(
|
||||
new_expires_at = %new_expires_at,
|
||||
"Refresh token rotated successfully"
|
||||
);
|
||||
let resolved = recompute_resolved_scope(&state, &token_data).await?;
|
||||
let access_token = create_access_token_with_delegation(
|
||||
&token_data.token_id,
|
||||
&token_data.did,
|
||||
dpop_jkt.as_ref(),
|
||||
token_data.scope.as_deref(),
|
||||
Some(resolved.as_str()),
|
||||
token_data.controller_did.as_ref(),
|
||||
)?;
|
||||
let mut response_headers = HeaderMap::new();
|
||||
@@ -421,7 +476,7 @@ pub async fn handle_refresh_token_grant(
|
||||
},
|
||||
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
refresh_token: Some(new_refresh_token),
|
||||
scope: token_data.scope,
|
||||
scope: Some(resolved),
|
||||
sub: Some(token_data.did),
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -106,6 +106,10 @@ pub async fn introspect_token(
|
||||
Ok(info) => info,
|
||||
Err(_) => return Ok(Json(inactive_response)),
|
||||
};
|
||||
let jwt_info = match tranquil_pds::oauth::verify::extract_oauth_token_info(&request.token) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Ok(Json(inactive_response)),
|
||||
};
|
||||
let token_id = TokenId::from(token_info.sid.clone());
|
||||
let token_data = match state.repos.oauth.get_token_by_id(&token_id).await {
|
||||
Ok(Some(data)) => data,
|
||||
@@ -118,7 +122,7 @@ pub async fn introspect_token(
|
||||
let issuer = format!("https://{}", pds_hostname);
|
||||
Ok(Json(IntrospectResponse {
|
||||
active: true,
|
||||
scope: token_data.scope,
|
||||
scope: jwt_info.scope,
|
||||
client_id: Some(token_data.client_id),
|
||||
username: None,
|
||||
token_type: if token_data.parameters.dpop_jkt.is_some() {
|
||||
@@ -129,7 +133,7 @@ pub async fn introspect_token(
|
||||
exp: Some(token_info.exp),
|
||||
iat: Some(token_info.iat),
|
||||
nbf: Some(token_info.iat),
|
||||
sub: Some(token_data.did.to_string()),
|
||||
sub: Some(jwt_info.did.to_string()),
|
||||
aud: Some(issuer.clone()),
|
||||
iss: Some(issuer),
|
||||
jti: Some(token_info.jti),
|
||||
|
||||
@@ -1023,7 +1023,9 @@ pub async fn complete_registration(
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did =
|
||||
tranquil_pds::types::Did::from(format!("did:web:{}", encoded_handle));
|
||||
tranquil_pds::types::Did::new(format!("did:web:{}", encoded_handle)).map_err(
|
||||
|_| ApiError::InvalidHandle(Some("Handle is not a valid did:web".into())),
|
||||
)?;
|
||||
tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
|
||||
self_hosted_did
|
||||
}
|
||||
@@ -1151,8 +1153,8 @@ pub async fn complete_registration(
|
||||
.filter(|s| !s.is_empty()),
|
||||
encrypted_key_bytes: encrypted_key_bytes.clone(),
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
commit_cid: tranquil_types::CidLink::from(commit_cid.to_string()),
|
||||
repo_rev: tranquil_types::Tid::from(rev.as_ref().to_string()),
|
||||
commit_cid: tranquil_types::CidLink::from_cid(&commit_cid),
|
||||
repo_rev: tranquil_types::Tid::from(rev.clone()),
|
||||
genesis_block_cids,
|
||||
invite_code: invite_registration.into_invite_code(),
|
||||
birthdate_pref,
|
||||
@@ -1207,20 +1209,23 @@ pub async fn complete_registration(
|
||||
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle.as_str()
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
#[cfg(feature = "bsky")]
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle.as_str()
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
};
|
||||
}
|
||||
|
||||
let app_password = generate_app_password();
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
[dependencies]
|
||||
tranquil-types = { workspace = true }
|
||||
tranquil-config = { workspace = true }
|
||||
tranquil-crypto = { workspace = true }
|
||||
tranquil-storage = { workspace = true }
|
||||
tranquil-cache = { workspace = true }
|
||||
tranquil-repo = { workspace = true }
|
||||
@@ -29,13 +28,11 @@ axum = { workspace = true }
|
||||
base32 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
bs58 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
governor = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
@@ -43,7 +40,6 @@ hkdf = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
http = { workspace = true }
|
||||
image = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
ipld-core = { workspace = true }
|
||||
iroh-car = { workspace = true }
|
||||
jacquard-common = { workspace = true }
|
||||
@@ -57,7 +53,6 @@ multihash = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
redis = { workspace = true, optional = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -70,7 +65,6 @@ sqlx = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
@@ -80,16 +74,14 @@ urlencoding = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
webauthn-rs = { workspace = true }
|
||||
webauthn-rs-proto = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-sdk-s3 = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["frontend", "s3", "valkey"]
|
||||
bsky = ["bsky-support"]
|
||||
bsky-support = []
|
||||
external-infra = []
|
||||
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
|
||||
s3 = ["s3-storage"]
|
||||
valkey = ["tranquil-cache/valkey", "dep:redis"]
|
||||
postgres = ["tranquil-db/postgres"]
|
||||
s3 = ["tranquil-storage/s3"]
|
||||
valkey = ["tranquil-cache/valkey"]
|
||||
frontend = []
|
||||
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
|
||||
|
||||
@@ -105,3 +97,8 @@ tranquil-api = { workspace = true }
|
||||
tranquil-oauth-server = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
wiremock = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
aws-config = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
|
||||
@@ -763,6 +763,8 @@ impl From<crate::api::validation::HandleValidationError> for ApiError {
|
||||
HandleValidationError::BannedWord => {
|
||||
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
|
||||
}
|
||||
HandleValidationError::UnusableHandleDomain
|
||||
| HandleValidationError::NoHandleDomains => Self::InternalError(Some(e.to_string())),
|
||||
_ => Self::InvalidHandle(Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod error;
|
||||
pub mod invite;
|
||||
pub mod proxy;
|
||||
pub mod proxy_client;
|
||||
pub mod query;
|
||||
pub mod responses;
|
||||
pub mod validation;
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
"app.bsky.actor.getPreferences",
|
||||
"app.bsky.actor.putPreferences",
|
||||
let mut methods: HashSet<&str> = [
|
||||
"com.atproto.admin.deleteAccount",
|
||||
"com.atproto.admin.disableAccountInvites",
|
||||
"com.atproto.admin.disableInviteCodes",
|
||||
@@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
"com.atproto.temp.dereferenceScope",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
.collect();
|
||||
// BSKY: the Bluesky preferences API must be implemented by PDSs
|
||||
if cfg!(feature = "bsky-support") {
|
||||
methods.insert("app.bsky.actor.getPreferences");
|
||||
methods.insert("app.bsky.actor.putPreferences");
|
||||
};
|
||||
methods
|
||||
});
|
||||
|
||||
fn is_protected_method(method: &str) -> bool {
|
||||
@@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Fetch the `feed` generator record from the AppView and return its `did`.
|
||||
#[cfg(feature = "bsky-support")]
|
||||
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<Did> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GetFeedQuery {
|
||||
@@ -201,6 +206,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
|
||||
}
|
||||
|
||||
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
|
||||
#[cfg(feature = "bsky")]
|
||||
if tranquil_config::get().server.age_assurance_override
|
||||
&& (path.ends_with("app.bsky.ageassurance.getState")
|
||||
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
|
||||
@@ -328,27 +334,28 @@ async fn proxy_handler(
|
||||
},
|
||||
};
|
||||
|
||||
// getFeed must be audienced to the feed generator, not the AppView.
|
||||
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
|
||||
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||
Some(feed_did) => (
|
||||
feed_did,
|
||||
"app.bsky.feed.getFeedSkeleton"
|
||||
.parse::<Nsid>()
|
||||
.expect("getFeedSkeleton is a valid NSID"),
|
||||
),
|
||||
None => {
|
||||
warn!(
|
||||
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||
// BSKY: getFeed must be audienced to the feed generator, not the AppView.
|
||||
let (token_aud, token_lxm) =
|
||||
if cfg!(feature = "bsky-support") && method == "app.bsky.feed.getFeed" {
|
||||
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
|
||||
Some(feed_did) => (
|
||||
feed_did,
|
||||
"app.bsky.feed.getFeedSkeleton"
|
||||
.parse::<Nsid>()
|
||||
.expect("getFeedSkeleton is a valid NSID"),
|
||||
),
|
||||
None => {
|
||||
warn!(
|
||||
"getFeed proxy: could not resolve feed generator DID; refusing \
|
||||
to mint an AppView-audienced token"
|
||||
);
|
||||
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||
.into_response();
|
||||
);
|
||||
return ApiError::InvalidRequest("Could not resolve feed".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(resolved.did.clone(), method_nsid.clone())
|
||||
};
|
||||
} else {
|
||||
(resolved.did.clone(), method_nsid.clone())
|
||||
};
|
||||
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
|
||||
@@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError {
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfError {}
|
||||
|
||||
// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts
|
||||
// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350
|
||||
#[cfg(feature = "bsky-support")]
|
||||
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
|
||||
[
|
||||
HeaderName::from_static("accept-language"),
|
||||
@@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
|
||||
http::header::CONTENT_TYPE,
|
||||
]
|
||||
});
|
||||
#[cfg(not(feature = "bsky-support"))]
|
||||
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| {
|
||||
[
|
||||
HeaderName::from_static("accept-language"),
|
||||
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
|
||||
http::header::CONTENT_TYPE,
|
||||
]
|
||||
});
|
||||
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
|
||||
[
|
||||
crate::util::HEADER_ATPROTO_REPO_REV,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use super::error::ApiError;
|
||||
|
||||
pub struct XrpcQuery<T>(pub T);
|
||||
|
||||
impl<T: DeserializeOwned, S: Send + Sync> FromRequestParts<S> for XrpcQuery<T> {
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let raw = parts.uri.query().unwrap_or_default();
|
||||
serde_urlencoded::from_str(raw)
|
||||
.map(Self)
|
||||
.map_err(|e| ApiError::InvalidRequest(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ pub struct DidResponse {
|
||||
}
|
||||
|
||||
impl DidResponse {
|
||||
pub fn response(did: impl Into<Did>) -> impl IntoResponse {
|
||||
Json(Self { did: did.into() })
|
||||
pub fn response(did: Did) -> impl IntoResponse {
|
||||
Json(Self { did })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,13 +101,17 @@ fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
|
||||
pub enum HandleValidationError {
|
||||
Empty,
|
||||
TooShort,
|
||||
TooLong,
|
||||
TooLong { max: usize },
|
||||
InvalidCharacters,
|
||||
StartsWithInvalidChar,
|
||||
EndsWithInvalidChar,
|
||||
ContainsSpaces,
|
||||
BannedWord,
|
||||
Reserved,
|
||||
InvalidSyntax,
|
||||
DisallowedTld,
|
||||
UnusableHandleDomain,
|
||||
NoHandleDomains,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HandleValidationError {
|
||||
@@ -119,11 +123,9 @@ impl std::fmt::Display for HandleValidationError {
|
||||
"Handle must be at least {} characters",
|
||||
MIN_HANDLE_LENGTH
|
||||
),
|
||||
Self::TooLong => write!(
|
||||
f,
|
||||
"Handle exceeds maximum length of {} characters",
|
||||
MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
),
|
||||
Self::TooLong { max } => {
|
||||
write!(f, "Handle exceeds maximum length of {} characters", max)
|
||||
}
|
||||
Self::InvalidCharacters => write!(
|
||||
f,
|
||||
"Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed"
|
||||
@@ -135,6 +137,15 @@ impl std::fmt::Display for HandleValidationError {
|
||||
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
|
||||
Self::BannedWord => write!(f, "Inappropriate language in handle"),
|
||||
Self::Reserved => write!(f, "Reserved handle"),
|
||||
Self::InvalidSyntax => write!(f, "Handle does not match atproto handle syntax"),
|
||||
Self::DisallowedTld => write!(f, "Handle uses a reserved TLD and cannot resolve"),
|
||||
Self::UnusableHandleDomain => write!(
|
||||
f,
|
||||
"This server's handle domain has a reserved TLD, so no handle under it is a valid atproto handle"
|
||||
),
|
||||
Self::NoHandleDomains => {
|
||||
write!(f, "No handle domains are configured on this server")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +170,9 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
|
||||
}
|
||||
|
||||
if handle.len() > MAX_HANDLE_LENGTH {
|
||||
return Err(HandleValidationError::TooLong);
|
||||
return Err(HandleValidationError::TooLong {
|
||||
max: MAX_HANDLE_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
if handle
|
||||
@@ -190,7 +203,11 @@ pub fn validate_full_domain_handle(handle: &str) -> Result<Handle, HandleValidat
|
||||
return Err(HandleValidationError::BannedWord);
|
||||
}
|
||||
|
||||
Ok(Handle::from(handle_lower))
|
||||
let handle = Handle::new(handle_lower).map_err(|_| HandleValidationError::InvalidSyntax)?;
|
||||
match handle.has_disallowed_tld() {
|
||||
true => Err(HandleValidationError::DisallowedTld),
|
||||
false => Ok(handle),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
@@ -210,16 +227,40 @@ pub fn resolve_handle_input(input: &str) -> Result<Handle, HandleValidationError
|
||||
None => input,
|
||||
};
|
||||
let validated = validate_short_handle(handle_to_validate)?;
|
||||
Ok(Handle::from(format!(
|
||||
"{}.{}",
|
||||
validated,
|
||||
matched_domain.unwrap_or(&available_domains[0])
|
||||
)))
|
||||
let domain = matched_domain
|
||||
.or_else(|| available_domains.first())
|
||||
.ok_or(HandleValidationError::NoHandleDomains)?;
|
||||
let handle = Handle::new(format!("{}.{}", validated, domain))
|
||||
.map_err(|_| HandleValidationError::InvalidSyntax)?;
|
||||
match handle.has_disallowed_tld() {
|
||||
true => Err(HandleValidationError::UnusableHandleDomain),
|
||||
false => Ok(handle),
|
||||
}
|
||||
} else {
|
||||
validate_full_domain_handle(input)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn domain_forms_valid_handles(domain: &str) -> bool {
|
||||
Handle::new(format!("whelk.{domain}")).is_ok_and(|h| !h.has_disallowed_tld())
|
||||
}
|
||||
|
||||
pub fn warn_unusable_handle_domains() {
|
||||
tranquil_config::get()
|
||||
.server
|
||||
.user_handle_domain_list()
|
||||
.iter()
|
||||
.filter(|domain| !domain_forms_valid_handles(domain))
|
||||
.for_each(|domain| {
|
||||
tracing::error!(
|
||||
domain = %domain,
|
||||
"configured handle domain can't form a valid atproto handle, so every account \
|
||||
creation under it will be rejected. Set server.user_handle_domains to a domain \
|
||||
whose TLD isn't reserved."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn validate_service_handle(
|
||||
handle: &str,
|
||||
reserved_policy: ReservedHandlePolicy,
|
||||
@@ -239,7 +280,9 @@ pub fn validate_service_handle(
|
||||
}
|
||||
|
||||
if handle.len() > MAX_SERVICE_HANDLE_LOCAL_PART {
|
||||
return Err(HandleValidationError::TooLong);
|
||||
return Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(first_char) = handle.chars().next()
|
||||
@@ -314,6 +357,19 @@ mod tests {
|
||||
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_domain_handles_with_reserved_tlds_are_rejected() {
|
||||
assert!(validate_full_domain_handle("whelk.oyster.cafe").is_ok());
|
||||
assert_eq!(
|
||||
validate_full_domain_handle("whelk.pds.internal"),
|
||||
Err(HandleValidationError::DisallowedTld)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_full_domain_handle("handle.invalid"),
|
||||
Err(HandleValidationError::DisallowedTld)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_handles() {
|
||||
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
|
||||
@@ -388,11 +444,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("exactly19characters"),
|
||||
Err(HandleValidationError::TooLong)
|
||||
Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("waytoolongusername123456789"),
|
||||
Err(HandleValidationError::TooLong)
|
||||
Err(HandleValidationError::TooLong {
|
||||
max: MAX_SERVICE_HANDLE_LOCAL_PART
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_create_and_validate_token() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -206,7 +206,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_token_consumed_after_use() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -224,7 +224,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_invalid_token_rejected() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let _token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -238,7 +238,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_wrong_purpose_rejected() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -253,7 +253,7 @@ mod tests {
|
||||
async fn test_token_format() {
|
||||
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
(0..50).for_each(|_| {
|
||||
let token = futures::executor::block_on(create_email_token(
|
||||
&cache,
|
||||
@@ -270,7 +270,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_case_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -285,7 +285,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_hyphen_insensitive_validation() {
|
||||
let cache = MockCache::new();
|
||||
let did = Did::from("did:plc:teq".to_string());
|
||||
let did = Did::new("did:plc:teq").expect("valid DID");
|
||||
|
||||
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
|
||||
.await
|
||||
@@ -300,7 +300,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_noop_cache_returns_unavailable() {
|
||||
let cache = crate::cache::NoOpCache;
|
||||
let did = Did::from("did:plc:whelk".to_string());
|
||||
let did = Did::new("did:plc:whelk").expect("valid DID");
|
||||
|
||||
let result = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail).await;
|
||||
assert_eq!(result.unwrap_err(), TokenError::CacheUnavailable);
|
||||
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn n(s: &str) -> crate::types::Nsid {
|
||||
crate::types::Nsid::from(s.to_string())
|
||||
crate::types::Nsid::new(s).expect("valid NSID")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,3 +39,10 @@ pub fn scope_ref_key(cid: &CidLink) -> String {
|
||||
pub fn auto_verify_sent_key(did: &Did) -> String {
|
||||
format!("auto_verify_sent:{}", did)
|
||||
}
|
||||
|
||||
pub fn permission_set_key(nsid: &tranquil_types::Nsid, aud: Option<&str>) -> String {
|
||||
match aud {
|
||||
Some(a) => format!("permset:{}:{}", nsid, a),
|
||||
None => format!("permset:{}", nsid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,13 @@ impl Crawlers {
|
||||
return None;
|
||||
}
|
||||
|
||||
let crawler_urls = cfg.firehose.crawler_list();
|
||||
let crawler_urls = &cfg.firehose.crawlers;
|
||||
|
||||
if crawler_urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::new(hostname.to_string(), crawler_urls))
|
||||
Some(Self::new(hostname.to_string(), crawler_urls.clone()))
|
||||
}
|
||||
|
||||
fn should_notify(&self) -> bool {
|
||||
|
||||
@@ -6,7 +6,7 @@ pub use roles::{
|
||||
};
|
||||
pub use scopes::{
|
||||
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
|
||||
ValidatedDelegationScope, intersect_scopes,
|
||||
ValidatedDelegationScope, grant_covers, intersect_scopes,
|
||||
};
|
||||
pub use tranquil_db_traits::DelegationActionType;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tranquil_scopes::{covers, parse_scope};
|
||||
|
||||
pub use tranquil_db_traits::{
|
||||
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
|
||||
};
|
||||
@@ -46,12 +48,13 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
|
||||
|
||||
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
|
||||
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
|
||||
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
|
||||
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
|
||||
granted.split_whitespace().map(parse_scope).collect();
|
||||
|
||||
let mut scopes: Vec<&str> = requested_set
|
||||
.iter()
|
||||
.filter(|requested_scope| {
|
||||
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set)
|
||||
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed)
|
||||
})
|
||||
.copied()
|
||||
.chain(requested_set.contains("atproto").then_some("atproto"))
|
||||
@@ -60,81 +63,18 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String {
|
||||
scopes.join(" ")
|
||||
}
|
||||
|
||||
fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool {
|
||||
granted
|
||||
.iter()
|
||||
.any(|granted_scope| scope_covers(granted_scope, requested))
|
||||
}
|
||||
|
||||
fn scope_covers(granted: &str, requested: &str) -> bool {
|
||||
if granted == requested {
|
||||
pub fn grant_covers(granted: &str, scope: &str) -> bool {
|
||||
if scope == "atproto" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let (granted_base, granted_params) = split_scope(granted);
|
||||
let (requested_base, requested_params) = split_scope(requested);
|
||||
|
||||
let base_matches = if granted_base.ends_with(":*")
|
||||
&& requested_base.starts_with(&granted_base[..granted_base.len() - 1])
|
||||
{
|
||||
true
|
||||
} else if let Some(prefix) = granted_base.strip_suffix(".*")
|
||||
&& requested_base.starts_with(prefix)
|
||||
&& requested_base.len() > prefix.len()
|
||||
{
|
||||
true
|
||||
} else {
|
||||
granted_base == requested_base
|
||||
};
|
||||
|
||||
if !base_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (granted_params, requested_params) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => true,
|
||||
(Some(gp), Some(rp)) => params_cover(gp, rp),
|
||||
}
|
||||
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
|
||||
granted.split_whitespace().map(parse_scope).collect();
|
||||
any_granted_covers(scope, &granted_parsed)
|
||||
}
|
||||
|
||||
fn params_cover(granted_params: &str, requested_params: &str) -> bool {
|
||||
let granted_kv: HashSet<(&str, &str)> = granted_params
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.collect();
|
||||
let requested_kv: HashSet<(&str, &str)> = requested_params
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.collect();
|
||||
|
||||
let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect();
|
||||
let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect();
|
||||
|
||||
requested_keys.iter().all(|key| {
|
||||
if !granted_keys.contains(key) {
|
||||
return false;
|
||||
}
|
||||
let requested_values: HashSet<&str> = requested_kv
|
||||
.iter()
|
||||
.filter(|(k, _)| k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.collect();
|
||||
let granted_values: HashSet<&str> = granted_kv
|
||||
.iter()
|
||||
.filter(|(k, _)| k == key)
|
||||
.map(|(_, v)| *v)
|
||||
.collect();
|
||||
requested_values.is_subset(&granted_values)
|
||||
})
|
||||
}
|
||||
|
||||
fn split_scope(scope: &str) -> (&str, Option<&str>) {
|
||||
if let Some(idx) = scope.find('?') {
|
||||
(&scope[..idx], Some(&scope[idx + 1..]))
|
||||
} else {
|
||||
(scope, None)
|
||||
}
|
||||
fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool {
|
||||
let requested_parsed = parse_scope(requested);
|
||||
granted.iter().any(|g| covers(g, &requested_parsed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -280,12 +220,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersect_granted_with_params_covers_requested_no_params() {
|
||||
fn test_intersect_partial_action_grant_drops_actionless_request() {
|
||||
let result = intersect_scopes(
|
||||
"repo:app.bsky.feed.post",
|
||||
"repo:*?action=create&action=delete",
|
||||
);
|
||||
assert_eq!(result, "repo:app.bsky.feed.post");
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,39 +237,6 @@ mod tests {
|
||||
assert_eq!(result, "repo:*?action=create");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_base_only() {
|
||||
assert!(scope_covers("repo:*", "repo:app.bsky.feed.post"));
|
||||
assert!(scope_covers(
|
||||
"repo:*",
|
||||
"repo:app.bsky.feed.post?action=create"
|
||||
));
|
||||
assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_params() {
|
||||
assert!(scope_covers("repo:*?action=create", "repo:*?action=create"));
|
||||
assert!(!scope_covers(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=delete"
|
||||
));
|
||||
assert!(scope_covers(
|
||||
"repo:*?action=create&action=delete",
|
||||
"repo:*?action=create"
|
||||
));
|
||||
assert!(!scope_covers(
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=create&action=delete"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_covers_no_granted_params_means_all() {
|
||||
assert!(scope_covers("repo:*", "repo:*?action=create"));
|
||||
assert!(scope_covers("repo:*", "repo:*?action=delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_scopes_valid() {
|
||||
assert!(ValidatedDelegationScope::new("atproto").is_ok());
|
||||
@@ -353,4 +260,35 @@ mod tests {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grant_covers_matches_intersection() {
|
||||
let granted = "atproto repo:* blob:*/* account:*?action=manage";
|
||||
let intersected = intersect_scopes(
|
||||
"repo:app.bsky.feed.post?action=create identity:* account:*?action=manage",
|
||||
granted,
|
||||
);
|
||||
assert!(grant_covers(
|
||||
granted,
|
||||
"repo:app.bsky.feed.post?action=create"
|
||||
));
|
||||
assert!(grant_covers(granted, "account:*?action=manage"));
|
||||
assert!(!grant_covers(granted, "identity:*"));
|
||||
assert_eq!(
|
||||
grant_covers(granted, "identity:*"),
|
||||
intersected.contains("identity")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grant_covers_atproto_always_true() {
|
||||
assert!(grant_covers("", "atproto"));
|
||||
assert!(grant_covers("repo:*", "atproto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grant_covers_empty_grant_covers_nothing_else() {
|
||||
assert!(!grant_covers("", "repo:app.bsky.feed.post?action=create"));
|
||||
assert!(!grant_covers("", "identity:*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// TODO: make all of this configurable.
|
||||
// PDS implementation should not impose any reserved domains imo.
|
||||
// some of these are even bad to have as defaults let alone non-configurables
|
||||
const ATP_SPECIFIC: &[&str] = &[
|
||||
"at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky",
|
||||
"bluesky", "handle",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod db;
|
||||
pub mod permission_set_resolver;
|
||||
pub mod scopes;
|
||||
pub mod verify;
|
||||
|
||||
@@ -20,6 +21,7 @@ pub use tranquil_oauth::{
|
||||
compute_pkce_challenge, verify_client_auth,
|
||||
};
|
||||
|
||||
pub use permission_set_resolver::expand_scopes;
|
||||
pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions};
|
||||
pub use verify::{
|
||||
OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
use crate::cache::Cache;
|
||||
use crate::cache_keys::permission_set_key;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tranquil_scopes::{
|
||||
ExpansionOutcome, FailedSet, ResolveFailure, ResolvedSetGroup, ScopeExpansionError,
|
||||
fetch_and_expand, parse_include_scope,
|
||||
};
|
||||
use tranquil_types::Nsid;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CachedPermissionSet {
|
||||
scope: String,
|
||||
title: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[serde(default)]
|
||||
refreshed_at: i64,
|
||||
}
|
||||
|
||||
const STALE_AFTER_SECS: i64 = 24 * 60 * 60;
|
||||
const PERMISSION_SET_CACHE_TTL_SECS: u64 = 90 * 24 * 60 * 60;
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
fn is_stale(refreshed_at: i64) -> bool {
|
||||
now_secs().saturating_sub(refreshed_at) >= STALE_AFTER_SECS
|
||||
}
|
||||
|
||||
pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome {
|
||||
let mut outcome = ExpansionOutcome::default();
|
||||
for tok in scope_string.split_whitespace() {
|
||||
match tok.strip_prefix("include:") {
|
||||
None => outcome.passthrough.push(tok.to_string()),
|
||||
Some(rest) => {
|
||||
let (nsid, aud) = parse_include_scope(rest);
|
||||
match resolve_one(cache, nsid, aud).await {
|
||||
Ok(group) => outcome.sets.push(group),
|
||||
Err(reason) => outcome.failures.push(FailedSet {
|
||||
given_nsid: nsid.to_string(),
|
||||
given_aud: aud.map(str::to_string),
|
||||
reason,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn resolve_one(
|
||||
cache: &dyn Cache,
|
||||
nsid: &str,
|
||||
aud: Option<&str>,
|
||||
) -> Result<ResolvedSetGroup, ResolveFailure> {
|
||||
let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Malformed)?;
|
||||
let key = permission_set_key(&parsed, aud);
|
||||
|
||||
let cached = cache
|
||||
.get(&key)
|
||||
.await
|
||||
.and_then(|json| serde_json::from_str::<CachedPermissionSet>(&json).ok());
|
||||
|
||||
if let Some(v) = &cached
|
||||
&& !is_stale(v.refreshed_at)
|
||||
{
|
||||
return Ok(group_from(
|
||||
parsed,
|
||||
aud,
|
||||
v.scope.clone(),
|
||||
v.title.clone(),
|
||||
v.detail.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
match fetch_and_expand(&parsed, aud).await {
|
||||
Ok(fetched) => {
|
||||
let stored = CachedPermissionSet {
|
||||
scope: fetched.expanded.clone(),
|
||||
title: fetched.title.clone(),
|
||||
detail: fetched.detail.clone(),
|
||||
refreshed_at: now_secs(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&stored) {
|
||||
let _ = cache
|
||||
.set(
|
||||
&key,
|
||||
&json,
|
||||
Duration::from_secs(PERMISSION_SET_CACHE_TTL_SECS),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(group_from(
|
||||
parsed,
|
||||
aud,
|
||||
fetched.expanded,
|
||||
fetched.title,
|
||||
fetched.detail,
|
||||
))
|
||||
}
|
||||
Err(e) => match cached {
|
||||
Some(v) => Ok(group_from(parsed, aud, v.scope, v.title, v.detail)),
|
||||
None => Err(map_err(&e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn group_from(
|
||||
nsid: Nsid,
|
||||
aud: Option<&str>,
|
||||
scope: String,
|
||||
title: Option<String>,
|
||||
detail: Option<String>,
|
||||
) -> ResolvedSetGroup {
|
||||
ResolvedSetGroup {
|
||||
nsid,
|
||||
aud: aud.map(str::to_string),
|
||||
title,
|
||||
detail,
|
||||
expanded: scope.split_whitespace().map(str::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: &ScopeExpansionError) -> ResolveFailure {
|
||||
use ScopeExpansionError as E;
|
||||
match e {
|
||||
E::InvalidNsid(_) => ResolveFailure::Malformed,
|
||||
E::RecordNotFound => ResolveFailure::NotFound,
|
||||
E::UnexpectedType(_) => ResolveFailure::NotAPermissionSet,
|
||||
E::MissingDefinition(_) => ResolveFailure::MalformedLexicon,
|
||||
E::EmptyPermissions => ResolveFailure::EmptyPermissions,
|
||||
E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::Unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cache::{Cache, CacheError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MapCache(Mutex<HashMap<String, String>>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Cache for MapCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
self.0.lock().unwrap().get(key).cloned()
|
||||
}
|
||||
async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.0.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_at(cache: &MapCache, nsid: &str, scope: &str, refreshed_at: i64) {
|
||||
let key =
|
||||
crate::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
|
||||
let val = serde_json::to_string(&CachedPermissionSet {
|
||||
scope: scope.to_string(),
|
||||
title: Some("Basic".into()),
|
||||
detail: None,
|
||||
refreshed_at,
|
||||
})
|
||||
.unwrap();
|
||||
cache.0.lock().unwrap().insert(key, val);
|
||||
}
|
||||
|
||||
fn seed(cache: &MapCache, nsid: &str, scope: &str) {
|
||||
seed_at(cache, nsid, scope, now_secs());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_hit_expands_without_network() {
|
||||
let cache = MapCache::default();
|
||||
seed(
|
||||
&cache,
|
||||
"io.atcr.authFullApp",
|
||||
"repo:io.atcr.manifest?action=create identity:*",
|
||||
);
|
||||
let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await;
|
||||
assert!(out.failures.is_empty());
|
||||
assert_eq!(out.passthrough, vec!["atproto".to_string()]);
|
||||
assert_eq!(out.sets.len(), 1);
|
||||
assert_eq!(out.sets[0].nsid, "io.atcr.authFullApp");
|
||||
assert!(
|
||||
out.flat_scopes()
|
||||
.iter()
|
||||
.any(|s| s == "repo:io.atcr.manifest?action=create")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_entry_is_served_when_refresh_fails() {
|
||||
let cache = MapCache::default();
|
||||
seed_at(
|
||||
&cache,
|
||||
"nonexistent.fake.permissionSet",
|
||||
"repo:nonexistent.fake.record?action=create",
|
||||
now_secs() - STALE_AFTER_SECS - 1,
|
||||
);
|
||||
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
|
||||
assert!(
|
||||
out.failures.is_empty(),
|
||||
"stale entry should survive an unresolvable publisher"
|
||||
);
|
||||
assert_eq!(out.sets.len(), 1);
|
||||
assert!(
|
||||
out.flat_scopes()
|
||||
.iter()
|
||||
.any(|s| s == "repo:nonexistent.fake.record?action=create")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entry_without_refreshed_at_is_treated_as_stale_but_usable() {
|
||||
let cache = MapCache::default();
|
||||
let key = crate::cache_keys::permission_set_key(
|
||||
&tranquil_types::Nsid::new("nonexistent.fake.permissionSet").unwrap(),
|
||||
None,
|
||||
);
|
||||
// Shape written before `refreshed_at` existed.
|
||||
let legacy =
|
||||
r#"{"scope":"repo:nonexistent.fake.record?action=create","title":null,"detail":null}"#;
|
||||
cache.0.lock().unwrap().insert(key, legacy.to_string());
|
||||
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
|
||||
assert!(out.failures.is_empty());
|
||||
assert_eq!(out.sets.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passthrough_scopes_untouched() {
|
||||
let cache = MapCache::default();
|
||||
let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await;
|
||||
assert!(out.failures.is_empty());
|
||||
assert!(out.sets.is_empty());
|
||||
assert_eq!(out.flat_scopes().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_miss_unresolvable_is_a_failure() {
|
||||
let cache = MapCache::default();
|
||||
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
|
||||
assert_eq!(out.sets.len(), 0);
|
||||
assert_eq!(out.failures.len(), 1);
|
||||
assert_eq!(out.failures[0].given_nsid, "nonexistent.fake.permissionSet");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub use tranquil_scopes::{
|
||||
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope,
|
||||
ParsedScope, RepoAction, RepoScope, RpcScope, SCOPE_DEFINITIONS, ScopeCategory,
|
||||
ScopeDefinition, ScopeError, ScopeExpansionError, ScopePermissions, expand_include_scopes,
|
||||
format_scope_for_display, get_required_scopes, get_scope_definition, is_valid_scope,
|
||||
parse_scope, parse_scope_string,
|
||||
ScopeDefinition, ScopeError, ScopeExpansionError, ScopePermissions, format_scope_for_display,
|
||||
get_required_scopes, get_scope_definition, is_valid_scope, parse_scope, parse_scope_string,
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ pub async fn verify_oauth_access_token(
|
||||
did: token_data.did,
|
||||
token_id,
|
||||
client_id: token_data.client_id,
|
||||
scope: token_data.scope,
|
||||
scope: token_info.scope,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -561,7 +561,8 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result<Did, PlcError> {
|
||||
let hash = hasher.finalize();
|
||||
let encoded = base32::encode(Alphabet::Rfc4648Lower { padding: false }, &hash);
|
||||
let truncated = &encoded[..24];
|
||||
Ok(Did::from(format!("did:plc:{}", truncated)))
|
||||
Ok(Did::new(format!("did:plc:{}", truncated))
|
||||
.expect("did:plc with 24 base32 characters of a sha256 digest is a valid DID"))
|
||||
}
|
||||
|
||||
pub fn validate_plc_operation(op: &Value) -> Result<PlcOpType, PlcError> {
|
||||
@@ -784,7 +785,7 @@ mod tests {
|
||||
let result = create_genesis_operation(
|
||||
&key,
|
||||
Some(operator_key),
|
||||
&crate::types::Handle::from("whelk.nel.pet".to_string()),
|
||||
&crate::types::Handle::new("whelk.nel.pet").expect("valid handle"),
|
||||
"https://nel.pet",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -14,7 +14,7 @@ use jacquard_repo::mst::{Mst, VerifiedWriteOp};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -115,6 +115,10 @@ pub fn extract_blob_cids(record: &Value) -> Vec<crate::types::CidLink> {
|
||||
use crate::types::AtUri;
|
||||
use tranquil_db_traits::{Backlink, BacklinkPath};
|
||||
|
||||
// TODO: it really really should not be necessary to extract backlinks and store those.
|
||||
// especially not in a way that isnt generic.
|
||||
// figure out what the fuck is going on here
|
||||
// (lewis do you remember why you did this???)
|
||||
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
|
||||
let record_type = record
|
||||
.get("$type")
|
||||
@@ -262,7 +266,7 @@ pub async fn repair_repo_structure(
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let data_root = commit.data;
|
||||
let repo_rev = crate::types::Tid::from(commit.rev().to_string());
|
||||
let repo_rev = crate::types::Tid::from(commit.rev().clone());
|
||||
|
||||
let records = state
|
||||
.repos
|
||||
@@ -323,30 +327,67 @@ pub async fn repair_repo_structure(
|
||||
missing.len()
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
let block_cids =
|
||||
crate::scheduled::collect_current_repo_blocks(&state.block_store, ¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: re-walk for user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let walk = crate::scheduled::collect_current_repo_blocks(&state.block_store, ¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: re-walk for user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let recorded = state
|
||||
.repos
|
||||
.repo
|
||||
.get_user_block_cids_since_rev(user_id, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: reading recorded user_blocks set failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let reachable: HashSet<&[u8]> = walk.block_cids.iter().map(Vec::as_slice).collect();
|
||||
let stale: Vec<Vec<u8>> = match walk.is_complete() {
|
||||
true => recorded
|
||||
.into_iter()
|
||||
.filter(|cid| !reachable.contains(cid.as_slice()))
|
||||
.collect(),
|
||||
false => {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
unreadable = walk.unreadable,
|
||||
"repair: repo walk couldn't read every block, so its complement isn't the \
|
||||
unreachable set. Backfilling without pruning."
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &walk.block_cids, &repo_rev)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: user_blocks backfill failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
if !stale.is_empty() {
|
||||
state
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &block_cids, &repo_rev)
|
||||
.delete_user_blocks(user_id, &stale)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("repair: user_blocks backfill failed: {}", e);
|
||||
error!("repair: pruning unreachable user_blocks rows failed: {}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
blocks = block_cids.len(),
|
||||
"repair: backfilled user_blocks from repaired DAG"
|
||||
);
|
||||
}
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
blocks = walk.block_cids.len(),
|
||||
pruned = stale.len(),
|
||||
"repair: rewrote user_blocks from current DAG"
|
||||
);
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
@@ -460,6 +501,69 @@ pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
|
||||
});
|
||||
}
|
||||
|
||||
async fn leaves_referenced_elsewhere(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
dropped_leaves: &BTreeSet<Cid>,
|
||||
ops: &[RecordOp],
|
||||
) -> BTreeSet<Cid> {
|
||||
if dropped_leaves.is_empty() {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let by_link: BTreeMap<crate::types::CidLink, Cid> = dropped_leaves
|
||||
.iter()
|
||||
.map(|cid| (crate::types::CidLink::from_cid(cid), *cid))
|
||||
.collect();
|
||||
let cids: Vec<crate::types::CidLink> = by_link.keys().cloned().collect();
|
||||
// Because the commit only runs later on, records table still shows
|
||||
// this write's pre-write state at this point
|
||||
// so excluding the keys that this write touches is the thing that
|
||||
// keeps a deleted record from counting as a reference to its own block.
|
||||
let touched: Vec<(&Nsid, &Rkey)> = ops.iter().map(RecordOp::collection_rkey).collect();
|
||||
// The two failure directions aren't symmetric at all
|
||||
// because a user_blocks row for a block that's already gone is only noise
|
||||
// that the scheduled repair clears...
|
||||
// While a *missing* row for a block that's still reachable
|
||||
// makes `getRepo` with a since-cursor give an incomplete tree
|
||||
// and thus any doubt at all about the lookup ought to mean keeping every row.
|
||||
let keep_everything = |reason: &str| {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
"{reason}. Keeping the user_blocks row for every record block this write removed. \
|
||||
Their refcounts still drop, so a row may remain after its block is gone until the \
|
||||
scheduled structural repair rewrites the set."
|
||||
);
|
||||
schedule_repo_repair(state, user_id);
|
||||
dropped_leaves.clone()
|
||||
};
|
||||
match state
|
||||
.repos
|
||||
.repo
|
||||
.referenced_record_cids(user_id, &cids, &touched)
|
||||
.await
|
||||
{
|
||||
Ok(referenced) => referenced
|
||||
.iter()
|
||||
.map(|link| by_link.get(link).copied())
|
||||
.collect::<Option<BTreeSet<Cid>>>()
|
||||
.unwrap_or_else(|| {
|
||||
keep_everything("record cid reference lookup returned a cid that wasn't queried")
|
||||
}),
|
||||
Err(e) => keep_everything(&format!("record cid reference lookup failed: {e:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reachable_tree_cids<S: BlockStore + Sync + 'static>(
|
||||
mst: &Mst<S>,
|
||||
) -> Result<BTreeSet<Cid>, jacquard_repo::error::RepoError> {
|
||||
let nodes = mst.collect_node_cids().await?;
|
||||
let leaves = mst.leaves().await?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.chain(leaves.into_iter().map(|(_, cid)| cid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn finalize_repo_write(
|
||||
state: &AppState,
|
||||
ctx: RepoWriteContext,
|
||||
@@ -472,7 +576,6 @@ pub async fn finalize_repo_write(
|
||||
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
|
||||
|
||||
let written_bytes = ctx.tracking_store.take_written_blocks();
|
||||
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
|
||||
|
||||
let storage_for_proof = Arc::new(ctx.tracking_store.clone());
|
||||
let original_settled = Mst::load(storage_for_proof.clone(), ctx.prev_data_cid, None);
|
||||
@@ -545,27 +648,124 @@ pub async fn finalize_repo_write(
|
||||
}
|
||||
}
|
||||
|
||||
let obsolete_cids = match original_settled.diff(&new_settled).await {
|
||||
let replaced_refs: Vec<Cid> = params
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(RecordOp::replaced_cid)
|
||||
.collect();
|
||||
|
||||
let (new_tree_cids, new_mst_bytes, dropped) = match original_settled.diff(&new_settled).await {
|
||||
Ok(diff) => {
|
||||
let mut obsolete: Vec<Cid> =
|
||||
Vec::with_capacity(1 + diff.removed_mst_blocks.len() + diff.removed_cids.len());
|
||||
obsolete.push(ctx.current_root_cid);
|
||||
obsolete.extend(diff.removed_mst_blocks);
|
||||
obsolete.extend(diff.removed_cids);
|
||||
obsolete
|
||||
// The moment that a leaf CID leaves its old position (leaves, get it)
|
||||
// the diff will report it as removed
|
||||
// even where the new tree goes on using that same block somewhere else
|
||||
// so filtering the removed leaves against `live` will stop this write
|
||||
// from dropping a block the new tree still needs.
|
||||
// The diffing minuses the unchanged nodes from `removed_mst_blocks` itself
|
||||
// and the same filter only covers a node which matches the new root.
|
||||
let live: BTreeSet<Cid> = diff
|
||||
.new_mst_blocks
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(diff.new_leaf_cids.iter().copied())
|
||||
.chain(std::iter::once(new_mst_root))
|
||||
.collect();
|
||||
let dropped_leaves: BTreeSet<Cid> = diff
|
||||
.removed_cids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|cid| !live.contains(cid))
|
||||
.collect();
|
||||
let still_referenced =
|
||||
leaves_referenced_elsewhere(state, params.user_id, &dropped_leaves, ¶ms.ops)
|
||||
.await;
|
||||
let removed_mst_blocks: Vec<Cid> = diff
|
||||
.removed_mst_blocks
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|cid| !live.contains(cid))
|
||||
.collect();
|
||||
|
||||
// These sets have distinct purposes:
|
||||
// - `unreachable` deletes user_blocks rows
|
||||
// and so has to leave out any block another record still references
|
||||
// - `decrements` drops one refcount for every ref this write released
|
||||
// including the replaced record CIDs that other records
|
||||
// continue on referencing
|
||||
//
|
||||
// Neither set includes the previous data root
|
||||
// since the diff reports subtree nodes and never a root of its own.
|
||||
// So that block keeps its refcount and its user_blocks row
|
||||
// until the leaked-refcount sweep reclaims it.
|
||||
let dropped = DroppedBlocks {
|
||||
unreachable: std::iter::once(ctx.current_root_cid)
|
||||
.chain(removed_mst_blocks.iter().copied())
|
||||
.chain(
|
||||
dropped_leaves
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|cid| !still_referenced.contains(cid)),
|
||||
)
|
||||
.collect(),
|
||||
decrements: std::iter::once(ctx.current_root_cid)
|
||||
.chain(removed_mst_blocks)
|
||||
.chain(replaced_refs)
|
||||
.collect(),
|
||||
};
|
||||
(live.into_iter().collect(), diff.new_mst_blocks, dropped)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"MST diff failed during finalize_repo_write: {e}. \
|
||||
Proceeding with commit CID only; leaked blocks \
|
||||
will be reclaimed by reachability GC."
|
||||
user_id = %params.user_id,
|
||||
"MST diff failed during finalize_repo_write: {e}. Walking the new MST insetad \
|
||||
and scheduling a structural repair."
|
||||
);
|
||||
vec![ctx.current_root_cid]
|
||||
schedule_repo_repair(state, params.user_id);
|
||||
let dropped = DroppedBlocks {
|
||||
unreachable: vec![ctx.current_root_cid],
|
||||
decrements: std::iter::once(ctx.current_root_cid)
|
||||
.chain(replaced_refs)
|
||||
.collect(),
|
||||
};
|
||||
match reachable_tree_cids(&new_settled).await {
|
||||
Ok(cids) => (cids.into_iter().collect(), BTreeMap::new(), dropped),
|
||||
Err(walk_err) => {
|
||||
error!(
|
||||
"MST walk after diff failure also failed: {walk_err}. \
|
||||
Recording only blocks this write observed and the commit CID. \
|
||||
user_blocks stays incomplete until a structural repair rewrites the tree."
|
||||
);
|
||||
(
|
||||
written_bytes.keys().copied().collect(),
|
||||
BTreeMap::new(),
|
||||
dropped,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Under jacquard-repo 0.9 that tranquil pins for now,
|
||||
// `Mst::persist` skips any subtree that's already in store
|
||||
// so `written_bytes` under-reports the new node set
|
||||
// and `new_mst_blocks` is full
|
||||
// where each of these blocks still needs a `put` to take a refcount
|
||||
// even though its bytes are already on disk.
|
||||
let recovered: Vec<(Cid, Bytes)> = new_mst_bytes
|
||||
.iter()
|
||||
.filter(|(cid, _)| !written_bytes.contains_key(*cid))
|
||||
.map(|(cid, bytes)| (*cid, bytes.clone()))
|
||||
.collect();
|
||||
if !recovered.is_empty() {
|
||||
state.block_store.put_many(recovered).await.map_err(|e| {
|
||||
error!("failed to reference MST blocks recovered from the diff: {e}");
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut block_bytes = written_bytes;
|
||||
block_bytes.extend(relevant);
|
||||
block_bytes.extend(new_mst_bytes);
|
||||
|
||||
let result = commit_and_log(
|
||||
state,
|
||||
@@ -580,7 +780,7 @@ pub async fn finalize_repo_write(
|
||||
block_bytes,
|
||||
new_tree_cids,
|
||||
blobs: params.blob_cids,
|
||||
obsolete_cids,
|
||||
dropped,
|
||||
backlinks_to_add: params.backlinks_to_add,
|
||||
backlinks_to_remove: params.backlinks_to_remove,
|
||||
},
|
||||
@@ -649,6 +849,13 @@ pub enum RecordOp {
|
||||
}
|
||||
|
||||
impl RecordOp {
|
||||
pub fn replaced_cid(&self) -> Option<Cid> {
|
||||
match self {
|
||||
Self::Create { .. } => None,
|
||||
Self::Update { prev, .. } | Self::Delete { prev, .. } => Some(*prev.as_cid()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collection_rkey(&self) -> (&Nsid, &Rkey) {
|
||||
match self {
|
||||
Self::Create {
|
||||
@@ -669,6 +876,11 @@ pub struct CommitResult {
|
||||
pub rev: crate::types::Tid,
|
||||
}
|
||||
|
||||
pub struct DroppedBlocks {
|
||||
pub unreachable: Vec<Cid>,
|
||||
pub decrements: Vec<Cid>,
|
||||
}
|
||||
|
||||
pub struct CommitParams<'a> {
|
||||
pub did: &'a Did,
|
||||
pub user_id: Uuid,
|
||||
@@ -680,7 +892,7 @@ pub struct CommitParams<'a> {
|
||||
pub block_bytes: std::collections::HashMap<Cid, Bytes>,
|
||||
pub new_tree_cids: Vec<Cid>,
|
||||
pub blobs: &'a [crate::types::CidLink],
|
||||
pub obsolete_cids: Vec<Cid>,
|
||||
pub dropped: DroppedBlocks,
|
||||
pub backlinks_to_add: Vec<Backlink>,
|
||||
pub backlinks_to_remove: Vec<AtUri>,
|
||||
}
|
||||
@@ -705,7 +917,7 @@ pub async fn commit_and_log(
|
||||
mut block_bytes,
|
||||
new_tree_cids,
|
||||
blobs,
|
||||
obsolete_cids,
|
||||
dropped,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = params;
|
||||
@@ -726,7 +938,7 @@ pub async fn commit_and_log(
|
||||
let signing_key =
|
||||
SigningKey::from_slice(&key_bytes).map_err(|e| CommitError::InvalidKey(e.to_string()))?;
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let rev_str = rev.to_string();
|
||||
let repo_rev = crate::types::Tid::from(rev.clone());
|
||||
let (new_commit_bytes, _sig) =
|
||||
create_signed_commit(did, new_mst_root, &rev, current_root_cid, &signing_key)?;
|
||||
let new_root_cid =
|
||||
@@ -751,7 +963,7 @@ pub async fn commit_and_log(
|
||||
.map(|c| c.to_bytes())
|
||||
.collect();
|
||||
|
||||
let obsolete_bytes: Vec<Vec<u8>> = obsolete_cids.iter().map(|c| c.to_bytes()).collect();
|
||||
let obsolete_bytes: Vec<Vec<u8>> = dropped.unreachable.iter().map(|c| c.to_bytes()).collect();
|
||||
|
||||
let final_ops: HashMap<(&Nsid, &Rkey), &RecordOp> =
|
||||
ops.iter().map(|op| (op.collection_rkey(), op)).collect();
|
||||
@@ -864,7 +1076,7 @@ pub async fn commit_and_log(
|
||||
blobs: Some(blobs.to_vec()),
|
||||
blocks: Some(inline_blocks),
|
||||
prev_data_cid: prev_data_cid.map(crate::types::CidLink::from),
|
||||
rev: Some(crate::types::Tid::from(rev_str.clone())),
|
||||
rev: Some(repo_rev.clone()),
|
||||
};
|
||||
|
||||
let input = ApplyCommitInput {
|
||||
@@ -872,7 +1084,7 @@ pub async fn commit_and_log(
|
||||
did: did.clone(),
|
||||
expected_root_cid: current_root_cid.map(crate::types::CidLink::from),
|
||||
new_root_cid: crate::types::CidLink::from(new_root_cid),
|
||||
new_rev: crate::types::Tid::from(rev_str.clone()),
|
||||
new_rev: repo_rev.clone(),
|
||||
new_block_cids: all_block_cids,
|
||||
obsolete_block_cids: obsolete_bytes,
|
||||
record_upserts,
|
||||
@@ -895,7 +1107,7 @@ pub async fn commit_and_log(
|
||||
|
||||
let apply_result = (|| {
|
||||
let bs = state.block_store.clone();
|
||||
let decrements = obsolete_cids.clone();
|
||||
let decrements = dropped.decrements.clone();
|
||||
async move { bs.decrement_refs(&decrements).await }
|
||||
})
|
||||
.retry(
|
||||
@@ -907,7 +1119,7 @@ pub async fn commit_and_log(
|
||||
.await;
|
||||
|
||||
if let Err(e) = apply_result {
|
||||
let leaked: Vec<String> = obsolete_cids.iter().map(Cid::to_string).collect();
|
||||
let leaked: Vec<String> = dropped.decrements.iter().map(Cid::to_string).collect();
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
user_id = %user_id,
|
||||
@@ -920,7 +1132,7 @@ pub async fn commit_and_log(
|
||||
|
||||
Ok(CommitResult {
|
||||
commit_cid: new_root_cid,
|
||||
rev: crate::types::Tid::from(rev_str),
|
||||
rev: repo_rev,
|
||||
})
|
||||
}
|
||||
pub async fn create_record_internal(
|
||||
|
||||
@@ -37,7 +37,7 @@ async fn process_repo_rev(
|
||||
}
|
||||
};
|
||||
let commit = Commit::from_cbor(&block).map_err(|_| user_id)?;
|
||||
let rev = crate::types::Tid::from(commit.rev().to_string());
|
||||
let rev = crate::types::Tid::from(commit.rev().clone());
|
||||
repo_repo
|
||||
.update_repo_rev(user_id, &rev)
|
||||
.await
|
||||
@@ -98,14 +98,22 @@ async fn process_user_blocks(
|
||||
repo_rev: Option<crate::types::Tid>,
|
||||
) -> Result<(uuid::Uuid, usize), uuid::Uuid> {
|
||||
let root_cid = Cid::from_str(&repo_root_cid).map_err(|_| user_id)?;
|
||||
let block_cids = collect_current_repo_blocks(block_store, &root_cid)
|
||||
let walk = collect_current_repo_blocks(block_store, &root_cid)
|
||||
.await
|
||||
.map_err(|_| user_id)?;
|
||||
if !walk.is_complete() {
|
||||
error!(
|
||||
user_id = %user_id,
|
||||
unreadable = walk.unreadable,
|
||||
"user_blocks backfill recorded an incomplete set becuase the walk couldn't read every block"
|
||||
);
|
||||
}
|
||||
let block_cids = walk.block_cids;
|
||||
if block_cids.is_empty() {
|
||||
return Err(user_id);
|
||||
}
|
||||
let count = block_cids.len();
|
||||
let rev = repo_rev.unwrap_or_else(|| crate::types::Tid::from("0".to_string()));
|
||||
let rev = repo_rev.unwrap_or_else(crate::types::Tid::earliest);
|
||||
repo_repo
|
||||
.insert_user_blocks(user_id, &block_cids, &rev)
|
||||
.await
|
||||
@@ -162,13 +170,35 @@ pub async fn backfill_user_blocks(repo_repo: Arc<dyn RepoRepository>, block_stor
|
||||
info!(success, failed, "Completed user_blocks backfill");
|
||||
}
|
||||
|
||||
// When a walk couldn't read every block,
|
||||
// what comes back is a subset of the reachable set
|
||||
// ...and the complement of a subset is *not* the unreachable set.
|
||||
// So callers check `is_complete` before they prune
|
||||
// or one unreadable block is enough to delete live rows.
|
||||
//
|
||||
// The count only covers a block that the store reports as missing or corrupt,
|
||||
// whereas a block whose bytes decode as neither commit or MST node ends
|
||||
// traversal without counting
|
||||
// so `is_complete` is necessary for a prune and not sufficient.
|
||||
pub struct RepoWalk {
|
||||
pub block_cids: Vec<Vec<u8>>,
|
||||
pub unreadable: usize,
|
||||
}
|
||||
|
||||
impl RepoWalk {
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.unreadable == 0
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn collect_current_repo_blocks(
|
||||
block_store: &AnyBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
) -> anyhow::Result<RepoWalk> {
|
||||
let mut block_cids: Vec<Vec<u8>> = Vec::new();
|
||||
let mut to_visit = vec![*head_cid];
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut unreadable = 0usize;
|
||||
|
||||
while let Some(cid) = to_visit.pop() {
|
||||
if visited.contains(&cid) {
|
||||
@@ -179,9 +209,14 @@ pub async fn collect_current_repo_blocks(
|
||||
|
||||
let block = match block_store.get(&cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => continue,
|
||||
Ok(None) => {
|
||||
warn!(cid = %cid, "block missing during repo walk, so its subtree stays unvisited");
|
||||
unreadable += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) if crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}")) => {
|
||||
warn!(cid = %cid, error = %format!("{e:#}"), "skipping corrupt block during repo walk");
|
||||
unreadable += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => anyhow::bail!("Failed to get block {}: {:?}", cid, e),
|
||||
@@ -215,7 +250,10 @@ pub async fn collect_current_repo_blocks(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(block_cids)
|
||||
Ok(RepoWalk {
|
||||
block_cids,
|
||||
unreadable,
|
||||
})
|
||||
}
|
||||
|
||||
async fn process_record_blobs(
|
||||
@@ -771,7 +809,9 @@ pub async fn generate_repo_car(
|
||||
block_store: &AnyBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> Result<Vec<u8>, RepoCarError> {
|
||||
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
|
||||
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid)
|
||||
.await?
|
||||
.block_cids;
|
||||
let block_cids: Vec<Cid> = block_cids_bytes
|
||||
.iter()
|
||||
.filter_map(|b| match Cid::try_from(b.as_slice()) {
|
||||
|
||||
@@ -30,6 +30,10 @@ pub fn init_rate_limit_override() {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rate_limiting_disabled(disabled: bool) {
|
||||
RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub repos: Arc<PostgresRepositories>,
|
||||
|
||||
@@ -2,5 +2,5 @@ pub use tranquil_storage::{
|
||||
BlobStorage, FilesystemBlobStorage, StorageError, StreamUploadResult, create_blob_storage,
|
||||
};
|
||||
|
||||
#[cfg(feature = "s3-storage")]
|
||||
#[cfg(feature = "s3")]
|
||||
pub use tranquil_storage::S3BlobStorage;
|
||||
|
||||
@@ -227,15 +227,23 @@ fn walk_mst_node(
|
||||
{
|
||||
let blob_refs = find_blob_refs_ipld(&record_value, 0);
|
||||
let parts: Vec<&str> = full_key.split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
let collection = Nsid::from(parts[..parts.len() - 1].join("/"));
|
||||
let rkey = Rkey::from(parts[parts.len() - 1].to_string());
|
||||
records.push(ImportedRecord {
|
||||
let parsed = match parts.len() >= 2 {
|
||||
true => Nsid::new(parts[..parts.len() - 1].join("/"))
|
||||
.ok()
|
||||
.zip(Rkey::new(parts[parts.len() - 1].to_string()).ok()),
|
||||
false => None,
|
||||
};
|
||||
match parsed {
|
||||
Some((collection, rkey)) => records.push(ImportedRecord {
|
||||
collection,
|
||||
rkey,
|
||||
cid: record_cid,
|
||||
blob_refs,
|
||||
});
|
||||
}),
|
||||
None => tracing::warn!(
|
||||
key = %full_key,
|
||||
"skipping a CAR record whose MST key isn't a valid collection/rkey pair"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ impl CarVerifier {
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
|
||||
let commit =
|
||||
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
let commit_did = commit.did().to_string().into();
|
||||
let commit_did = tranquil_types::Did::new(commit.did().to_string())
|
||||
.map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
let data_cid = commit.data();
|
||||
self.verify_mst_structure(data_cid, blocks)?;
|
||||
debug!("MST structure verified for commit: {:?}", commit);
|
||||
|
||||
@@ -2,6 +2,8 @@ pub use tranquil_types::*;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
#[cfg(feature = "bsky")]
|
||||
pub static PROFILE_COLLECTION: LazyLock<Nsid> =
|
||||
LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap());
|
||||
#[cfg(feature = "bsky")]
|
||||
pub static PROFILE_RKEY: LazyLock<Rkey> = LazyLock::new(|| "self".parse().unwrap());
|
||||
|
||||
@@ -87,6 +87,7 @@ pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName =
|
||||
pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev");
|
||||
pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName =
|
||||
HeaderName::from_static("atproto-content-labelers");
|
||||
#[cfg(feature = "bsky-support")]
|
||||
pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics");
|
||||
|
||||
pub fn get_header_str(
|
||||
@@ -247,7 +248,13 @@ pub fn build_full_url(path: &str) -> String {
|
||||
let cfg = tranquil_config::get();
|
||||
let normalized_path = if !path.starts_with("/xrpc/")
|
||||
&& (path.starts_with("/com.atproto.")
|
||||
|| path.starts_with("/app.bsky.")
|
||||
// BSKY: Bluesky requires that the PDS implement some app.bsky.* endpoints so we need to deal with those here too.
|
||||
// TODO: surely we can figure out a way to do this more generically?
|
||||
|| (if cfg!(feature = "bsky-support") {
|
||||
path.starts_with("/app.bsky.")
|
||||
} else {
|
||||
true
|
||||
})
|
||||
|| path.starts_with("/_"))
|
||||
{
|
||||
format!("/xrpc{path}")
|
||||
|
||||
@@ -138,20 +138,25 @@ fn check_banned_content(
|
||||
rkey: Option<&Rkey>,
|
||||
) -> Result<(), ValidationError> {
|
||||
match record_type {
|
||||
#[cfg(feature = "bsky")]
|
||||
"app.bsky.feed.post" => {
|
||||
check_post_banned_content(obj)?;
|
||||
}
|
||||
#[cfg(feature = "bsky")]
|
||||
"app.bsky.actor.profile" => {
|
||||
check_string_field(obj, "displayName")?;
|
||||
check_string_field(obj, "description")?;
|
||||
}
|
||||
#[cfg(feature = "bsky")]
|
||||
"app.bsky.graph.list" => {
|
||||
check_string_field(obj, "name")?;
|
||||
}
|
||||
#[cfg(feature = "bsky")]
|
||||
"app.bsky.graph.starterpack" => {
|
||||
check_string_field(obj, "name")?;
|
||||
check_string_field(obj, "description")?;
|
||||
}
|
||||
#[cfg(feature = "bsky")]
|
||||
"app.bsky.feed.generator" => {
|
||||
if let Some(rkey) = rkey
|
||||
&& crate::moderation::has_explicit_slur(rkey)
|
||||
@@ -167,6 +172,7 @@ fn check_banned_content(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "bsky")]
|
||||
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
|
||||
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
|
||||
tags.iter().enumerate().try_for_each(|(i, tag)| {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
use aws_config::BehaviorVersion;
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
use aws_sdk_s3::config::Credentials;
|
||||
use chrono::Utc;
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
@@ -56,9 +56,9 @@ pub struct ServerInstance {
|
||||
pub distributed_rate_limiter: Option<Arc<dyn DistributedRateLimiter>>,
|
||||
}
|
||||
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
use testcontainers::GenericImage;
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
use testcontainers::core::ContainerPort;
|
||||
#[cfg(not(feature = "external-infra"))]
|
||||
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
|
||||
@@ -66,7 +66,7 @@ use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
#[cfg(not(feature = "external-infra"))]
|
||||
static DB_CONTAINER: OnceLock<ContainerAsync<Postgres>> = OnceLock::new();
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
static S3_CONTAINER: OnceLock<ContainerAsync<GenericImage>> = OnceLock::new();
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -195,7 +195,7 @@ async fn setup_with_external_infra() -> String {
|
||||
spawn_app(database_url).await
|
||||
}
|
||||
|
||||
#[cfg(all(not(feature = "external-infra"), not(feature = "s3-storage")))]
|
||||
#[cfg(all(not(feature = "external-infra"), not(feature = "s3")))]
|
||||
async fn setup_with_testcontainers() -> String {
|
||||
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
|
||||
let blob_path = temp_dir.join("blobs");
|
||||
@@ -227,7 +227,7 @@ async fn setup_with_testcontainers() -> String {
|
||||
spawn_app(connection_string).await
|
||||
}
|
||||
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
|
||||
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
|
||||
async fn setup_with_testcontainers() -> String {
|
||||
let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest")
|
||||
.with_exposed_port(ContainerPort::Tcp(9000))
|
||||
|
||||
@@ -175,6 +175,139 @@ async fn test_external_did_web_no_local_doc() {
|
||||
);
|
||||
}
|
||||
|
||||
async fn reserve_signing_key(client: &reqwest::Client, base: &str, did: &str) -> String {
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base
|
||||
))
|
||||
.json(&json!({ "did": did }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response wasn't JSON");
|
||||
body["signingKey"]
|
||||
.as_str()
|
||||
.expect("No signingKey returned")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn assert_reserved_key_placement_rejected(
|
||||
build_methods: impl FnOnce(&str, &str, &str) -> Value,
|
||||
) {
|
||||
let client = client();
|
||||
let base = base_url().await;
|
||||
let mock_server = MockServer::start().await;
|
||||
let mock_uri = mock_server.uri();
|
||||
let mock_addr = mock_uri.trim_start_matches("http://");
|
||||
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
|
||||
let handle = format!("wm{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
||||
let pds_endpoint = common::pds_endpoint();
|
||||
|
||||
let signing_key = reserve_signing_key(&client, base, &did).await;
|
||||
let signing_multibase = signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.expect("signingKey should start with did:key:");
|
||||
|
||||
let decoy_did = format!(
|
||||
"did:web:{}.nel.pet",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..12]
|
||||
);
|
||||
let decoy_key = reserve_signing_key(&client, base, &decoy_did).await;
|
||||
let decoy_multibase = decoy_key
|
||||
.strip_prefix("did:key:")
|
||||
.expect("decoy signingKey should start with did:key:");
|
||||
|
||||
let did_doc = json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"verificationMethod": build_methods(signing_multibase, decoy_multibase, &did),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": pds_endpoint
|
||||
}]
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/did.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@nel.pet", handle),
|
||||
"password": "Testpass123!",
|
||||
"didType": "web-external",
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_ne!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"createAccount must reject a did:web doc whose #atproto method isn't the reserved signing key"
|
||||
);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let message = body["message"]
|
||||
.as_str()
|
||||
.or_else(|| body["error"].as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
message.contains("reserved signing key"),
|
||||
"error should report signing-key mismatch, got: {:?}",
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_did_web_signing_key_under_wrong_method_rejected() {
|
||||
assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| {
|
||||
json!([
|
||||
{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": decoy_multibase
|
||||
},
|
||||
{
|
||||
"id": format!("{}#atproto_reserved", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": signing_multibase
|
||||
}
|
||||
])
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_did_web_signing_key_under_foreign_atproto_fragment_rejected() {
|
||||
assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| {
|
||||
json!([
|
||||
{
|
||||
"id": "did:web:squid.nel.pet#atproto",
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": signing_multibase
|
||||
},
|
||||
{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": decoy_multibase
|
||||
}
|
||||
])
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plc_operations_blocked_for_did_web() {
|
||||
let client = client();
|
||||
|
||||
@@ -145,7 +145,7 @@ async fn sync_event_carries_inline_commit_block() {
|
||||
let commit_bytes = b"synthetic sync commit block payload".to_vec();
|
||||
let commit_cid = synthetic_cid(&commit_bytes);
|
||||
let cid_link: CidLink = (&commit_cid).into();
|
||||
let rev = tranquil_types::Tid::from("3kabcdefghij2".to_string());
|
||||
let rev = tranquil_types::Tid::new("3kabcdefghij2").expect("valid TID");
|
||||
|
||||
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
|
||||
repos
|
||||
@@ -192,7 +192,7 @@ async fn genesis_commit_event_carries_inline_blocks() {
|
||||
let mst_root_cid = synthetic_cid(&mst_root_bytes);
|
||||
let commit_link: CidLink = (&commit_cid).into();
|
||||
let mst_link: CidLink = (&mst_root_cid).into();
|
||||
let rev = tranquil_types::Tid::from("3kabcdefghij3".to_string());
|
||||
let rev = tranquil_types::Tid::new("3kabcdefghij3").expect("valid TID");
|
||||
|
||||
let baseline = repos.repo.get_max_seq().await.expect("get_max_seq");
|
||||
repos
|
||||
|
||||
@@ -5,7 +5,7 @@ use common::*;
|
||||
use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use tranquil_types::Did;
|
||||
use tranquil_types::{Did, Nsid, Rkey};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_record_marks_blocks_obsolete() {
|
||||
@@ -13,22 +13,17 @@ async fn test_delete_record_marks_blocks_obsolete() {
|
||||
let base = base_url().await;
|
||||
let repos = get_test_repos().await;
|
||||
let (did, jwt) = setup_new_user("gc-after-delete").await;
|
||||
let did = Did::new(did).expect("setup_new_user returned a valid DID");
|
||||
|
||||
let user_id = repos
|
||||
.user
|
||||
.get_id_by_did(&Did::new(did.clone()).unwrap())
|
||||
.get_id_by_did(&did)
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("User not found");
|
||||
|
||||
let count_baseline = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
|
||||
let collection = "app.bsky.feed.post";
|
||||
let rkey = format!("gc_test_{}", Utc::now().timestamp_millis());
|
||||
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
|
||||
let rkey = Rkey::new(format!("gc_test_{}", Utc::now().timestamp_millis())).expect("valid rkey");
|
||||
let create_payload = json!({
|
||||
"repo": did,
|
||||
"collection": collection,
|
||||
@@ -65,17 +60,7 @@ async fn test_delete_record_marks_blocks_obsolete() {
|
||||
.expect("createRecord response missing cid")
|
||||
.to_string();
|
||||
|
||||
let count_after_create = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
assert!(
|
||||
count_after_create > count_baseline,
|
||||
"user_blocks count did not grow after createRecord (baseline={}, after_create={})",
|
||||
count_baseline,
|
||||
count_after_create
|
||||
);
|
||||
assert_user_blocks_matches_repo(user_id, "createRecord").await;
|
||||
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
@@ -96,28 +81,13 @@ async fn test_delete_record_marks_blocks_obsolete() {
|
||||
delete_res.text().await
|
||||
);
|
||||
|
||||
let count_after_delete = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
|
||||
assert!(
|
||||
count_after_delete < count_after_create,
|
||||
"user_blocks count did not shrink after deleteRecord \
|
||||
(baseline={}, after_create={}, after_delete={}). \
|
||||
The delete path produced no obsolete CIDs beyond the prior commit root, \
|
||||
which is the regression this test guards against.",
|
||||
count_baseline,
|
||||
count_after_create,
|
||||
count_after_delete
|
||||
);
|
||||
assert_user_blocks_matches_repo(user_id, "deleteRecord").await;
|
||||
|
||||
let get_res = client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", collection),
|
||||
("collection", collection.as_str()),
|
||||
("rkey", rkey.as_str()),
|
||||
])
|
||||
.send()
|
||||
@@ -138,16 +108,18 @@ async fn test_update_record_marks_old_record_block_obsolete() {
|
||||
let base = base_url().await;
|
||||
let repos = get_test_repos().await;
|
||||
let (did, jwt) = setup_new_user("gc-after-update").await;
|
||||
let did = Did::new(did).expect("setup_new_user returned a valid DID");
|
||||
|
||||
let user_id = repos
|
||||
.user
|
||||
.get_id_by_did(&Did::new(did.clone()).unwrap())
|
||||
.get_id_by_did(&did)
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("User not found");
|
||||
|
||||
let collection = "app.bsky.feed.post";
|
||||
let rkey = format!("gc_update_{}", Utc::now().timestamp_millis());
|
||||
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
|
||||
let rkey =
|
||||
Rkey::new(format!("gc_update_{}", Utc::now().timestamp_millis())).expect("valid rkey");
|
||||
|
||||
let put_v1 = json!({
|
||||
"repo": did,
|
||||
@@ -168,11 +140,7 @@ async fn test_update_record_marks_old_record_block_obsolete() {
|
||||
.expect("Failed to send putRecord v1");
|
||||
assert_eq!(res.status(), StatusCode::OK, "first putRecord failed");
|
||||
|
||||
let count_after_create = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
assert_user_blocks_matches_repo(user_id, "the first putRecord").await;
|
||||
|
||||
let put_v2 = json!({
|
||||
"repo": did,
|
||||
@@ -193,133 +161,7 @@ async fn test_update_record_marks_old_record_block_obsolete() {
|
||||
.expect("Failed to send putRecord v2");
|
||||
assert_eq!(res.status(), StatusCode::OK, "second putRecord failed");
|
||||
|
||||
let count_after_update = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
|
||||
assert!(
|
||||
count_after_update <= count_after_create + 1,
|
||||
"user_blocks count grew by more than 1 after putRecord update \
|
||||
(after_create={}, after_update={}). The previous version's record block \
|
||||
should have been marked obsolete; instead it appears to be leaking.",
|
||||
count_after_create,
|
||||
count_after_update
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_in_populated_repo_marks_merged_subtree_blocks_obsolete() {
|
||||
let client = client();
|
||||
let base = base_url().await;
|
||||
let repos = get_test_repos().await;
|
||||
let (did, jwt) = setup_new_user("gc-merge").await;
|
||||
|
||||
let user_id = repos
|
||||
.user
|
||||
.get_id_by_did(&Did::new(did.clone()).unwrap())
|
||||
.await
|
||||
.expect("DB error")
|
||||
.expect("User not found");
|
||||
|
||||
let collection = "app.bsky.feed.post";
|
||||
let record_count = 64usize;
|
||||
let now_ms = Utc::now().timestamp_millis();
|
||||
|
||||
let rkeys: Vec<String> = (0..record_count)
|
||||
.map(|i| format!("gc_merge_{}_{:04}", now_ms, i))
|
||||
.collect();
|
||||
|
||||
let create_results =
|
||||
futures::future::try_join_all(rkeys.iter().enumerate().map(|(i, rkey)| {
|
||||
let client = client.clone();
|
||||
let jwt = jwt.clone();
|
||||
let did = did.clone();
|
||||
let base = base.to_string();
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": collection,
|
||||
"rkey": rkey,
|
||||
"record": {
|
||||
"$type": collection,
|
||||
"text": format!("seed record {}", i),
|
||||
"createdAt": Utc::now().to_rfc3339()
|
||||
}
|
||||
});
|
||||
async move {
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send createRecord");
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(format!("seed createRecord failed: {}", res.status()));
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
create_results.expect("seeding records failed");
|
||||
|
||||
let count_after_seed = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
|
||||
let target_rkey = &rkeys[record_count / 2];
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
"collection": collection,
|
||||
"rkey": target_rkey,
|
||||
});
|
||||
let delete_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&delete_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send deleteRecord");
|
||||
assert_eq!(
|
||||
delete_res.status(),
|
||||
StatusCode::OK,
|
||||
"deleteRecord did not return 200: {:?}",
|
||||
delete_res.text().await
|
||||
);
|
||||
|
||||
let count_after_delete = repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.expect("count_user_blocks failed");
|
||||
assert!(
|
||||
count_after_delete < count_after_seed,
|
||||
"user_blocks did not shrink after deleting from a populated repo \
|
||||
(after_seed={}, after_delete={}). The path-walk-based obsolete \
|
||||
calculation does not capture sibling subtree blocks orphaned by \
|
||||
delete-merge; only an MST-diff-based calculation does.",
|
||||
count_after_seed,
|
||||
count_after_delete
|
||||
);
|
||||
|
||||
let get_res = client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", collection),
|
||||
("rkey", target_rkey.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send getRecord");
|
||||
assert!(
|
||||
!get_res.status().is_success(),
|
||||
"deleted record is still resolvable via getRecord (status={})",
|
||||
get_res.status(),
|
||||
);
|
||||
assert_user_blocks_matches_repo(user_id, "the second putRecord").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -339,9 +181,11 @@ async fn test_delete_decrements_tranquil_store_refcounts() {
|
||||
.as_tranquil_store()
|
||||
.expect("tranquil-store backend selected but block_store is not TranquilStore");
|
||||
let (did, jwt) = setup_new_user("gc-store-decrement").await;
|
||||
let did = Did::new(did).expect("setup_new_user returned a valid DID");
|
||||
|
||||
let collection = "app.bsky.feed.post";
|
||||
let rkey = format!("gc_store_{}", Utc::now().timestamp_millis());
|
||||
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
|
||||
let rkey =
|
||||
Rkey::new(format!("gc_store_{}", Utc::now().timestamp_millis())).expect("valid rkey");
|
||||
|
||||
let create_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
|
||||
|
||||
@@ -107,9 +107,11 @@ async fn mst_blocks_survive_full_store_reopen() {
|
||||
|
||||
let head_cid = cid::Cid::try_from(repo_root_str.as_str()).expect("invalid cid");
|
||||
|
||||
let car_blocks = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
|
||||
let walk = tranquil_pds::scheduled::collect_current_repo_blocks(block_store, &head_cid)
|
||||
.await
|
||||
.expect("collect blocks");
|
||||
assert!(walk.is_complete(), "repo walk read every block it reached");
|
||||
let car_blocks = walk.block_cids;
|
||||
|
||||
let block_count_before = car_blocks.len();
|
||||
|
||||
|
||||
@@ -480,6 +480,69 @@ pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> S
|
||||
multibase::encode(multibase::Base::Base58Btc, buf)
|
||||
}
|
||||
|
||||
async fn reachable_blocks(user_id: uuid::Uuid) -> std::collections::BTreeSet<cid::Cid> {
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
|
||||
let repos = super::common::get_test_repos().await;
|
||||
let store = super::common::get_test_block_store().await;
|
||||
|
||||
let root_str = repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.expect("DB error fetching repo root")
|
||||
.expect("repo root not found");
|
||||
let root_cid = cid::Cid::try_from(root_str.as_str()).expect("repo root isn't a valid CID");
|
||||
let commit_bytes = store
|
||||
.get(&root_cid)
|
||||
.await
|
||||
.expect("block store error fetching commit")
|
||||
.expect("commit block not in block store");
|
||||
let data_cid = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
|
||||
.expect("commit block doesn't parse")
|
||||
.data;
|
||||
|
||||
let mst = jacquard_repo::mst::Mst::load(std::sync::Arc::new(store.clone()), data_cid, None);
|
||||
let mut cids = tranquil_pds::repo_ops::reachable_tree_cids(&mst)
|
||||
.await
|
||||
.expect("walking the new MST failed");
|
||||
cids.insert(root_cid);
|
||||
cids
|
||||
}
|
||||
|
||||
async fn recorded_blocks(user_id: uuid::Uuid) -> std::collections::BTreeSet<cid::Cid> {
|
||||
super::common::get_test_repos()
|
||||
.await
|
||||
.repo
|
||||
.get_user_block_cids_since_rev(user_id, None)
|
||||
.await
|
||||
.expect("get_user_block_cids_since_rev failed")
|
||||
.iter()
|
||||
.map(|b| cid::Cid::try_from(b.as_slice()).expect("invalid CID in user_blocks"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn assert_user_blocks_matches_repo(user_id: uuid::Uuid, phase: &str) {
|
||||
let reachable = reachable_blocks(user_id).await;
|
||||
let recorded = recorded_blocks(user_id).await;
|
||||
let missing: Vec<String> = reachable
|
||||
.difference(&recorded)
|
||||
.map(cid::Cid::to_string)
|
||||
.collect();
|
||||
let stale: Vec<String> = recorded
|
||||
.difference(&reachable)
|
||||
.map(cid::Cid::to_string)
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty() && stale.is_empty(),
|
||||
"user_blocks doesn't match the blocks reachable from the repo root after {phase}. \
|
||||
reachable={} recorded={} missing={missing:?} stale={stale:?}",
|
||||
reachable.len(),
|
||||
recorded.len(),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
|
||||
let repos = super::common::get_test_repos().await;
|
||||
|
||||
@@ -40,7 +40,7 @@ fn create_unsigned_jwt(header: &Value, claims: &Value) -> String {
|
||||
#[test]
|
||||
fn test_signature_attacks() {
|
||||
let key_bytes = generate_user_key();
|
||||
let did = Did::from("did:plc:whelk".to_string());
|
||||
let did = Did::new("did:plc:whelk").expect("valid DID");
|
||||
let token = create_access_token(&did, &key_bytes).expect("create token");
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
|
||||
@@ -143,7 +143,7 @@ fn test_algorithm_substitution_attacks() {
|
||||
#[test]
|
||||
fn test_token_type_confusion() {
|
||||
let key_bytes = generate_user_key();
|
||||
let did = Did::from("did:plc:whelk".to_string());
|
||||
let did = Did::new("did:plc:whelk").expect("valid DID");
|
||||
|
||||
let refresh_token = create_refresh_token(&did, &key_bytes).expect("create refresh token");
|
||||
let result = verify_access_token(&refresh_token, &key_bytes);
|
||||
@@ -169,8 +169,8 @@ fn test_token_type_confusion() {
|
||||
|
||||
let service_token = create_service_token(
|
||||
&did,
|
||||
&Did::from("did:web:nel.pet".to_string()),
|
||||
Some(&Nsid::from("cafe.oyster.method".to_string())),
|
||||
&Did::new("did:web:nel.pet").expect("valid DID"),
|
||||
Some(&Nsid::new("cafe.oyster.method").expect("valid NSID")),
|
||||
&key_bytes,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -434,7 +434,7 @@ fn test_claim_validation() {
|
||||
#[test]
|
||||
fn test_did_and_jti_extraction() {
|
||||
let key_bytes = generate_user_key();
|
||||
let did = Did::from("did:plc:limpet".to_string());
|
||||
let did = Did::new("did:plc:limpet").expect("valid DID");
|
||||
let token = create_access_token(&did, &key_bytes).expect("create token");
|
||||
|
||||
assert_eq!(get_did_from_token(&token).unwrap(), did);
|
||||
@@ -459,7 +459,7 @@ fn test_did_and_jti_extraction() {
|
||||
#[test]
|
||||
fn test_header_injection_and_constant_time() {
|
||||
let key_bytes = generate_user_key();
|
||||
let did = Did::from("did:plc:whelk".to_string());
|
||||
let did = Did::new("did:plc:whelk").expect("valid DID");
|
||||
|
||||
let header = json!({
|
||||
"alg": "ES256K", "typ": TokenType::Access.as_str(),
|
||||
@@ -501,7 +501,7 @@ async fn test_server_rejects_invalid_tokens() {
|
||||
|
||||
let key_bytes = generate_user_key();
|
||||
let forged_token =
|
||||
create_access_token(&Did::from("did:plc:lyna".to_string()), &key_bytes).unwrap();
|
||||
create_access_token(&Did::new("did:plc:lyna").expect("valid DID"), &key_bytes).unwrap();
|
||||
let res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
||||
.header("Authorization", format!("Bearer {}", forged_token))
|
||||
|
||||
@@ -605,6 +605,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
("redirect_uri", "https://client1.example.com/callback"),
|
||||
("code_challenge", &challenge1),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", "atproto repo:app.bsky.feed.post?action=create"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
@@ -631,7 +632,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
|
||||
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
|
||||
.send().await.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
@@ -666,6 +667,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
("redirect_uri", "https://client2.example.com/callback"),
|
||||
("code_challenge", &challenge2),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", "atproto repo:app.bsky.feed.post?action=create"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
@@ -692,7 +694,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
let consent_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
|
||||
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
|
||||
.send().await.unwrap();
|
||||
let consent_body: Value = consent_res.json().await.unwrap();
|
||||
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
@@ -763,6 +765,33 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
StatusCode::OK,
|
||||
"Client 2 token should work"
|
||||
);
|
||||
let ungranted_collection = "app.bsky.graph.follow";
|
||||
let denied_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
|
||||
.bearer_auth(token1)
|
||||
.json(&json!({
|
||||
"repo": user_did,
|
||||
"collection": ungranted_collection,
|
||||
"record": {
|
||||
"$type": ungranted_collection,
|
||||
"subject": user_did,
|
||||
"createdAt": Utc::now().to_rfc3339()
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
denied_res.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"Client 1 token shouldn't write outside its granted collection"
|
||||
);
|
||||
let denied_body: Value = denied_res.json().await.unwrap();
|
||||
assert_eq!(
|
||||
denied_body["error"].as_str(),
|
||||
Some("InsufficientScope"),
|
||||
"Write outside the granted collection should fail on scope"
|
||||
);
|
||||
let list_res = http_client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
|
||||
.bearer_auth(token1)
|
||||
@@ -770,6 +799,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list_res.status(), StatusCode::OK, "listRecords should work");
|
||||
let list_body: Value = list_res.json().await.unwrap();
|
||||
let records = list_body["records"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ fn now() -> String {
|
||||
}
|
||||
|
||||
fn c(s: &str) -> Nsid {
|
||||
Nsid::from(s.to_string())
|
||||
Nsid::new(s).expect("valid NSID")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -144,7 +144,7 @@ async fn repair_fails_loud_on_missing_leaf_block() {
|
||||
|
||||
let recorded = repos
|
||||
.repo
|
||||
.get_user_block_cids_since_rev(user_id, &tranquil_types::Tid::from(String::new()))
|
||||
.get_user_block_cids_since_rev(user_id, None)
|
||||
.await
|
||||
.expect("read user_blocks");
|
||||
assert!(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user