Compare commits

...
111 Commits
Author SHA1 Message Date
nelind 75c341b94b chore: bump version to 0.6.7 2026-09-20 22:29:26 +02:00
nelind 0b29c40b27 docs: list jola.dev as a core team maintainer 2026-09-20 18:51:59 +02:00
Anastasia CalicoandTangled 34ece34296 feat: Implement s3_path in storage config. 2026-09-20 16:47:22 +00:00
Johanna LarssonandTangled 833356116a Set inputmode on inputs
This improves mobile behavior for the inputs without affecting other things.

Two inputs set to email, rest to URL pre recommendation in https://tangled.org/tranquil.farm/tranquil-pds/issues/140

Also disables autocapitalize, autocorrect, and spellcheck, while we're at it. All really annoying when typing in handles on a mobile phone.
2026-09-19 14:10:15 +00:00
Johanna LarssonandTangled 643e1bb902 Make ownership repair job report failures more loudly 2026-09-17 12:35:55 +00:00
Johanna LarssonandTangled 9f05ea5f31 Introduce job to repair missing blobs
My last PR introduced a migration to blobs, changing them from being unique per cid to being unique per cid+user. This is because two users can upload the same blob, get the same cid, and then previously in tranquil the second user would never actually get their ownership recorded, meaning listBlobs was missing the blob, and a migration _off_ of tranquil would leave them sans blob https://tangled.org/tranquil.farm/tranquil-pds/pulls/261/round/3

This PR attempts to add a job to repair existing instances where blobs have been deduplicated and not attributed to each owning user. It does this by pulling all records for each user, and then in batches crawling the records to find blob references. For each blob reference, it checks whether that user has its ownership recorded, and if not, repairs it by adding the entry.

To avoid re-running this potentially heavy job on every startup, I've used the config table to store a little record of whether it has been run.
2026-09-17 12:35:55 +00:00
TrezyandTangled 71cd282d1e feat: show repo actions narrowed out by client metadata on consent
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
TrezyandTangled 0e40bdca19 fix: store filtered scopes on tokens so refresh can skip the client metadata check
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
TrezyandTangled 156066fe1b fix: reject PAR requests missing the atproto scope
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
TrezyandTangled 311530a9a9 test: give the mock client a scope so NotRegistered actually gets exercised
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
TrezyandTangled 08cd3fa100 chore: remove unreachable scope format check in consent
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
TrezyandTangled 12a8712eae fix: unrecognized scopes can no longer fail PAR
Signed-off-by: Trezy <tre@trezy.com>
2026-09-16 16:20:17 +00:00
LunaandTangled cdd5fa70c9 fix: add MIME version to outgoing mail 2026-09-14 13:40:08 +00:00
xan.lolandTangled 218741050d add a period (spelling fix) 2026-09-13 22:47:12 +00:00
Johanna LarssonandTangled b3ff62c221 Simplify blob migration 2026-09-12 15:34:46 +00:00
Johanna LarssonandTangled 2088f59197 Stop deleting logo blob when switching
This also means we can clean up the last blob operations that were per cid rather than user+cid 🪓

We should be setting ourselves up to be able to go garbage collect blobs safely so deleting the logo blobs manually won't matter anyway.
2026-09-12 15:34:46 +00:00
Johanna LarssonandTangled 2fc5f2e308 Wrap in counter lock and stop deleting blobs by cid 2026-09-12 15:34:46 +00:00
Johanna LarssonandTangled 877b587481 Ensure listing missing blobs always checks per user blobs 2026-09-12 15:34:46 +00:00
Johanna LarssonandTangled 695a7d981c Allow blobs to be owned by multiple accounts
The current tranquil database design only allows each blob to be owned by one account. This means that if a second account also has that blob, tranquil skips associated the blob with the account. That works fine a lot of the time, since blobs are looked up by cid and the blob exists. However, it can lead to loss of data under certain scenarios.

One example is where I upload a blob, the blob already exists in my instance so insertion is skipped (postgres requires cid to be unique in blobs, fjall only allows one owner per blob cid), I then decide to migrate off tranquil, the blob does not come with me since it is not mine.

Another example is where an account is deleted. If a blob was uploaded for account a, then account b uploads the same blob tranquil skips storing it since it exists. Then I delete account a, now account b's blob is missing.

I accidentally stumbled upon this when I migrated my account to my own tranuil instance and list blobs now lists 2 fewer blobs than before, two images that had been uploaded by accounts already on the PDS.

ps I found record_blobs a bit confusing, at first it looked like a blob ownership table, but then it turns out to just be used for migrations!

This PR makes the blob primary key be cid+user for postgres, and updates the queries to account for there being multiple "blobs" with the same cid. For queries that just care about the blob existing, it doesn't matter "whose" blob it is, so limit 1.

Most of the work is on the metastore side. Adds ref_count to track how many are referencing the blob since we can't just check for other rows. Instead of storing blobs directly, we now store a per account cid, and the blob reference itself is shared and keyed by cid only. This means some of these operations now require updating two places, so they're done in `batch`es.

With the new layout get_blob_value becomes simpler, all blob data is a single "table" or whatever it's called, so we just grab it using cid instead of looking it up for the user.

Migrates blobs rather than maintaining two different versions of the tables, although it seems like that could be supported.

I removed a test that asserted the old behavior, and added a reasonable (?) set of new tests that assert the new behavior, including a parity test.
2026-09-12 15:34:46 +00:00
Johanna LarssonandTangled 04689cbe25 Improve logging and correct translation string 2026-09-11 19:54:37 +00:00
Johanna LarssonandTangled 3474ed588d Make legacy login alerts configurable
This adds security alerts section to the communication settings, with one new item: a toggle for enabling/disabling legacy login alerts. It's enabled by default. Disabling it means that you no longer get emails when logging in through a non-2FA enabled login flow, like createSession.

The reason I tackled this is that I have a bot account that automatically refreshes its session, using username and app password, and I'm close to having 100 of these emails in my inbox. I also tried to just disable TOTP but I wasn't allowed (I fixed that in a different PR).

English and Swedish translations were me, the rest were MyMemory. I imagine someone can improve on them after this is merged!
2026-09-11 19:54:37 +00:00
Johanna LarssonandTangled 09ba5e4521 Allow disabling TOTP even if last session was legacy
This removes the `require_legacy_session_mfa` call on the TOTP disable path. It looks at the last session, which isn't really relevant, and we're getting a fresh TOTP from the form anyway, which should be enough proof to allow disabling TOTP.

The way I bumped into this was having a "bot account" log in with app password on some app on a schdule, triggering lots of warning emails. So I tried to disable TOTP to stop the flood and wasn't allowed.
2026-09-05 18:16:56 +00:00
SteffoandTangled 6750aeccaf fix: Use "registered notification channel" where applicable 2026-09-03 13:37:09 +00:00
LewisandTangled 8c3386a3ab caddy: on-demand TLS endpoint
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-09-01 16:31:10 +00:00
TrezyandTangled eba8167da8 chore: clean up supersedence shtuff
Signed-off-by: Trezy <tre@trezy.com>
2026-08-29 20:11:10 +00:00
TrezyandTangled 2e92310518 fix: allow transition:generic to be used with granular scopes
Signed-off-by: Trezy <tre@trezy.com>
2026-08-29 20:11:10 +00:00
nelindandTangled 0e82a38add fix(api): dont do rotation key validation in signPlcOperation as it blocks migrations 2026-08-29 05:42:03 +00:00
TrezyandTangled 1866d2bf0e test: i dunno what youre talking about i would never leave prints in production code 🙃
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled dc2f924130 fix: add transition: scopes to owner-level delegates
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled 97224551bf test: verify taxonomy entries parse to the correct type
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
TrezyandTangled 68ae485a52 fix: restore rpc: scopes for delegation
Signed-off-by: Trezy <tre@trezy.com>
2026-08-28 20:28:54 +00:00
Johanna LarssonandTangled 228c1bbbf5 Normalize the allow private IPs flag application 2026-08-28 12:36:47 +00:00
Johanna LarssonandTangled 093484388f Allow private IPs in dev
Running tranquil with `just run-dev` using `pds.test` seems to be broken due to a recent change that blocks requests to private ips. Inside the compose network `pds.test` resolves to the traefik container's private IP. So it can't make requests to stuff like `https://pds.test/oauth-client-metadata.json` or the local plc.

Introduces a new flag, default off, that allows connecting to private IPs, set to true for the dev compose.
2026-08-28 12:36:47 +00:00
Johanna LarssonandTangled 0f0c50f7d4 Loosen CORS header requirements
Bluesky started sending `x-bsky-is-beta-user` for some users on XRPC requests, but tranquil has a strict allowlist of CORS headers. The spec doesn't (?) specify any requirements around CORS headers, so we can avoid trouble when Bluesky make changes by just allowing all headers.

This PR replaces the allowlist with the request mirror behavior, where any headers sent on a CORS request are echoed back. This also matches the reference PDS.
2026-08-28 07:16:50 +00:00
Jack PlattenandTangled 26aa399cda Fix name of test 2026-08-27 20:07:59 +00:00
Jack PlattenandTangled f296bb68df fix: allow path-empty URIs and drop authority/path charset checks
RFC 3986 lets hier-part be path-empty, so "urn:" alone is a valid
URI; treat it as one.
2026-08-27 20:07:59 +00:00
Jack PlattenandTangled d979cb969a add underscore to scheme support.
adds tests for mbid directly, as well as underscore scheme.

Followup:
`scheme:` is not currently passing this function, but can wait for now
2026-08-27 20:07:59 +00:00
739db41130 fix: accept RFC 3986 scheme:opaque-part URIs without //
is_valid_uri required a literal "://", but the atproto uri string
format follows RFC 3986's generic URI grammar, which also allows
"scheme:opaque-part" forms with no authority (e.g. urn:isbn:...).
Records using such values were rejected once production lexicons
enable strict validation.

Reported as #130.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:07:59 +00:00
Johanna LarssonandTangled c0caa93228 Dev compose improvements
1. Set the max connections to 20, I frequently see

db-1            | 2026-08-23 08:57:02.784 UTC [12641] FATAL:  sorry, too many clients already

2. Add a wildcard route in Traefik to serve DID documents locally.

3. Expose the PLC port locally so I can point an app at `http://localhost:2582` and be able to do full OAuth flows.
2026-08-23 09:50:08 +00:00
blooym.devandTangled 3ade3d10c1 docs: clarify the postgres seq fix command
There was a syntax error in this command, so I updated the doc to clarify it and also show it needs an integer, not a string like it implied before.
2026-08-21 17:35:46 +00:00
Jack PlattenandTangled 0189aa9f96 Update config commit to create new round 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled d495d7d729 Use crate::types::queuedcomms export
also generate example.toml
2026-08-21 16:16:45 +00:00
73cb89c9b7 resolve review feedback.
- eliminates panic opportunity on receiving email
- strict enum
- added unit test for ensuring that atmos headers don't leak onto
  directmx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:16:45 +00:00
Jack PlattenandTangled ecb7934a20 fix: fix missing test failure 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled 9edc7dcdd8 comms: add comail.at category support
Adds a defaulted to off option to add the `X-Atmos-Category` headers
to emails sent via smarthost, for proper categorization by comail.

Category breakdown is as follows:

verification: EmailVerification, ChannelVerification, ChannelVerified,
  MigrationVerification, LegacyLoginAlert, EmailUpdate, PlcOperation,
  AccountDeletion
password-reset: PasswordReset, PasskeyRecovery
mfa-otp: TwoFactorCode
bulk: Welcome
untagged: AdminEmail
2026-08-21 16:16:45 +00:00
Matan KushnerandTangled 479fa3ed22 fix: require DPoP for loopback clients 2026-08-21 11:41:29 +00:00
Louis EscherandTangled aa815931e0 Update lib.rs 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 0ce725174d fix: DID length test, service test, cloning, dead code (should be it!) 2026-08-20 08:21:53 +00:00
Louis EscherandTangled dae3cc7e08 fix: aud fragment matching 2026-08-20 08:21:53 +00:00
Louis EscherandTangled b9e7955606 fix: pass exp to token creation 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 32c58b1d0b fix: make thingy allow list 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 1b5a2b319c fix: getServiceAuth aud parsing 2026-08-20 08:21:53 +00:00
LewisandTangled ed3d129594 just: clippy over all targets, lint the bsky-off build
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 8d0b6f8322 cache: DID, SSO, & OAuth client metadata caches onto shared cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0fc577316e lexicon: schema docs & negative results via cluster cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 52d5236e89 plc: dedup fetch paths, cache TTL from config
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0274f19d75 auth: EmailTokenPurpose from tranquil-types, shared cache key fns, MemoryCache in tests
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 135912194d types: HttpUrl newtypes, shared cache key/JSON helpers
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0b8787d1de pds: compile bsky-specific proxy, CORS, & validation out under bsky features
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
Lewis 18455f54f2 api: moorfc compliance endpoint toggle
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 19:58:24 +03:00
Louis EscherandTangled ce2f05b9d4 fix: make coverage triple state instead of boolean 2026-08-13 16:09:03 +00:00
Louis EscherandTangled c88f69f31d fix: Address PR review 2026-08-13 16:09:03 +00:00
Louis EscherandTangled b3c314ce66 fix: Address review comments 2026-08-13 16:09:03 +00:00
Louis EscherandTangled 434079a732 feat: compress large token scopes with brotli 2026-08-13 16:09:03 +00:00
Louis EscherandTangled a5a2f30bbe fix: Collapse action parameters for repo scopes
TODO: Still missing tests!
2026-08-13 16:09:03 +00:00
Jack PlattenandTangled dc2fbe6654 chore: use JSON array for healthcheck now that container is distroless 2026-08-13 05:35:23 +00:00
Johanna LarssonandTangled bc751b0ee2 Bring back thing that made yubikey work 2026-08-08 15:41:29 +00:00
Johanna LarssonandTangled 9e78206cf4 Switch back to SecurityKey, remove hint 2026-08-08 15:41:29 +00:00
Johanna LarssonandTangled 779dc1b985 Replace SecurityKey with Passkey 2026-08-08 15:41:29 +00:00
Edmund EdgarandTangled 1dc0c40206 fix: don't require a server-custodied rotation key
Remove the requirement that the user's did can be controlled by the
server rotation key and signing key. This was preventing users who
didn't want to trust the PDS with their rotation keys from activating
their accounts. Errors are removed but we issue debug messages.
2026-07-26 11:45:09 +03:00
nelind 72fa88d79a chore: bump version to 0.6.6 2026-07-26 00:25:56 +02:00
nelindandTangled 596b9b15fd feat(docs): explain our definition of "correct PDS implementation" 2026-07-25 18:31:06 +03:00
nelindandTangled 59934cc184 feat: add bsky and bsky-support cargo features to manage bsky specific code 2026-07-25 18:31:06 +03:00
nelindandTangled 34a47e6e5a chore: clean up Cargo.toml files with cargo-shear and remove default feature on lib crates 2026-07-25 18:31:06 +03:00
LewisandTangled aca78bb8d3 scopes: tweak tests, add translations
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-07-25 11:08:27 +03:00
LewisandTangled 25d7d24d4e types: drop unchecked construction from validated newtypes
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled b6274bb3c4 api: validated newtypes from their checked constructors
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 2a96a8f420 server: report absent repo rev instead of an empty one
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 9ad70bda9e sync: parse xrpc query params into real types
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled c3a8240154 tests: assert user_blocks matches reachable set after every write
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 1e2311f8fc repo: keep user_blocks equal to the reachable block set
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 17905115d8 store: revalidate stored mutation sets on replay
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 6ed568dbfb store: rebuild derived indexes when stored format version is older
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled d00d72895a store: record-by-cid reverse index
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:40 +03:00
LewisandTangled 2f1e22a950 store: add record-by-cid key shapes & chunked scan helpers
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 946cb9740f store: warn & skip instead of failing on unreadable event payloads
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 0c7cccb14c invite: require owning account for generated codes
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 4f37ac26cd store: typed revs thru metastore keys & requests
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled bbe9f6f3b3 db: decode sequenced event rows leniently
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 4e3b2f2af4 handle: reject handles whose tld can never resolve
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 932b0c07d4 db: make stored handle optional when it no longer parses
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 01a71ece7c db: check column conversions when mapping rows
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 8abb6cc741 db: name column behind invalid value
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 2bfea64ffc api: add xrpc query extractor
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled e931268f7e types: validate on construction & normalize what gets stored
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
LewisandTangled 14a086cb13 store: fix inline block span parsing in event sidecars
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-25 08:27:39 +03:00
TrezyandTangled 7244551ae1 refactor: clean up property names and document stringy values
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 01d93e44e7 fix: show the user which scopes will not be granted based on delegation
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled fa50d7d54e fix: prevent rpc scopes from escaping permission set namespace
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 19e7ec29f5 fix: prevent transient permission set publishers from causing auth refresh failures
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 311fbfcb86 feat: add handling for more scope failures
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 515c058006 refactor: use Nsid newtype instead of strings
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled aca6dd926e chore: clean up dead code
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 9c6730579e feat: display bundled permission-sets on consent screen
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:27 +03:00
TrezyandTangled 348ac887fc fix: use JWT scopes as source-of-truth for access and refresh tokens
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:26 +03:00
TrezyandTangled ecdda4c555 feat: cache expanded permission sets 2026-07-24 20:11:26 +03:00
TrezyandTangled f17adc6f88 refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth
Signed-off-by: Trezy <tre@trezy.com>
2026-07-24 20:11:26 +03:00
LewisandTangled 00ca223b5f identity: force did:web signkey check to #atproto verification
Lewis: May this revision serve well! <lu5a@proton.me>
2026-07-23 10:34:21 +03:00
nelind 9ccec274aa chore: nix flake update 2026-07-17 02:14:30 +02:00
nelind f2754efeb2 chore(nix): use fetcherVersion 4 of fetchPnmpDeps 2026-07-17 02:14:30 +02:00
nelindandTangled 4416f50c87 feat(config): add more default crawlers/relays 2026-07-15 08:58:35 +03:00
265 changed files with 15021 additions and 4591 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1",
"query": "SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1 LIMIT 1",
"describe": {
"columns": [
{
@@ -30,5 +30,5 @@
false
]
},
"hash": "dd1b61d6ec81fd891d4effd3b51e6c22308b878acdc5355dfcb04c5664c9463b"
"hash": "03f129e4984e1bed9e87294adc9caf1730906d889101b9039113ec8aa234618d"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as \"total!\" FROM blobs",
"query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as \"total!\"\n FROM (SELECT DISTINCT cid, size_bytes FROM blobs) t",
"describe": {
"columns": [
{
@@ -16,5 +16,5 @@
null
]
},
"hash": "0890b2c7c921005f58ed0e57b6e062b2085ce804a4cccb27b4ae2ba6711f24c4"
"hash": "155efbae4cd55f73ec0709dda7b18a76e92065e6ae4a6081bd38a19821fbfcc3"
}
@@ -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"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
"query": "SELECT cid, takedown_ref FROM blobs WHERE cid = $1 ORDER BY takedown_ref NULLS LAST LIMIT 1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "62942bd21d545eb15bfea4f46378b6c2ebfe12b8bc9e27c63a6c0f77a9105303"
"hash": "5996484ff0f8dbc3b278cfd01b8375dbf7bf6da8d903145b12871dda6e1fd5d9"
}
@@ -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"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage_key as \"storage_key!\" FROM blobs b\n WHERE created_by_user = $1\n AND NOT EXISTS (\n SELECT 1 FROM blobs o\n WHERE o.cid = b.cid AND o.created_by_user <> $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "storage_key!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "8844d942ef2810afc386e5a9838624ee07a43c380d2df31efdba5cf299aab571"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT u.id as user_id, u.did\n FROM users u\n JOIN repos r ON r.user_id = u.id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "8951136879711bca5b562c34f88e691a8ee16f370f6ef9b88ddb3873ddf2b45f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (cid) DO NOTHING RETURNING cid",
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (cid, created_by_user) DO NOTHING RETURNING cid",
"describe": {
"columns": [
{
@@ -22,5 +22,5 @@
false
]
},
"hash": "8afea2b745385348f4c78b51f74145d6718bfcf9a3a0c218109ec691aeb930ba"
"hash": "996e5513fb55670fe3304a6046381e377da6a187dfa3347bd285078a7b4410f2"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage_key FROM blobs WHERE cid = $1",
"query": "SELECT storage_key FROM blobs WHERE cid = $1 LIMIT 1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
"hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052"
"hash": "9fb9e128076b20ff067d01955221488ce7e5b886dba0529fb073c3e0461fe030"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM blobs WHERE cid = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)\n SELECT DISTINCT b.cid, b.mime_type, b.size_bytes, $1::uuid, b.storage_key\n FROM blobs b WHERE b.cid = $2\n ON CONFLICT (cid, created_by_user) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "e248d71f595abf0207b01bc2f4e1f312d0c96b0f2f5131dfc13bfbb42a79d886"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT storage_key as \"storage_key!\" FROM blobs WHERE created_by_user = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "storage_key!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "f59010ecdd7f782489e0e03288a06dacd72b33d04c1e2b98475018ad25485852"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3",
"query": "SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = $1\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3",
"describe": {
"columns": [
{
@@ -26,5 +26,5 @@
false
]
},
"hash": "8e88ec169f0ef14c7207944cd4c2c0970e302c0457f9e317ec752dc13a5b1393"
"hash": "f8bb421e07e47f7b0a3b2789e368abfa6ad64152e8660e3efc7117b2d9320f22"
}
+55
View File
@@ -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 application.
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
+79 -75
View File
@@ -105,6 +105,21 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -1250,6 +1265,27 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -2486,7 +2522,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -7630,9 +7665,8 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"anyhow",
"axum",
"backon",
"base32",
@@ -7666,27 +7700,25 @@ 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.7"
dependencies = [
"anyhow",
"base32",
"base64 0.22.1",
"bcrypt",
"brotli",
"chrono",
"hmac",
"k256",
@@ -7698,13 +7730,14 @@ dependencies = [
"totp-rs",
"tranquil-config",
"tranquil-crypto",
"tranquil-types",
"urlencoding",
"uuid",
]
[[package]]
name = "tranquil-cache"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7719,7 +7752,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7733,7 +7766,6 @@ dependencies = [
"rsa",
"secrecy",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7745,15 +7777,16 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"confique",
"serde",
"tranquil-types",
]
[[package]]
name = "tranquil-crypto"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7769,7 +7802,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"chrono",
@@ -7786,7 +7819,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7802,18 +7835,20 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"bytes",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"tranquil-config",
"tranquil-types",
]
[[package]]
name = "tranquil-lexicon"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"chrono",
"futures",
@@ -7825,14 +7860,15 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"unicode-segmentation",
"urlencoding",
"wiremock",
]
[[package]]
name = "tranquil-oauth"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"anyhow",
"axum",
@@ -7849,14 +7885,16 @@ dependencies = [
"sqlx",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"uuid",
]
[[package]]
name = "tranquil-oauth-server"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bcrypt",
@@ -7879,7 +7917,9 @@ dependencies = [
"tranquil-config",
"tranquil-crypto",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-pds",
"tranquil-scopes",
"tranquil-types",
"urlencoding",
"uuid",
@@ -7888,7 +7928,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7900,7 +7940,6 @@ dependencies = [
"base32",
"base64 0.22.1",
"bcrypt",
"bs58",
"bytes",
"chrono",
"ciborium",
@@ -7916,7 +7955,6 @@ dependencies = [
"hmac",
"http 1.4.0",
"image",
"infer",
"ipld-core",
"iroh-car",
"jacquard-common",
@@ -7958,9 +7996,9 @@ dependencies = [
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-lexicon",
"tranquil-oauth",
"tranquil-oauth-server",
@@ -7977,12 +8015,11 @@ dependencies = [
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"bytes",
"cid",
@@ -7994,7 +8031,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"backon",
@@ -8023,7 +8060,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"axum",
"futures",
@@ -8034,12 +8071,13 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-types",
"urlencoding",
]
[[package]]
name = "tranquil-server"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"arc-swap",
"axum",
@@ -8076,7 +8114,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"chrono",
@@ -8084,7 +8122,6 @@ dependencies = [
"futures",
"presage",
"rand 0.9.2",
"serde",
"serde_json",
"sqlx",
"tempfile",
@@ -8092,14 +8129,13 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"tranquil-signal",
"url",
"uuid",
]
[[package]]
name = "tranquil-storage"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"aws-config",
@@ -8116,7 +8152,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"async-trait",
"bytes",
@@ -8156,7 +8192,6 @@ dependencies = [
"tranquil-db",
"tranquil-db-traits",
"tranquil-oauth",
"tranquil-repo",
"tranquil-store",
"tranquil-types",
"uuid",
@@ -8165,7 +8200,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"anyhow",
"axum",
@@ -8187,15 +8222,22 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.5"
version = "0.6.7"
dependencies = [
"base64 0.22.1",
"chrono",
"cid",
"jacquard-common",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid",
]
[[package]]
@@ -8247,12 +8289,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 +9490,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 +9538,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"
+2 -3
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.5"
version = "0.6.7"
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" }
@@ -138,11 +137,11 @@ tower-layer = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"
urlencoding = "2.1"
url = "2.5"
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"
+1
View File
@@ -79,6 +79,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
- [@oyster.cafe](https://tangled.org/did:plc:3fwecdnvtcscjnrx2p4n7alz)
- [@nel.pet](https://tangled.org/did:plc:h5wsnqetncv6lu2weom35lg2)
- [@jola.dev](https://tangled.org/did:plc:bvraa6gajy4tfr3eh2sisdkr)
### Amazing contributors
+2
View File
@@ -1,6 +1,7 @@
[server]
hostname = "pds.test"
allow_http_proxy = true
allow_private_fetch = true
invite_code_required = false
disable_rate_limiting = true
@@ -10,6 +11,7 @@ dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
max_connections = 20
[storage]
path = "/var/lib/tranquil-pds/blobs"
+4 -4
View File
@@ -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 = []
-3
View File
@@ -1,3 +0,0 @@
mod preferences;
pub use preferences::{get_preferences, put_preferences};
@@ -66,11 +66,11 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let available_domains = tranquil_config::get().server.available_user_domain_list();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, &available_domains[0])
} else {
let primary = tranquil_pds::handle::ServiceDomains::for_user_handles().primary();
let handle = if input_handle.contains('.') {
input_handle.to_string()
} else {
format!("{}.{}", input_handle, primary)
};
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
-42
View File
@@ -1,10 +1,8 @@
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
use tranquil_types::CidLink;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -183,46 +181,6 @@ pub async fn update_server_config(
}
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid = state
.repos
.infra
.get_server_config("logo_cid")
.await
.ok()
.flatten();
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
(Some(old), true) => Some(old.clone()),
(Some(old), false) if old != logo_cid => Some(old.clone()),
_ => None,
};
if let Some(old_cid_str) = should_delete_old {
match CidLink::new(old_cid_str) {
Ok(old_cid) => {
if let Ok(Some(storage_key)) = state
.repos
.infra
.get_blob_storage_key_by_cid(&old_cid)
.await
{
if let Err(e) = state.blob_store.delete(&storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
}
}
}
Err(e) => {
warn!(
"Old logo CID in database is invalid, skipping cleanup: {:?}",
e
);
}
}
}
if logo_cid.is_empty() {
state
.repos
+2 -1
View File
@@ -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 {
+15 -10
View File
@@ -12,8 +12,8 @@ use tranquil_pds::api::{
};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::delegation::{
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
verify_can_control_accounts,
DelegationActionType, IdentityResolutionError, SCOPE_PRESETS, ValidatedDelegationScope,
verify_can_add_controllers, verify_can_control_accounts,
};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
@@ -65,16 +65,16 @@ pub async fn add_controller(
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.map_err(|_| ApiError::ControllerNotFound)?;
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
if !resolved.is_local
&& let Some(ref pds_url) = resolved.pds_url
{
if !pds_url.starts_with("https://") {
return Err(ApiError::InvalidDelegation(
"Controller PDS must use HTTPS".into(),
));
}
match state
.cross_pds_oauth
.check_remote_is_delegated(pds_url, &input.controller_did)
@@ -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,
};
@@ -477,7 +477,12 @@ pub async fn resolve_controller(
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.map_err(|_| ApiError::ControllerNotFound)?;
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
Ok(Json(resolved))
}
+14 -11
View File
@@ -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
+6 -10
View File
@@ -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
@@ -147,12 +147,7 @@ async fn try_reactivate_migration(
Json(CreateAccountOutput {
handle: handle.clone(),
did: did.clone(),
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
@@ -202,7 +197,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 +444,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 {
@@ -567,7 +563,7 @@ pub async fn create_account(
Json(CreateAccountOutput {
handle: handle.clone(),
did,
did_doc: did_doc.map(|f| (*f).clone()),
did_doc,
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
+23 -29
View File
@@ -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;
@@ -130,12 +132,9 @@ pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -
let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
if host_without_port != hostname_without_port {
let is_subdomain = cfg
.server
.available_user_domain_list()
.into_iter()
.chain(std::iter::once(hostname_without_port.to_string()))
.any(|d| host_without_port.ends_with(&format!(".{}", d)));
let is_subdomain = tranquil_pds::handle::ServiceDomains::served()
.split_handle(host_without_port)
.is_some();
if is_subdomain {
return serve_handle_did_doc(&state, host_without_port, hostname).await;
}
@@ -425,10 +424,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(
@@ -575,26 +579,16 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let handle_domains = tranquil_config::get().server.user_handle_domain_list();
let matched_handle_domain = handle_domains
.iter()
.filter(|d| new_handle.ends_with(&format!(".{}", d)))
.max_by_key(|d| d.len())
.cloned();
let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle);
let handle: Handle = if (!new_handle.contains('.') || matched_handle_domain.is_some())
&& !is_domain_itself
{
let (short_part, full_handle) = match &matched_handle_domain {
Some(domain) => {
let suffix = format!(".{}", domain);
let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle);
(short.to_string(), new_handle.clone())
}
None => {
let primary = &handle_domains[0];
(new_handle.clone(), format!("{}.{}", new_handle, primary))
}
let handle_domains = tranquil_pds::handle::ServiceDomains::for_user_handles();
let split = handle_domains.split_handle(&new_handle);
let is_domain_itself = handle_domains.contains(&new_handle);
let handle: Handle = if (!new_handle.contains('.') || split.is_some()) && !is_domain_itself {
let (short_part, full_handle) = match split {
Some((_domain, short)) => (short.to_string(), new_handle.clone()),
None => (
new_handle.clone(),
format!("{}.{}", new_handle, handle_domains.primary()),
),
};
if full_handle == current_handle {
let handle: Handle = match full_handle.parse() {
+1 -16
View File
@@ -9,10 +9,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::circuit_breaker::with_circuit_breaker;
use tranquil_pds::plc::{
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
signing_key_to_did_key,
};
use tranquil_pds::plc::{PlcError, PlcService, create_update_op, sign_operation};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
@@ -118,18 +115,6 @@ pub async fn sign_plc_operation(
}
})?;
let signing_did_key = signing_key_to_did_key(&signing_key);
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
if let Some(missing) = missing_required_rotation_key(
&rotation_key_strs,
&signing_did_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
) {
return Err(ApiError::InvalidRequest(missing.message().into()));
}
}
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
error!("Failed to sign PLC operation: {:?}", e);
ApiError::InternalError(None)
+26 -15
View File
@@ -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()],
})
}
@@ -164,6 +164,13 @@ pub async fn resolve_signing_key(
}
}
#[cfg_attr(
not(feature = "bsky"),
expect(
unused_variables,
reason = "only the bsky block writes display_name into the default profile record"
)
)]
pub async fn sequence_new_account(
state: &AppState,
did: &Did,
@@ -198,27 +205,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);
};
}
}
+42 -15
View File
@@ -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,15 +459,23 @@ 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> {
use axum::routing::get;
axum::Router::new()
let routes = axum::Router::new()
.route("/did.json", get(identity::well_known_did))
.route("/atproto-did", get(identity::well_known_atproto_did))
.route("/atproto-did", get(identity::well_known_atproto_did));
if tranquil_config::get().server.enable_caddy_on_demand_tls {
routes.route("/caddy/ask", get(server::caddy_ask))
} else {
routes
}
}
pub fn webhook_routes() -> axum::Router<AppState> {
@@ -474,9 +495,15 @@ pub fn webhook_routes() -> axum::Router<AppState> {
pub fn misc_routes() -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new()
let router = axum::Router::new()
.route("/health", get(server::health))
.route("/robots.txt", get(server::robots_txt))
.route("/favicon.ico", get(server::get_logo))
.route("/u/{handle}/did.json", get(identity::user_did_doc))
.route("/u/{handle}/did.json", get(identity::user_did_doc));
if tranquil_config::get().server.rfc_moo_compliance {
router.route("/cow.txt", get(server::cow_txt))
} else {
router
}
}
+8 -4
View File
@@ -6,12 +6,16 @@ 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};
use tranquil_pds::auth::{AnyUser, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Nsid};
use tranquil_pds::types::{Did, DidRef, 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 {
@@ -145,11 +149,11 @@ 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),
&DidRef::from(service_did),
Some(&CREATE_REPORT_NSID),
None,
&key_bytes,
) {
Ok(t) => t,
+36 -2
View File
@@ -19,6 +19,7 @@ pub struct NotificationPrefsOutput {
pub telegram_verified: bool,
pub signal_username: Option<String>,
pub signal_verified: bool,
pub legacy_login_alerts: bool,
}
pub async fn get_notification_prefs(
@@ -32,6 +33,26 @@ pub async fn get_notification_prefs(
.await
.log_db_err("get notification prefs")?
.ok_or(ApiError::AccountNotFound)?;
let user_id = state
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("get user by did")?
.ok_or(ApiError::AccountNotFound)?;
let legacy_login_alerts = state
.repos
.infra
.get_account_preferences(user_id)
.await
.log_db_err("get legacy login alert prefs")?
.iter()
.find(|(name, _)| name == "legacy_login_alerts")
.and_then(|(_, value)| value.as_bool())
.unwrap_or(true);
Ok(Json(NotificationPrefsOutput {
preferred_channel: prefs.preferred_channel,
email: prefs.email,
@@ -41,6 +62,7 @@ pub async fn get_notification_prefs(
telegram_verified: prefs.telegram_verified,
signal_username: prefs.signal_username,
signal_verified: prefs.signal_verified,
legacy_login_alerts,
}))
}
@@ -121,6 +143,7 @@ pub struct UpdateNotificationPrefsInput {
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_username: Option<String>,
pub legacy_login_alerts: Option<bool>,
}
#[derive(Serialize)]
@@ -147,12 +170,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,
)
@@ -433,6 +458,15 @@ pub async fn update_notification_prefs(
.await?;
}
if let Some(alerts) = input.legacy_login_alerts {
state
.repos
.infra
.upsert_account_preference(user_id, "legacy_login_alerts", json!(alerts))
.await
.log_db_err("update legacy login alert prefs")?;
}
Ok(Json(UpdateNotificationPrefsOutput {
success: true,
verification_required,
+14 -19
View File
@@ -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) => {
@@ -144,7 +148,13 @@ pub async fn upload_blob(
size, cid_str
);
match state
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to copy blob to final location: {:?}", e);
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
}
if let Err(e) = state
.repos
.blob
.insert_blob(
@@ -156,24 +166,9 @@ pub async fn upload_blob(
)
.await
{
Ok(_) => {}
Err(e) => {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to insert blob record: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
let _ = state.blob_store.delete(&temp_key).await;
if let Err(db_err) = state.repos.blob.delete_blob_by_cid(&cid_link).await {
error!(
"Failed to clean up orphaned blob record after copy failure: {:?}",
db_err
);
}
error!("Failed to copy blob to final location: {:?}", e);
return Err(ApiError::InternalError(Some("Failed to store blob".into())));
error!("Failed to insert blob record: {:?}", e);
return Err(ApiError::InternalError(None));
}
let _ = state.blob_store.delete(&temp_key).await;
+35 -12
View File
@@ -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::*;
+17 -7
View File
@@ -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) => {
@@ -10,7 +10,7 @@ use serde_json::Value;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
@@ -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
@@ -207,9 +212,10 @@ async fn assert_valid_did_document_for_service(
if let Some(ref expected_rotation_key) = server_rotation_key
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
{
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
debug!(
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
did, doc_rotation_keys, expected_rotation_key
);
}
let doc_signing_key = doc_data
@@ -246,13 +252,10 @@ async fn assert_valid_did_document_for_service(
}
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
warn!(
debug!(
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
did, doc_rotation_keys, expected_did_key
);
return Err(ApiError::InvalidRequest(
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
));
}
}
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
@@ -449,7 +452,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
View File
@@ -0,0 +1,43 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use tracing::error;
use tranquil_pds::handle::ServiceDomains;
use tranquil_pds::state::AppState;
use tranquil_pds::types::Handle;
pub struct AskedDomain(Handle);
impl<'de> Deserialize<'de> for AskedDomain {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
let without_root_dot = raw.strip_suffix('.').unwrap_or(&raw);
Handle::new(without_root_dot)
.map(Self)
.map_err(D::Error::custom)
}
}
#[derive(Deserialize)]
pub struct CaddyAskQuery {
pub domain: AskedDomain,
}
pub async fn caddy_ask(
State(state): State<AppState>,
Query(ask): Query<CaddyAskQuery>,
) -> StatusCode {
let AskedDomain(handle) = ask.domain;
if ServiceDomains::served().contains(handle.as_str()) {
return StatusCode::OK;
}
match state.repos.user.get_by_handle(&handle).await {
Ok(Some(_)) => StatusCode::OK,
Ok(None) => StatusCode::NOT_FOUND,
Err(e) => {
error!("caddy ask couldn't look up handle {handle}: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
+57
View File
@@ -0,0 +1,57 @@
..........................
....*o|||||||8#@@@@@@@@@@@@@@@@@@@@@@@###&|o:_..
..*:o|||&8##@###8888888######@#@###########################|*...
.:o|||8#####8888|:::**. *&########################@@################&o_
.*o&8###@#8&o*_. :###@##############@########################@@##&o_
.*o8########& :##@#@##############@############################@###|_
.*o|8##########8o .#######################################################&o_
*&##|_ ..*&##8&o*|88888|_ _#######################################@##################|.
*#####& *&######&o_..*o|o:_ .&##o _###########################################################&_
_##8*##8 .|88|:::|#######8###8|*:_ .&#@@8 _##@@@########################################################&_
_#@8_##8_ *8#8|*_ _:|#####&&####8 .&##############################################################|
_#@8.|##8_ _::o###8&##8 .|##@############################8###########################@@#|_
*###o.|88o ..*&####|..##& _|##########################8|_ .|#############################8
*|###|_ ._&####8|*_ _*_ _::&8888888888888888|::*_ .|##@####@@@##################|
*&###|_ _:_ .&88###8|*_ ..... .|#####@@@##################8
.##@#& _##& .|##o _#@@#@#| .|#######&:_ _|###@####################8
.:8##8o _o:*&##| *##8_.&@@##@#| _::o8#8|::|#####|_ _|#################88###8
.&##&*_ *###o_###| .|##8*&##|*###o _###8####8|_ _:|###|_ .*o|||o:_ _:::&8888888888|_ _##8
.###|_. _###o *###|*&#######8 *##8 .##8_ _:|###|_ _|###|_ .&########o _##&
_|####8|&##8:_ _|#########88o .##8 *##& _|###o .|###o .#########| .o##o
o#8|*:#@@###o _:::*__*_ _##8_ _##8_ _&##|_ *##8_ *8#####8|_ .*oo:_ o##|
*###o.&#####& _oo* .8##& .8##8_ .|##& o##& _::::_.*o|8######|_ .##8.
_###&o&##8_:*_ .###& .###|_&#####| _##8 :###o *ooo&#########@#@#& ....:##&
.|8||###&. _**_ .###88##|*&###|*._&##& *|##8_ *o&####@@####@@######& .*o||||||&#######8_
*&###o _|88##8_ _:8######|*:###|_ _##################88|_ *&#################&
*#####o *&8o *##& _:::*_.&##|_ _#@##############8_ :##################8*
.###&##8_.|88o *&8o _@@& .###| .&####@#########|_ .####@###@@########8*
_##&.|###|_.... .|88o _##8* *&###|_ *###o _:&88######8|_ .*o|||##################o
_##8_ _|########|_ .*o8####&#@@#@##o *#@8 _*:*. .&###@###################|
.|##& _:::::&##& .*&##############@#8_ .###o _#@####################|_
.&##|_ .&##8_ *o&####################8_ *##& .&#####@@############8*
.|##8_.&###&####8_ _########################8**##& _##################|_
.&###&##888888##8_ .|88######@###########|*######o _|8###############|_
.&#####o *###|_ _::::::*o##8**o##8 .|###8o .&##@#############&*
.|####o _|###|_ _##8.*&##& _*_ .#################|
_*_ _|###|_.. .|#####8|_ *&#@#########8###&
*#######&|o:_... ..*:::*. ......._:o&8####888&o:#####8_
_###|&888#####@#####&|||o:_........................._:o||||8##@@@@####8|:*_ _:::*_
.|#@###o _:::o#@#888######@@@@@@@@@@@@@@@@@@@@@@@@#####888|::::::**_
_::*_ :##& *&8|_:::::::::::::::::::::::::**_
.###8||&##8o
_|888888|_
+19 -17
View File
@@ -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 }))
}
+10 -1
View File
@@ -29,6 +29,10 @@ pub async fn robots_txt() -> impl IntoResponse {
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
)
}
pub async fn cow_txt() -> &'static str {
include_str!("cow.txt")
}
pub fn is_self_hosted_did_web_enabled() -> bool {
tranquil_config::get().server.enable_pds_hosted_did_web
}
@@ -73,7 +77,12 @@ pub async fn describe_server(State(state): State<AppState>) -> Json<DescribeServ
let pds_hostname = &cfg.server.hostname;
Json(DescribeServerOutput {
available_user_domains: cfg.server.user_handle_domain_list(),
available_user_domains: match cfg.server.user_handle_domains.as_deref() {
Some(domains) if !domains.is_empty() => {
domains.iter().map(|d| d.as_str().to_owned()).collect()
}
_ => vec![cfg.server.hostname_without_port().to_owned()],
},
invite_code_required: cfg.server.invite_code_required,
did: format!("did:web:{}", pds_hostname),
links: DescribeServerLinks {
+3 -1
View File
@@ -1,5 +1,6 @@
pub mod account_status;
pub mod app_password;
pub mod caddy;
pub mod email;
pub mod invite;
pub mod logo;
@@ -22,13 +23,14 @@ pub use account_status::{
request_account_delete,
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use caddy::caddy_ask;
pub use email::{
authorize_email_update, check_channel_verified, check_email_in_use, check_email_update_status,
check_email_verified, confirm_email, request_email_update, update_email,
};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};
pub use meta::{cow_txt, describe_server, health, robots_txt};
pub use migration::{get_did_document, update_did_document};
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,
@@ -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(),
+17 -9
View File
@@ -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");
+15 -10
View File
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::extractor::{Auth, Permissive};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
use tranquil_pds::types::DidRef;
use tranquil_types::Nsid;
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: Did,
pub aud: DidRef,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -169,14 +169,19 @@ pub async fn get_service_auth(
}
}
let service_token =
match tranquil_pds::auth::create_service_token(&auth.did, &params.aud, lxm, &key_bytes) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
&params.aud,
lxm,
params.exp,
&key_bytes,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
(
StatusCode::OK,
Json(GetServiceAuthOutput {
+34 -20
View File
@@ -317,23 +317,37 @@ pub async fn create_session(
return Err(ApiError::InternalError(None));
}
if is_legacy_login && !used_totp_factor {
warn!(
did = %row.did,
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_legacy_login(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
row.id,
hostname,
client_ip,
row.preferred_comms_channel,
)
.await
{
error!("Failed to queue legacy login notification: {:?}", e);
let alerts_enabled = match state.repos.infra.get_account_preferences(row.id).await {
Ok(prefs) => prefs
.iter()
.find(|(name, _)| name == "legacy_login_alerts")
.and_then(|(_, value)| value.as_bool())
.unwrap_or(true),
Err(e) => {
warn!("Failed to fetch legacy login alert preference: {:?}", e);
true
}
};
if alerts_enabled {
warn!(
did = %row.did,
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_legacy_login(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
row.id,
hostname,
client_ip,
row.preferred_comms_channel,
)
.await
{
error!("Failed to queue legacy login notification: {:?}", e);
}
}
}
let handle = row.handle.clone();
@@ -351,7 +365,7 @@ pub async fn create_session(
refresh_jwt: refresh_meta.token,
handle,
did: row.did,
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
email: row.email,
email_confirmed: Some(row.channel_verification.email),
email_auth_factor: email_auth_factor_out,
@@ -444,7 +458,7 @@ pub async fn get_session(
status: account_state.status_for_session().map(String::from),
migrated_to_pds,
migrated_at,
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
}))
}
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
@@ -800,7 +814,7 @@ async fn build_refresh_session_output(
preferred_locale: u.preferred_locale,
is_admin: u.is_admin,
active: account_state.is_active(),
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
status: account_state.status_for_session().map(String::from),
}))
}
@@ -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)]
+4 -6
View File
@@ -6,8 +6,8 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
verify_totp_code, verify_totp_mfa,
is_backup_code_format, verify_backup_code, verify_password_mfa, verify_totp_code,
verify_totp_mfa,
};
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use tranquil_pds::state::AppState;
@@ -163,11 +163,9 @@ pub async fn disable_totp(
auth: Auth<Active>,
Json(input): Json<DisableTotpInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
session_mfa.did(),
auth.did.as_str(),
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
@@ -184,7 +182,7 @@ pub async fn disable_totp(
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
Ok(Json(EmptyResponse {}))
}
+14 -2
View File
@@ -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,
+10 -11
View File
@@ -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) => {
-31
View File
@@ -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(),
}
}
+1
View File
@@ -24,3 +24,4 @@ subtle = { workspace = true }
totp-rs = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
brotli = "8.0.4"
+183
View File
@@ -0,0 +1,183 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use brotli::{CompressorWriter, Decompressor};
use std::fmt;
use std::io::{Read, Write};
const COMPRESSED_PREFIX: &str = "$br$";
const QUALITY: u32 = 9;
const WINDOW_BITS: u32 = 16;
const BUFFER_SIZE: usize = 4096;
const MAX_SCOPE_LEN: u64 = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeDecodeError {
Base64DecodeFailed,
DecompressFailed,
TooLarge,
}
impl fmt::Display for ScopeDecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Base64DecodeFailed => write!(f, "Base64 decode of compressed scope failed"),
Self::DecompressFailed => write!(f, "Brotli decompression of scope failed"),
Self::TooLarge => write!(f, "Decompressed scope exceeds maximum length"),
}
}
}
impl std::error::Error for ScopeDecodeError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeEncodeError {
TooLarge,
}
impl fmt::Display for ScopeEncodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TooLarge => write!(f, "Scope exceeds maximum length"),
}
}
}
impl std::error::Error for ScopeEncodeError {}
fn brotli_compress(input: &str) -> Vec<u8> {
let mut writer = CompressorWriter::new(Vec::new(), BUFFER_SIZE, QUALITY, WINDOW_BITS);
writer
.write_all(input.as_bytes())
.expect("writing to a Vec cannot fail");
writer.into_inner()
}
fn brotli_decompress(input: &[u8]) -> Result<String, ScopeDecodeError> {
let mut output = String::new();
Decompressor::new(input, BUFFER_SIZE)
.take(MAX_SCOPE_LEN + 1)
.read_to_string(&mut output)
.map_err(|_| ScopeDecodeError::DecompressFailed)?;
if output.len() as u64 > MAX_SCOPE_LEN {
return Err(ScopeDecodeError::TooLarge);
}
Ok(output)
}
pub fn encode_scope(scope: &str) -> Result<String, ScopeEncodeError> {
if scope.len() as u64 > MAX_SCOPE_LEN {
return Err(ScopeEncodeError::TooLarge);
}
let tagged = format!(
"{COMPRESSED_PREFIX}{}",
URL_SAFE_NO_PAD.encode(brotli_compress(scope))
);
if tagged.len() < scope.len() || scope.starts_with(COMPRESSED_PREFIX) {
Ok(tagged)
} else {
Ok(scope.to_owned())
}
}
pub fn decode_scope(scope: &str) -> Result<String, ScopeDecodeError> {
let Some(encoded) = scope.strip_prefix(COMPRESSED_PREFIX) else {
return Ok(scope.to_owned());
};
let compressed = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| ScopeDecodeError::Base64DecodeFailed)?;
brotli_decompress(&compressed)
}
#[cfg(test)]
mod tests {
use super::*;
fn long_scope() -> String {
let mut scope = String::from("transition:generic transition:chat.bsky");
for collection in [
"social.colibri.message",
"social.colibri.community",
"social.colibri.reaction",
"social.colibri.member",
"social.colibri.channel.read",
] {
scope.push_str(&format!(" repo:{collection}?action=create&action=delete"));
}
scope
}
#[test]
fn long_scope_roundtrips_through_compression() {
let scope = long_scope();
let encoded = encode_scope(&scope).unwrap();
assert!(encoded.starts_with(COMPRESSED_PREFIX));
assert!(encoded.len() < scope.len());
assert_eq!(decode_scope(&encoded).unwrap(), scope);
}
#[test]
fn short_scope_stays_plaintext() {
let encoded = encode_scope("com.atproto.access").unwrap();
assert_eq!(encoded, "com.atproto.access");
assert_eq!(decode_scope(&encoded).unwrap(), "com.atproto.access");
}
#[test]
fn untagged_scope_passes_through() {
assert_eq!(
decode_scope("com.atproto.refresh").unwrap(),
"com.atproto.refresh"
);
assert_eq!(decode_scope("").unwrap(), "");
}
#[test]
fn malformed_compressed_scope_errors_instead_of_panicking() {
assert_eq!(
decode_scope("$br$not valid base64!"),
Err(ScopeDecodeError::Base64DecodeFailed)
);
assert_eq!(
decode_scope("$br$AAAAAAAAAAAAAAAA"),
Err(ScopeDecodeError::DecompressFailed)
);
}
#[test]
fn compression_bomb_is_rejected() {
let bomb = URL_SAFE_NO_PAD.encode(brotli_compress(&"a".repeat(MAX_SCOPE_LEN as usize * 2)));
assert_eq!(
decode_scope(&format!("{COMPRESSED_PREFIX}{bomb}")),
Err(ScopeDecodeError::TooLarge)
);
}
#[test]
fn plaintext_that_looks_compressed_roundtrips() {
let scope = "$br$repo:*";
let encoded = encode_scope(scope).unwrap();
assert!(encoded.starts_with(COMPRESSED_PREFIX));
assert_eq!(decode_scope(&encoded).unwrap(), scope);
}
#[test]
fn encode_rejects_oversized_scope() {
let oversized = "a".repeat(MAX_SCOPE_LEN as usize + 1);
assert_eq!(encode_scope(&oversized), Err(ScopeEncodeError::TooLarge));
assert!(encode_scope(&"a".repeat(MAX_SCOPE_LEN as usize)).is_ok());
}
}
+3
View File
@@ -1,3 +1,4 @@
mod compress;
mod token;
mod totp;
mod types;
@@ -12,6 +13,8 @@ pub use token::{
create_service_token_hs256,
};
pub use compress::{ScopeDecodeError, ScopeEncodeError, decode_scope, encode_scope};
pub use totp::{
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
+15 -9
View File
@@ -1,14 +1,16 @@
use crate::compress::encode_scope;
use super::types::{
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
};
use anyhow::Result;
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
use tranquil_types::{Did, Jti, Nsid};
use tranquil_types::{Did, DidRef, Jti, Nsid};
type HmacSha256 = Hmac<Sha256>;
@@ -125,16 +127,20 @@ pub fn create_refresh_token_with_jti(
pub fn create_service_token(
did: &Did,
aud: &Did,
aud: &DidRef,
lxm: Option<&Nsid>,
exp: Option<i64>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let expiration = match exp {
Some(exp) => exp,
None => Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp(),
};
let claims = Claims {
iss: did.clone(),
@@ -205,7 +211,7 @@ fn create_signed_token_pinned(
aud: format!("did:web:{}", aud_hostname),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
lxm: None,
jti: jti.clone(),
act,
@@ -328,7 +334,7 @@ fn create_hs256_token_with_metadata(
),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
lxm: None,
jti: jti.clone(),
act: None,
+14 -2
View File
@@ -1,3 +1,5 @@
use crate::compress::decode_scope;
use super::types::{
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, UnsafeClaims,
@@ -164,9 +166,15 @@ pub fn verify_token_es256k(
.decode(claims_b64)
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
let claims: Claims = serde_json::from_slice(&claims_bytes)
let mut claims: Claims = serde_json::from_slice(&claims_bytes)
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
if let Some(scope) = &claims.scope {
claims.scope = Some(
decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?,
);
}
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(TokenVerifyError::Expired);
@@ -244,9 +252,13 @@ fn verify_token_hs256_internal(
.decode(claims_b64)
.context("Base64 decode of claims failed")?;
let claims: Claims =
let mut claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
if let Some(scope) = &claims.scope {
claims.scope = Some(decode_scope(scope).context("Invalid scope claim encoding")?);
}
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(anyhow!("Token expired"));
+1 -2
View File
@@ -5,12 +5,11 @@ edition.workspace = true
license.workspace = true
[features]
default = []
valkey = ["dep:redis"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
tranquil-infra = { workspace = true, features = ["cache-keys"] }
tranquil-ripple = { workspace = true }
async-trait = { workspace = true }
+4 -3
View File
@@ -1,4 +1,6 @@
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
pub use tranquil_infra::{
Cache, CacheError, DistributedRateLimiter, cache_keys, cached_json, read_json, write_json,
};
use async_trait::async_trait;
use std::sync::Arc;
@@ -173,11 +175,10 @@ pub async fn create_cache(
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
#[cfg(feature = "valkey")]
if backend == "valkey" {
if let Some(url) = valkey_url {
if let Some(url) = cache_cfg.and_then(|c| c.valkey_url.as_deref()) {
match ValkeyCache::new(url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
-1
View File
@@ -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 }
+126 -9
View File
@@ -1,25 +1,42 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use lettre::message::header::{ContentType, MIME_VERSION_1_0};
use lettre::message::header::{Header, HeaderName, HeaderValue};
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::QueuedComms;
use crate::types::{CommsType, QueuedComms};
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
pub(super) fn build(
from: &Mailbox,
qc: &QueuedComms,
apply_atmos_categories: bool,
) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
Message::builder()
let builder = Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN)
.header(MIME_VERSION_1_0)
.header(ContentType::TEXT_PLAIN);
let category = apply_atmos_categories
.then(|| atmos_category(qc.comms_type))
.flatten();
let builder = match category {
Some(category) => builder.header(category),
None => builder,
};
builder
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
@@ -34,10 +51,57 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
// for use with comail.at
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
enum AtmosCategory {
PasswordReset,
MfaOtp,
Verification,
}
impl AtmosCategory {
fn as_str(self) -> &'static str {
match self {
Self::PasswordReset => "password-reset",
Self::MfaOtp => "mfa-otp",
Self::Verification => "verification",
}
}
}
impl Header for AtmosCategory {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("X-Atmos-Category")
}
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
//since we're never receiving email, we don't care about parsing
Err("X-Atmos-Category is write-only".into())
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.as_str().to_string())
}
}
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
use CommsType::*;
match comms_type {
EmailVerification
| ChannelVerification
| ChannelVerified
| MigrationVerification
| LegacyLoginAlert
| EmailUpdate
| PlcOperation
| AccountDeletion
| Welcome => Some(AtmosCategory::Verification),
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
TwoFactorCode => Some(AtmosCategory::MfaOtp),
AdminEmail => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus, CommsType};
use crate::types::{CommsChannel, CommsStatus};
use chrono::Utc;
use uuid::Uuid;
@@ -71,6 +135,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -78,6 +143,7 @@ mod tests {
assert!(raw.contains("From: \"Test Sender\" <noreply@nel.pet>"));
assert!(raw.contains("To: user@nel.pet"));
assert!(raw.contains("Subject: Welcome"));
assert!(raw.contains("MIME-Version: 1.0"));
assert!(lower.contains("content-type: text/plain"));
assert!(raw.contains("Hello world."));
}
@@ -87,6 +153,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -99,6 +166,7 @@ mod tests {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
false,
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
@@ -108,6 +176,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
false,
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -123,7 +192,12 @@ mod tests {
#[test]
fn message_id_uses_from_domain() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
@@ -137,15 +211,58 @@ mod tests {
#[test]
fn missing_subject_uses_default() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", None, "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"),
false,
)
.unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
#[test]
fn atmos_category_header_present_when_enabled_and_mapped() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("X-Atmos-Category: password-reset"));
}
#[test]
fn atmos_category_header_absent_when_disabled() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, false).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
#[test]
fn atmos_category_header_absent_when_unmapped() {
let qc = QueuedComms {
comms_type: CommsType::AdminEmail,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
}
+55 -1
View File
@@ -124,6 +124,7 @@ fn build_smarthost(
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
})
}
@@ -176,6 +177,16 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
DkimSigner::load(selector, domain, path).map(Some)
}
fn wants_atmos_categories(mode: &SendMode) -> bool {
match mode {
SendMode::Smarthost {
apply_atmos_categories,
..
} => *apply_atmos_categories,
SendMode::DirectMx { .. } => false,
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
@@ -183,7 +194,8 @@ impl CommsSender for EmailSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
let mut message =
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
@@ -196,3 +208,45 @@ impl CommsSender for EmailSender {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lettre::Tokio1Executor;
use std::time::Duration;
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
let transport =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout: Duration::from_secs(10),
apply_atmos_categories,
}
}
fn dummy_direct_mx() -> SendMode {
SendMode::DirectMx {
resolver: Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
ResolverOpts::default(),
)),
helo: HeloName::parse("mta.nel.pet").unwrap(),
command_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(10),
require_tls: false,
inflight: Arc::new(Semaphore::new(1)),
}
}
#[tokio::test]
async fn smarthost_reflects_its_own_flag() {
assert!(wants_atmos_categories(&dummy_smarthost(true)));
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
}
#[test]
fn direct_mx_never_wants_atmos_categories() {
assert!(!wants_atmos_categories(&dummy_direct_mx()));
}
}
+11 -2
View File
@@ -19,6 +19,7 @@ pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
apply_atmos_categories: bool,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
@@ -33,8 +34,15 @@ pub enum SendMode {
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
Self::Smarthost {
total_timeout,
apply_atmos_categories,
..
} => {
write!(
f,
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
)
}
Self::DirectMx {
helo, require_tls, ..
@@ -52,6 +60,7 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
SendMode::Smarthost {
transport,
total_timeout,
..
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
@@ -53,6 +53,7 @@ fn build_smarthost_sender_with_total_timeout(
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
apply_atmos_categories: false,
},
None,
)
+2 -1
View File
@@ -5,5 +5,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
tranquil-types = { workspace = true }
confique = { workspace = true }
+60 -33
View File
@@ -2,6 +2,7 @@ use confique::Config;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
use tranquil_types::Domain;
static CONFIG: OnceLock<TranquilConfig> = OnceLock::new();
@@ -30,7 +31,6 @@ impl fmt::Display for ConfigError {
}
impl std::error::Error for ConfigError {}
/// Initialize the global configuration. Must be called once at startup before
/// any other code accesses the configuration. Panics if called more than once.
pub fn init(config: TranquilConfig) {
@@ -224,6 +224,12 @@ impl TranquilConfig {
}
}
if let Err(e) = Domain::new(self.server.hostname_without_port()) {
errors.push(format!(
"server.hostname (PDS_HOSTNAME) must be a plain domain, {e}"
));
}
// -- email -----------------------------------------------------------
self.email
.validate(self.server.hostname_without_port(), &mut errors);
@@ -428,7 +434,7 @@ pub struct ServerConfig {
pub hostname: String,
/// Address to bind the HTTP server to.
#[config(env = "SERVER_HOST", default = "127.0.0.1")]
#[config(env = "SERVER_HOST", default = "[::1]")]
pub host: String,
/// Port to bind the HTTP server to.
@@ -438,13 +444,25 @@ pub struct ServerConfig {
/// List of domains for user handles.
/// Defaults to the PDS hostname when not set.
#[config(env = "PDS_USER_HANDLE_DOMAINS", parse_env = split_comma_list)]
pub user_handle_domains: Option<Vec<String>>,
pub user_handle_domains: Option<Vec<Domain>>,
/// Enable PDS-hosted did:web identities. Hosting did:web requires a
/// long-term commitment to serve DID documents; opt-in only.
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
pub enable_pds_hosted_did_web: bool,
/// The caddy on-demand TLS requires we serve
/// the endpoint `/.well-known/caddy/ask`.
/// It will be used so that caddy can create TLS
/// certs for us on the fly
/// and we don't have to do annoying wildcard certs.
#[config(env = "ENABLE_CADDY_ON_DEMAND_TLS", default = true)]
pub enable_caddy_on_demand_tls: bool,
/// iykyk!
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
pub rfc_moo_compliance: bool,
/// When set to true, skip age-assurance birthday prompt for all accounts.
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
pub age_assurance_override: bool,
@@ -461,6 +479,10 @@ pub struct ServerConfig {
#[config(env = "DISABLE_RATE_LIMITING", default = false)]
pub disable_rate_limiting: bool,
/// Allow outbound fetches to private network addresses. Useful for local development using docker compose.
#[config(env = "ALLOW_PRIVATE_FETCH", default = false)]
pub allow_private_fetch: bool,
/// Skip the verified-comms-channel gate for login and record writes.
/// Please keep this off unless you're an invite-only PDS!
#[config(env = "DISABLE_ACCOUNT_VERIFICATION_GATE", default = false)]
@@ -565,20 +587,6 @@ impl ServerConfig {
pub fn banned_word_list(&self) -> Vec<String> {
self.banned_words.clone().unwrap_or_default()
}
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
pub fn user_handle_domain_list(&self) -> Vec<String> {
self.user_handle_domains
.as_deref()
.filter(|v| !v.is_empty())
.map(|v| v.to_vec())
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
}
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
pub fn available_user_domain_list(&self) -> Vec<String> {
self.user_handle_domain_list()
}
}
#[derive(Debug, Config)]
@@ -762,6 +770,10 @@ pub struct StorageConfig {
#[config(env = "S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
/// Path on the storage for the S3 blob backend.
#[config(env = "S3_PATH", default = "")]
pub s3_path: String,
/// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db.
/// tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS.
#[config(env = "REPO_BACKEND", default = "postgres")]
@@ -831,7 +843,7 @@ pub struct PlcConfig {
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
pub connect_timeout_secs: u64,
/// Seconds to cache DID documents in memory.
/// Seconds to cache DID documents.
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
pub did_cache_ttl_secs: u64,
}
@@ -853,18 +865,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)]
@@ -1111,6 +1132,10 @@ pub struct SmarthostConfig {
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
pub apply_atmos_categories: bool,
}
#[derive(Debug, Config)]
@@ -1463,12 +1488,13 @@ pub struct ImportConfig {
/// trimming whitespace and dropping empty entries.
///
/// Signature matches confique's `parse_env` expectation: `fn(&str) -> Result<T, E>`.
fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible> {
Ok(value
fn split_comma_list<T: std::str::FromStr>(value: &str) -> Result<Vec<T>, T::Err> {
value
.split(',')
.map(|item| item.trim().to_string())
.map(str::trim)
.filter(|item| !item.is_empty())
.collect())
.map(T::from_str)
.collect()
}
#[derive(Debug, Config)]
@@ -1972,6 +1998,7 @@ port = 587
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
apply_atmos_categories: false,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
+2 -2
View File
@@ -70,12 +70,12 @@ pub trait BlobRepository: Send + Sync {
takedown_ref: Option<&str>,
) -> Result<bool, DbError>;
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<bool, DbError>;
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError>;
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError>;
async fn ensure_blob_ownership(&self, user_id: Uuid, cid: &CidLink) -> Result<bool, DbError>;
async fn insert_record_blobs(
&self,
repo_id: Uuid,
+3 -1
View File
@@ -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>,
+29
View File
@@ -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),
}
+3 -7
View File
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::{CidLink, Did, Handle, InviteCode};
use tranquil_types::{Did, Handle, InviteCode};
use uuid::Uuid;
use crate::DbError;
@@ -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(
@@ -417,10 +417,6 @@ pub trait InfraRepository: Send + Sync {
async fn delete_server_config(&self, key: &str) -> Result<(), DbError>;
async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result<Option<String>, DbError>;
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError>;
async fn get_admin_account_info_by_did(
&self,
did: &Did,
+3 -3
View File
@@ -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,
@@ -36,8 +36,8 @@ pub use repo::{
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData,
EventBlockInline, EventBlocks, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
PruneCount, RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo,
RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository,
RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoIdentity, RepoInfo, RepoListItem,
RepoRepository, RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
UserWithoutBlocks,
};
pub use scope::{DbScope, InvalidScopeError};
+16 -1
View File
@@ -171,6 +171,12 @@ pub struct UserNeedingRecordBlobsBackfill {
pub did: Did,
}
#[derive(Debug, Clone)]
pub struct RepoIdentity {
pub user_id: Uuid,
pub did: Did,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoSeqEvent {
pub seq: SequenceNumber,
@@ -410,6 +416,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 +445,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>;
@@ -538,6 +551,8 @@ pub trait RepoRepository: Send + Sync {
limit: i64,
) -> Result<Vec<UserNeedingRecordBlobsBackfill>, DbError>;
async fn get_all_repo_identities(&self) -> Result<Vec<RepoIdentity>, DbError>;
async fn insert_record_blobs(
&self,
repo_id: Uuid,
+1 -2
View File
@@ -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)]
-2
View File
@@ -5,9 +5,7 @@ edition.workspace = true
license.workspace = true
[features]
default = ["postgres"]
postgres = []
sqlite = []
[dependencies]
tranquil-db-traits = { workspace = true }
+3 -1
View File
@@ -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> {
+63 -38
View File
@@ -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,
@@ -31,7 +33,7 @@ impl BlobRepository for PostgresBlobRepository {
let result = sqlx::query_scalar!(
r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (cid) DO NOTHING RETURNING cid"#,
ON CONFLICT (cid, created_by_user) DO NOTHING RETURNING cid"#,
cid.as_str(),
mime_type,
size_bytes,
@@ -42,12 +44,12 @@ 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> {
let result = sqlx::query!(
"SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1",
"SELECT storage_key, mime_type, size_bytes FROM blobs WHERE cid = $1 LIMIT 1",
cid.as_str()
)
.fetch_optional(&self.pool)
@@ -66,25 +68,31 @@ impl BlobRepository for PostgresBlobRepository {
cid: &CidLink,
) -> Result<Option<BlobWithTakedown>, DbError> {
let result = sqlx::query!(
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1 ORDER BY takedown_ref NULLS LAST LIMIT 1",
cid.as_str()
)
.fetch_optional(&self.pool)
.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> {
let result =
sqlx::query_scalar!("SELECT storage_key FROM blobs WHERE cid = $1", cid.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
let result = sqlx::query_scalar!(
"SELECT storage_key FROM blobs WHERE cid = $1 LIMIT 1",
cid.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result)
}
@@ -109,7 +117,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 +132,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> {
@@ -141,7 +149,8 @@ impl BlobRepository for PostgresBlobRepository {
async fn sum_blob_storage(&self) -> Result<i64, DbError> {
let result = sqlx::query_scalar!(
r#"SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as "total!" FROM blobs"#
r#"SELECT COALESCE(SUM(size_bytes), 0)::BIGINT as "total!"
FROM (SELECT DISTINCT cid, size_bytes FROM blobs) t"#
)
.fetch_one(&self.pool)
.await
@@ -167,15 +176,6 @@ impl BlobRepository for PostgresBlobRepository {
Ok(result.rows_affected() > 0)
}
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<bool, DbError> {
let result = sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn delete_blobs_by_user(&self, user_id: Uuid) -> Result<u64, DbError> {
let result = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
.execute(&self.pool)
@@ -187,7 +187,12 @@ impl BlobRepository for PostgresBlobRepository {
async fn get_blob_storage_keys_by_user(&self, user_id: Uuid) -> Result<Vec<String>, DbError> {
let results = sqlx::query_scalar!(
r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
r#"SELECT storage_key as "storage_key!" FROM blobs b
WHERE created_by_user = $1
AND NOT EXISTS (
SELECT 1 FROM blobs o
WHERE o.cid = b.cid AND o.created_by_user <> $1
)"#,
user_id
)
.fetch_all(&self.pool)
@@ -197,6 +202,22 @@ impl BlobRepository for PostgresBlobRepository {
Ok(results)
}
async fn ensure_blob_ownership(&self, user_id: Uuid, cid: &CidLink) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"INSERT INTO blobs (cid, mime_type, size_bytes, created_by_user, storage_key)
SELECT DISTINCT b.cid, b.mime_type, b.size_bytes, $1::uuid, b.storage_key
FROM blobs b WHERE b.cid = $2
ON CONFLICT (cid, created_by_user) DO NOTHING"#,
user_id,
cid.as_str()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn insert_record_blobs(
&self,
repo_id: Uuid,
@@ -232,7 +253,7 @@ impl BlobRepository for PostgresBlobRepository {
let results = sqlx::query!(
r#"SELECT rb.blob_cid, rb.record_uri
FROM record_blobs rb
LEFT JOIN blobs b ON rb.blob_cid = b.cid
LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = $1
WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2
ORDER BY rb.blob_cid
LIMIT $3"#,
@@ -244,13 +265,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 +300,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()
}
}
+55 -41
View File
@@ -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> {
+121 -107
View File
@@ -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::{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> {
@@ -992,25 +1010,6 @@ impl InfraRepository for PostgresInfraRepository {
Ok(())
}
async fn get_blob_storage_key_by_cid(&self, cid: &CidLink) -> Result<Option<String>, DbError> {
let result =
sqlx::query_scalar!("SELECT storage_key FROM blobs WHERE cid = $1", cid.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result)
}
async fn delete_blob_by_cid(&self, cid: &CidLink) -> Result<(), DbError> {
sqlx::query!("DELETE FROM blobs WHERE cid = $1", cid.as_str())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_admin_account_info_by_did(
&self,
did: &Did,
@@ -1027,16 +1026,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 +1061,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 +1110,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 +1210,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> {
+99
View File
@@ -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;
+45 -48
View File
@@ -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> {
+215 -99
View File
@@ -4,13 +4,16 @@ use sqlx::PgPool;
use tranquil_db_traits::{
AccountStatus, CommitEventData, DbError, EventBlockInline, EventBlocks, FullRecordInfo,
ImportBlock, ImportRecord, ImportRepoError, PruneCount, RecordInfo, RecordWithTakedown,
RepoAccountInfo, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev,
SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
RepoAccountInfo, RepoEventType, RepoIdentity, RepoInfo, RepoListItem, RepoRepository,
RepoWithoutRev, SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill,
UserWithoutBlocks,
};
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 +46,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 +60,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 +103,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 +288,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 +300,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 +319,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 +337,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 +431,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 +444,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 +475,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 +491,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 +515,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 +532,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 +554,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 +570,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 +592,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 +608,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 +627,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 +642,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 +658,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 +677,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 +722,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 +833,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 +1112,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 +1145,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 +1160,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 +1183,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 +1223,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 +1246,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 +1270,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 +1612,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 +1641,36 @@ 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 get_all_repo_identities(&self) -> Result<Vec<RepoIdentity>, DbError> {
let rows = sqlx::query!(
r#"
SELECT u.id as user_id, u.did
FROM users u
JOIN repos r ON r.user_id = u.id
"#
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
rows.into_iter()
.map(|r| {
Ok(RepoIdentity {
user_id: r.user_id,
did: column(r.did, col::USERS_DID)?,
})
})
.collect()
}
async fn insert_record_blobs(
+82 -60
View File
@@ -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> {
+287 -214
View File
@@ -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,
})
}
+7 -1
View File
@@ -4,10 +4,16 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
testing = []
cache-keys = ["dep:tranquil-types"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-types = { workspace = true, optional = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+103
View File
@@ -0,0 +1,103 @@
use tranquil_types::{
CidLink, ClientId, CrossPdsState, Did, EmailTokenPurpose, Handle, Jti, JwksUri, Nsid, PdsUrl,
SsoIssuer, SsoJwksUri,
};
pub fn session_key(did: &Did, jti: &Jti) -> String {
format!("auth:session:{}:{}", did, jti)
}
pub fn signing_key_key(did: &Did) -> String {
format!("auth:key:{}", did)
}
pub fn user_status_key(did: &Did) -> String {
format!("auth:status:{}", did)
}
pub fn handle_key(handle: &Handle) -> String {
format!("handle:{}", handle)
}
pub fn reauth_key(did: &Did) -> String {
format!("reauth:{}", did)
}
pub fn plc_doc_key(did: &Did) -> String {
format!("plc:doc:{}", did)
}
pub fn plc_data_key(did: &Did) -> String {
format!("plc:data:{}", did)
}
pub fn did_web_doc_key(did: &Did) -> String {
format!("did:web:doc:{}", did)
}
pub fn email_update_key(did: &Did) -> String {
format!("email_update:{}", did)
}
pub fn email_token_key(did: &Did, purpose: EmailTokenPurpose) -> String {
format!("email_token:{}:{}", purpose, did)
}
pub fn legacy_2fa_challenge_key(did: &Did) -> String {
format!("legacy_2fa:{}", did)
}
pub fn legacy_2fa_cooldown_key(did: &Did) -> String {
format!("legacy_2fa_cooldown:{}", did)
}
pub fn scope_ref_key(cid: &CidLink) -> String {
format!("scope_ref:{}", cid)
}
pub fn auto_verify_sent_key(did: &Did) -> String {
format!("auto_verify_sent:{}", did)
}
pub fn permission_set_key(nsid: &Nsid, aud: Option<&str>) -> String {
match aud {
Some(a) => format!("permset:{}:{}", nsid, a),
None => format!("permset:{}", nsid),
}
}
pub fn oauth_client_meta_key(client_id: &ClientId) -> String {
format!("oauth:client_meta:{}", client_id)
}
pub fn oauth_client_jwks_key(jwks_uri: &JwksUri) -> String {
format!("oauth:jwks:{}", jwks_uri.canonical())
}
pub fn oauth_client_jwks_cooldown_key(jwks_uri: &JwksUri) -> String {
format!("oauth:jwks_cooldown:{}", jwks_uri.canonical())
}
pub fn sso_jwks_key(jwks_uri: &SsoJwksUri) -> String {
format!("sso:jwks:{}", jwks_uri.canonical())
}
pub fn oidc_discovery_key(issuer: &SsoIssuer) -> String {
format!("oidc:discovery:{}", issuer.canonical())
}
pub fn cross_pds_state_key(state: &CrossPdsState) -> String {
format!("cross_pds_state:{}", state)
}
pub fn cross_pds_oauth_meta_key(pds_url: &PdsUrl) -> String {
format!("cross_pds_oauth_meta:v2:{}", pds_url.canonical())
}
pub fn lexicon_doc_key(nsid: &Nsid) -> String {
format!("lexicon:doc:{}", nsid)
}
pub fn lexicon_negative_key(nsid: &Nsid) -> String {
format!("lexicon:neg:{}", nsid)
}
+45
View File
@@ -1,6 +1,15 @@
#[cfg(feature = "cache-keys")]
pub mod cache_keys;
#[cfg(feature = "testing")]
mod memory_cache;
#[cfg(feature = "testing")]
pub use memory_cache::MemoryCache;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
@@ -57,6 +66,42 @@ pub trait Cache: Send + Sync {
}
}
pub async fn read_json<T: serde::de::DeserializeOwned>(cache: &dyn Cache, key: &str) -> Option<T> {
let json = cache.get(key).await?;
serde_json::from_str(&json).ok()
}
pub async fn write_json<T: serde::Serialize>(
cache: &dyn Cache,
key: &str,
value: &T,
ttl: Duration,
) {
if let Ok(json) = serde_json::to_string(value) {
let _ = cache.set(key, &json, ttl).await;
}
}
pub async fn cached_json<T, E, Fut>(
cache: &dyn Cache,
key: &str,
ttl: Duration,
fetch: impl FnOnce() -> Fut,
) -> Result<T, E>
where
T: serde::Serialize + serde::de::DeserializeOwned,
Fut: Future<Output = Result<T, E>>,
{
match read_json(cache, key).await {
Some(value) => Ok(value),
None => {
let value = fetch().await?;
write_json(cache, key, &value, ttl).await;
Ok(value)
}
}
}
#[async_trait]
pub trait DistributedRateLimiter: Send + Sync {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
+74
View File
@@ -0,0 +1,74 @@
use crate::{Cache, CacheError};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct Entry {
value: Vec<u8>,
expires_at: Instant,
}
#[derive(Default)]
pub struct MemoryCache {
entries: Mutex<HashMap<String, Entry>>,
}
impl MemoryCache {
pub fn new() -> Self {
Self::default()
}
fn read(&self, key: &str) -> Option<Vec<u8>> {
let now = Instant::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
match entries.get(key) {
Some(entry) if entry.expires_at > now => Some(entry.value.clone()),
Some(_) => {
entries.remove(key);
None
}
None => None,
}
}
fn write(&self, key: &str, value: Vec<u8>, ttl: Duration) {
let entry = Entry {
value,
expires_at: Instant::now() + ttl,
};
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.to_string(), entry);
}
}
#[async_trait]
impl Cache for MemoryCache {
async fn get(&self, key: &str) -> Option<String> {
self.read(key).and_then(|v| String::from_utf8(v).ok())
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
self.write(key, value.as_bytes().to_vec(), ttl);
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key);
Ok(())
}
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
self.read(key)
}
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
self.write(key, value.to_vec(), ttl);
Ok(())
}
}
+4 -4
View File
@@ -5,11 +5,11 @@ edition.workspace = true
license.workspace = true
[features]
default = []
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:tranquil-infra"]
[dependencies]
tranquil-types = { path = "../tranquil-types", default-features = false }
tranquil-types = { workspace = true }
tranquil-infra = { workspace = true, optional = true, features = ["cache-keys"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -20,9 +20,9 @@ hickory-resolver = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
parking_lot = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
tranquil-infra = { workspace = true, features = ["testing", "cache-keys"] }
+217 -31
View File
@@ -6,9 +6,11 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
use tranquil_infra::cache_keys::{lexicon_doc_key, lexicon_negative_key};
use tranquil_infra::{Cache, read_json, write_json};
use tranquil_types::Nsid;
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
@@ -17,6 +19,13 @@ struct NegativeEntry {
expires_at: Instant,
}
fn negative_ttl_for(error: &ResolveError) -> Duration {
match error.is_definitive() {
true => NEGATIVE_CACHE_TTL,
false => REFRESH_FAILURE_BACKOFF,
}
}
struct PositiveEntry {
doc: Arc<LexiconDoc>,
expires_at: Instant,
@@ -44,6 +53,7 @@ pub struct DynamicRegistry {
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
network_disabled: AtomicBool,
shared: RwLock<Option<Arc<dyn Cache>>>,
}
struct InFlightGuard<'a> {
@@ -70,9 +80,18 @@ impl DynamicRegistry {
negative_cache: RwLock::new(HashMap::new()),
in_flight: RwLock::new(HashMap::new()),
network_disabled: AtomicBool::new(false),
shared: RwLock::new(None),
}
}
pub fn set_shared_cache(&self, cache: Arc<dyn Cache>) {
*self.shared.write() = Some(cache);
}
fn shared_cache(&self) -> Option<Arc<dyn Cache>> {
self.shared.read().clone()
}
pub fn from_env() -> Self {
let registry = Self::new();
let disabled =
@@ -105,13 +124,17 @@ impl DynamicRegistry {
}
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
let cache = self.negative_cache.read();
cache
.get(nsid)
.is_some_and(|entry| entry.expires_at > Instant::now())
self.negative_remaining(nsid).is_some()
}
fn insert_negative(&self, nsid: &Nsid) {
fn negative_remaining(&self, nsid: &Nsid) -> Option<Duration> {
self.negative_cache
.read()
.get(nsid)
.and_then(|entry| entry.expires_at.checked_duration_since(Instant::now()))
}
fn insert_negative(&self, nsid: &Nsid, ttl: Duration) {
let mut cache = self.negative_cache.write();
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
let now = Instant::now();
@@ -120,7 +143,7 @@ impl DynamicRegistry {
cache.insert(
nsid.clone(),
NegativeEntry {
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
expires_at: Instant::now() + ttl,
},
);
}
@@ -159,6 +182,44 @@ impl DynamicRegistry {
arc
}
async fn shared_get(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
let cache = self.shared_cache()?;
let doc = read_json::<LexiconDoc>(cache.as_ref(), &lexicon_doc_key(nsid)).await?;
Some(self.insert_schema(doc))
}
async fn shared_put(&self, doc: &LexiconDoc) {
let Some(cache) = self.shared_cache() else {
return;
};
write_json(
cache.as_ref(),
&lexicon_doc_key(&doc.id),
doc,
POSITIVE_CACHE_TTL,
)
.await;
let _ = cache.delete(&lexicon_negative_key(&doc.id)).await;
}
async fn shared_is_negative(&self, nsid: &Nsid) -> bool {
match self.shared_cache() {
Some(cache) => cache.get(&lexicon_negative_key(nsid)).await.is_some(),
None => false,
}
}
async fn shared_put_negative(&self, nsid: &Nsid, error: &ResolveError) {
if !error.is_definitive() {
return;
}
if let Some(cache) = self.shared_cache() {
let _ = cache
.set(&lexicon_negative_key(nsid), "1", NEGATIVE_CACHE_TTL)
.await;
}
}
fn bump_expiry(&self, nsid: &Nsid, duration: Duration) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
@@ -203,15 +264,23 @@ impl DynamicRegistry {
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Err(e) => {
let (doc, source) = match self.shared_get(nsid).await {
Some(doc) => (doc, "shared"),
None => (stale, "local"),
};
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
tracing::warn!(
nsid = %nsid,
error = %e,
"lexicon refresh failed, serving stale cached entry"
source,
"lexicon refresh failed, serving cached entry"
);
Ok(stale)
Ok(doc)
}
},
None => {
@@ -230,34 +299,59 @@ impl DynamicRegistry {
F: FnOnce(Nsid) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
if let Some(doc) = self.shared_get(nsid).await {
return Ok(doc);
}
if self.is_negative_cached(nsid) {
if let Some(remaining) = self.negative_remaining(nsid) {
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
ttl_secs: remaining.as_secs(),
});
}
if self.shared_is_negative(nsid).await {
// Cache reports 0 remaining TTL for shared negative hit,
// so we mirror for the backoff rather than a full `NEGATIVE_CACHE_TTL`.
self.insert_negative(nsid, REFRESH_FAILURE_BACKOFF);
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: REFRESH_FAILURE_BACKOFF.as_secs(),
});
}
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
}
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Err(e) => {
self.insert_negative(nsid);
tracing::debug!(nsid = %nsid, error = %e, "caching negative resolution result");
let ttl = negative_ttl_for(&e);
self.insert_negative(nsid, ttl);
self.shared_put_negative(nsid, &e).await;
tracing::debug!(
nsid = %nsid,
error = %e,
ttl_secs = ttl.as_secs(),
"caching negative resolution result"
);
Err(e)
}
},
None => {
self.wait_for_leader(nsid).await;
match self.get_cached(nsid) {
Some(doc) => Ok(doc),
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
(Some(doc), _) => Ok(doc),
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
ttl_secs: remaining.as_secs(),
}),
None => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
}
}
}
@@ -316,6 +410,7 @@ impl Default for DynamicRegistry {
#[cfg(test)]
mod tests {
use super::*;
use tranquil_infra::MemoryCache;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
@@ -324,19 +419,19 @@ mod tests {
#[test]
fn test_negative_cache() {
let registry = DynamicRegistry::new();
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
registry.insert_negative(&nsid("com.example.test"));
assert!(registry.is_negative_cached(&nsid("com.example.test")));
registry.insert_negative(&nsid("pet.nel.negative"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.negative")));
}
#[tokio::test]
async fn test_negative_cache_returns_appropriate_error_variant() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("com.example.cached"));
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
let err = registry
.resolve_and_cache(&nsid("com.example.cached"))
.resolve_and_cache(&nsid("pet.nel.cached"))
.await
.unwrap_err();
@@ -383,17 +478,17 @@ mod tests {
fn test_negative_cache_cleared_on_insert() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("com.example.test"));
assert!(registry.is_negative_cached(&nsid("com.example.test")));
registry.insert_negative(&nsid("pet.nel.cleared"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.cleared")));
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.test"),
id: nsid("pet.nel.cleared"),
defs: HashMap::new(),
};
registry.insert_schema(doc);
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
}
#[test]
@@ -692,4 +787,95 @@ mod tests {
"evicted Arc should be freed when no external references remain"
);
}
#[tokio::test]
async fn test_shared_positive_hit_skips_resolver() {
let registry = DynamicRegistry::new();
let cache = Arc::new(MemoryCache::new());
registry.set_shared_cache(cache.clone());
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.sharedDoc"),
defs: HashMap::new(),
};
cache
.set(
&lexicon_doc_key(&nsid("pet.nel.sharedDoc")),
&serde_json::to_string(&doc).unwrap(),
POSITIVE_CACHE_TTL,
)
.await
.unwrap();
let resolved = registry
.resolve_and_cache_with(&nsid("pet.nel.sharedDoc"), |_| async move {
panic!("resolver mustn't run on a shared positive hit")
})
.await
.unwrap();
assert_eq!(resolved.id, "pet.nel.sharedDoc");
assert!(registry.get_cached(&nsid("pet.nel.sharedDoc")).is_some());
}
#[tokio::test]
async fn test_definitive_failure_writes_shared_negative_and_peers_mirror_it() {
let cache = Arc::new(MemoryCache::new());
let registry = DynamicRegistry::new();
registry.set_shared_cache(cache.clone());
let _ = registry
.resolve_and_cache_with(&nsid("pet.nel.gone"), |n| async move {
Err::<LexiconDoc, _>(ResolveError::SchemaNotFound {
nsid: n,
url: "https://oyster.cafe".to_string(),
})
})
.await;
assert!(
cache
.get(&lexicon_negative_key(&nsid("pet.nel.gone")))
.await
.is_some(),
"definitive failure must write the shared negative key"
);
let _ = registry
.resolve_and_cache_with(&nsid("pet.nel.transient"), |n| async move {
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n.into_inner(),
reason: "simulated".to_string(),
})
})
.await;
assert!(
cache
.get(&lexicon_negative_key(&nsid("pet.nel.transient")))
.await
.is_none(),
"transient failure must stay out of the shared negative key"
);
let peer = DynamicRegistry::new();
peer.set_shared_cache(cache);
let err = peer
.resolve_and_cache_with(&nsid("pet.nel.gone"), |_| async move {
panic!("resolver mustn't run on a shared negative hit")
})
.await
.unwrap_err();
match err {
ResolveError::NegativelyCached { ttl_secs, .. } => assert!(
ttl_secs <= REFRESH_FAILURE_BACKOFF.as_secs(),
"local mirror must use the backoff TTL, got {}s",
ttl_secs
),
other => panic!("expected NegativelyCached, got: {}", other),
}
assert!(
peer.negative_remaining(&nsid("pet.nel.gone"))
.expect("local mirror exists")
<= REFRESH_FAILURE_BACKOFF
);
}
}
+47 -8
View File
@@ -34,15 +34,28 @@ pub fn is_valid_datetime(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}
/// Checks the scheme only, not the character set or structure of what
/// follows. The aim is to accept at least all valid URIs; we can always
/// tighten this later. It does not parse the authority, because at-uris
/// put colons in the authority (at://did:plc:abc123/collection/rkey) and
/// any 3986 authority parser reads that as a non-numeric port and rejects
/// it.
pub fn is_valid_uri(s: &str) -> bool {
s.split_once("://").is_some_and(|(scheme, rest)| {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& !rest.is_empty()
})
let Some((scheme, rest)) = s.split_once(':') else {
return false;
};
let valid_scheme = !scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_');
if !valid_scheme {
return false;
}
match rest.strip_prefix("//") {
Some(authority_and_path) => !authority_and_path.is_empty(),
None => true,
}
}
pub fn is_valid_cid(s: &str) -> bool {
@@ -151,6 +164,32 @@ mod tests {
assert!(!is_valid_uri("https://"));
}
#[test]
fn test_valid_uris_without_authority() {
// RFC 3986 hier-part doesn't require "//": scheme ":" opaque-part is also a URI.
assert!(is_valid_uri("urn:isbn:9780141439518"));
assert!(is_valid_uri("mailto:user@example.com"));
assert!(is_valid_uri("mbid:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5")); //authority-less / path-rootless
assert!(is_valid_uri(
"has_an_underscore:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5"
));
assert!(is_valid_uri("urn:"));
}
#[test]
fn test_invalid_uri_without_scheme() {
assert!(!is_valid_uri(":no-scheme"));
}
#[test]
fn test_valid_uris_dont_reject_at_uri_authority_colons() {
// at-uri authorities contain colons (did:plc:...); is_valid_uri must not
// reject them the way a strict RFC 3986 authority parser would.
assert!(is_valid_uri(
"at://did:plc:cwdkf4xxjpznceembuuspt3d/sh.tangled.repo.pull/3mtjn7zouwn22"
));
}
#[test]
fn test_valid_cids() {
assert!(is_valid_cid("bafyreiabcdef123456"));
+5
View File
@@ -125,6 +125,11 @@ impl LexiconRegistry {
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
self.dynamic.is_negative_cached(nsid)
}
#[cfg(feature = "resolve")]
pub fn set_shared_cache(&self, cache: Arc<dyn tranquil_infra::Cache>) {
self.dynamic.set_shared_cache(cache);
}
}
pub struct ResolvedRef {
+95 -82
View File
@@ -4,7 +4,10 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use std::sync::OnceLock;
use std::time::Duration;
use tranquil_types::{Did, Nsid};
use tranquil_types::did_doc::extract_pds_endpoint;
use tranquil_types::{
Did, Nsid, SchemaHostUrl, UrlKind, dns_guard, redirect_policy, url_kind, url_reach_permits,
};
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
@@ -17,7 +20,8 @@ fn client() -> &'static Client {
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(4)
.pool_idle_timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::limited(3))
.redirect(redirect_policy(url_kind::SchemaHost::REACH_POLICY))
.dns_resolver(dns_guard(url_kind::SchemaHost::REACH_POLICY))
.build()
.expect("failed to build lexicon resolver HTTP client")
})
@@ -63,6 +67,8 @@ pub enum ResolveError {
NoPdsEndpoint { did: Did },
#[error("schema fetch failed from {url}: {reason}")]
SchemaFetch { url: String, reason: String },
#[error("no schema record for {nsid} at {url}")]
SchemaNotFound { nsid: Nsid, url: String },
#[error("schema deserialization failed: {0}")]
InvalidSchema(String),
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
@@ -73,6 +79,23 @@ pub enum ResolveError {
LeaderAborted { nsid: Nsid },
}
impl ResolveError {
pub fn is_definitive(&self) -> bool {
match self {
Self::NoDid { .. }
| Self::NoPdsEndpoint { .. }
| Self::InvalidSchema(_)
| Self::SchemaNotFound { .. } => true,
Self::DnsLookup { .. }
| Self::DidResolution { .. }
| Self::SchemaFetch { .. }
| Self::NegativelyCached { .. }
| Self::NetworkDisabled
| Self::LeaderAborted { .. } => false,
}
}
}
pub fn nsid_to_authority(nsid: &Nsid) -> String {
let mut segments: Vec<&str> = nsid.split('.').collect();
segments.pop();
@@ -123,7 +146,7 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError>
pub async fn resolve_pds_endpoint(
did: &Did,
plc_directory_url: Option<&str>,
) -> Result<String, ResolveError> {
) -> Result<SchemaHostUrl, ResolveError> {
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
let url = match did
@@ -131,7 +154,20 @@ pub async fn resolve_pds_endpoint(
.and_then(|(_, rest)| rest.split_once(':'))
{
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
Some(("web", domain)) => {
let url = format!("https://{}/.well-known/did.json", domain);
let permitted = reqwest::Url::parse(&url)
.is_ok_and(|u| url_reach_permits(&u, url_kind::SchemaHost::REACH_POLICY));
match permitted {
true => url,
false => {
return Err(ResolveError::DidResolution {
did: did.clone(),
reason: "did:web host is outside the allowed host reach".to_string(),
});
}
}
}
_ => {
return Err(ResolveError::DidResolution {
did: did.clone(),
@@ -162,39 +198,29 @@ pub async fn resolve_pds_endpoint(
reason: e.to_string(),
})?;
extract_pds_endpoint(&doc).ok_or_else(|| ResolveError::NoPdsEndpoint { did: did.clone() })
extract_pds_endpoint(&doc).map_err(|_| ResolveError::NoPdsEndpoint { did: did.clone() })
}
fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
doc.get("service")
.and_then(|s| s.as_array())
.and_then(|services| {
services.iter().find_map(|svc| {
let is_pds = svc
.get("type")
.and_then(|t| t.as_str())
.is_some_and(|t| t == "AtprotoPersonalDataServer");
is_pds
.then(|| svc.get("serviceEndpoint").and_then(|ep| ep.as_str()))?
.map(|s| s.to_string())
})
})
fn is_record_absent(xrpc_error: &str, xrpc_message: &str) -> bool {
xrpc_error == "RecordNotFound"
|| xrpc_error == "InvalidRequest" && xrpc_message.starts_with("Could not locate record")
}
pub async fn fetch_schema_from_pds(
pds_endpoint: &str,
pds_endpoint: &SchemaHostUrl,
did: &Did,
nsid: &Nsid,
) -> Result<LexiconDoc, ResolveError> {
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
pds_endpoint.trim_end_matches('/'),
urlencoding::encode(did.as_str()),
urlencoding::encode(nsid.as_str())
);
let mut request_url = pds_endpoint.endpoint("xrpc/com.atproto.repo.getRecord");
request_url
.query_pairs_mut()
.append_pair("repo", did.as_str())
.append_pair("collection", "com.atproto.lexicon.schema")
.append_pair("rkey", nsid.as_str());
let url = request_url.to_string();
let resp = client()
.get(&url)
.get(request_url)
.send()
.await
.map_err(|e| ResolveError::SchemaFetch {
@@ -204,10 +230,27 @@ pub async fn fetch_schema_from_pds(
let status = resp.status();
if !status.is_success() {
return Err(ResolveError::SchemaFetch {
url,
reason: format!("HTTP {}", status),
});
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
.await
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or(serde_json::Value::Null);
let field = |name: &str| {
body.get(name)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
return match is_record_absent(&field("error"), &field("message")) {
true => Err(ResolveError::SchemaNotFound {
nsid: nsid.clone(),
url,
}),
false => Err(ResolveError::SchemaFetch {
url,
reason: format!("HTTP {}", status),
}),
};
}
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
@@ -292,6 +335,27 @@ mod tests {
s.parse().unwrap()
}
#[test]
fn is_record_absent_recognizes_only_the_reference_pds_absence_shapes() {
assert!(is_record_absent(
"RecordNotFound",
"Could not locate record: at://did:plc:nel/com.atproto.lexicon.schema/x"
));
assert!(is_record_absent("RecordNotFound", ""));
assert!(is_record_absent(
"InvalidRequest",
"Could not locate record"
));
assert!(!is_record_absent(
"InvalidRequest",
"Error: rkey must be a valid record key"
));
assert!(!is_record_absent("InvalidRequest", ""));
assert!(!is_record_absent("InternalServerError", ""));
assert!(!is_record_absent("RateLimitExceeded", ""));
assert!(!is_record_absent("", ""));
}
#[test]
fn test_nsid_to_authority() {
assert_eq!(
@@ -316,57 +380,6 @@ mod tests {
);
}
#[test]
fn test_extract_pds_endpoint_valid() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_multiple_services() {
let doc = serde_json::json!({
"service": [
{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_missing() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
}]
});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_extract_pds_endpoint_no_services() {
let doc = serde_json::json!({});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_validate_fetched_schema_ok() {
let doc = LexiconDoc {
+15 -15
View File
@@ -1,8 +1,8 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tranquil_types::Nsid;
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexiconDoc {
pub lexicon: u32,
pub id: Nsid,
@@ -10,7 +10,7 @@ pub struct LexiconDoc {
pub defs: HashMap<String, LexDef>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LexDef {
#[serde(rename = "record")]
@@ -35,14 +35,14 @@ pub enum LexDef {
PermissionSet {},
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexRecord {
#[serde(default)]
pub key: Option<String>,
pub record: LexObject,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexObject {
#[serde(default)]
pub required: Vec<String>,
@@ -52,7 +52,7 @@ pub struct LexObject {
pub properties: HashMap<String, LexProperty>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LexProperty {
#[serde(rename = "string")]
@@ -79,7 +79,7 @@ pub enum LexProperty {
Object(LexObject),
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexString {
#[serde(default)]
@@ -102,7 +102,7 @@ pub struct LexString {
pub default: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexInteger {
#[serde(default)]
pub minimum: Option<i64>,
@@ -116,7 +116,7 @@ pub struct LexInteger {
pub const_value: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBytes {
#[serde(default)]
@@ -125,7 +125,7 @@ pub struct LexBytes {
pub min_length: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBlob {
#[serde(default)]
@@ -134,7 +134,7 @@ pub struct LexBlob {
pub max_size: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexArray {
pub items: Box<LexProperty>,
@@ -144,7 +144,7 @@ pub struct LexArray {
pub max_length: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexUnion {
#[serde(default)]
pub refs: Vec<String>,
@@ -152,14 +152,14 @@ pub struct LexUnion {
pub closed: bool,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexRef {
#[serde(rename = "ref")]
pub reference: String,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StringFormat {
#[serde(rename = "did")]
Did,
@@ -204,6 +204,6 @@ pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexStringDef {}
@@ -74,7 +74,7 @@ async fn test_resolve_pds_endpoint_from_plc() {
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
assert_eq!(endpoint.as_str(), "https://pds.example.com");
}
#[tokio::test]
@@ -130,14 +130,17 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
"id": did,
"service": [
{
"id": "#atproto_labeler",
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"id": "#bsky_notif",
"type": "BskyNotificationService",
"serviceEndpoint": "https://notify.example.com"
},
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
@@ -149,7 +152,7 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
assert_eq!(endpoint.as_str(), "https://pds.example.com");
}
#[tokio::test]
@@ -168,7 +171,7 @@ async fn test_fetch_schema_from_pds_success() {
.await;
let doc = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -195,7 +198,7 @@ async fn test_fetch_schema_missing_value_field() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -222,7 +225,7 @@ async fn test_fetch_schema_invalid_lexicon_json() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -352,7 +355,7 @@ async fn test_pds_trailing_slash_handled() {
let pds_url_with_slash = format!("{}/", pds_server.uri());
let doc = fetch_schema_from_pds(
&pds_url_with_slash,
&pds_url_with_slash.parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -377,7 +380,7 @@ async fn test_fetch_schema_error_status_gives_meaningful_error() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
+8
View File
@@ -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,10 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tranquil-infra = { workspace = true, features = ["testing"] }
[features]
bsky = []
@@ -1,4 +1,6 @@
use super::*;
use tranquil_scopes::{ParsedScope, parse_scope};
use tranquil_types::Nsid;
#[derive(Debug, Serialize)]
pub struct ScopeInfo {
@@ -8,6 +10,44 @@ pub struct ScopeInfo {
pub description: String,
pub display_name: String,
pub granted: Option<bool>,
pub restricted: bool,
pub superseded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_scope: Option<String>,
}
#[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,
pub superseded: 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)]
pub struct RejectedScopeInfo {
// The scope exactly as the client requested it, which may be invalid or malformed.
pub scope: String,
pub reason: tranquil_scopes::ScopeRejection,
}
#[derive(Debug, Serialize)]
@@ -18,6 +58,10 @@ pub struct ConsentResponse {
pub client_uri: Option<String>,
pub logo_uri: Option<String>,
pub scopes: Vec<ScopeInfo>,
pub permission_sets: Vec<PermissionSetInfo>,
pub transition_supersedes: bool,
pub failed_sets: Vec<FailedSetInfo>,
pub rejected_scopes: Vec<RejectedScopeInfo>,
pub show_consent: bool,
pub did: Did,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -88,7 +132,7 @@ pub async fn consent_get(
};
let did = flow_with_user.did().clone();
let client_cache = ClientMetadataCache::new(3600);
let client_cache = &state.client_metadata_cache;
let client_metadata = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -116,26 +160,18 @@ 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,
client_metadata.as_ref().and_then(|m| m.scope.as_deref()),
)
.await;
let requested_scopes: Vec<&str> = effective.permitted.split_whitespace().collect();
let preferences = state
.repos
.oauth
@@ -146,30 +182,45 @@ 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 = effective.outcome.unexpanded_scopes();
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
let has_transition_generic = 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 {
.any(|s| matches!(parse_scope(s), ParsedScope::TransitionGeneric));
let grant_scope_str: Option<&str> =
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
let coverage_of = |scope: &str| -> tranquil_pds::delegation::GrantCoverage {
match grant_scope_str {
Some(g) => tranquil_pds::delegation::grant_coverage(g, scope),
None => tranquil_pds::delegation::GrantCoverage::Full,
}
};
let make_scope_info = |scope: &str| -> ScopeInfo {
let (restricted, effective_scope) = match coverage_of(scope) {
tranquil_pds::delegation::GrantCoverage::Full => (false, None),
tranquil_pds::delegation::GrantCoverage::Narrowed(narrowed) => (false, Some(narrowed)),
tranquil_pds::delegation::GrantCoverage::Withheld => (true, None),
};
let described = effective_scope.as_deref().unwrap_or(scope);
let (category, required, description, display_name) =
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(described) {
let desc = if described == "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 described == "atproto" && has_granular_scopes {
"AT Protocol Access".to_string()
} else {
def.display_name.to_string()
@@ -180,33 +231,88 @@ pub async fn consent_get(
desc,
name,
)
} else if scope.starts_with("ref:") {
} else if described.starts_with("ref:") {
(
"Reference".to_string(),
false,
"Referenced scope".to_string(),
scope.to_string(),
described.to_string(),
)
} else {
(
"Other".to_string(),
false,
format!("Access to {}", scope),
scope.to_string(),
format!("Access to {}", described),
described.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();
let superseded = has_transition_generic
&& tranquil_scopes::superseded_by_transition_generic(&parse_scope(scope));
ScopeInfo {
scope: scope.to_string(),
category,
required,
description,
display_name,
granted,
restricted,
superseded,
effective_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 = g.include_token();
let expanded: Vec<ScopeInfo> = g.expanded.iter().map(|s| make_scope_info(s)).collect();
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
let superseded = !expanded.is_empty() && expanded.iter().all(|s| s.superseded);
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,
superseded,
}
})
.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 rejected_scopes: Vec<RejectedScopeInfo> = effective
.outcome
.rejected
.iter()
.map(|r| RejectedScopeInfo {
scope: r.scope.clone(),
reason: r.reason,
})
.collect();
let account_handle = state
.repos
.user
@@ -248,6 +354,9 @@ pub async fn consent_get(
(None, None, None, None)
};
let transition_supersedes =
scopes.iter().any(|s| s.superseded) || permission_sets.iter().any(|s| s.superseded);
Json(ConsentResponse {
request_uri: query.request_uri.clone(),
client_id: request_data.parameters.client_id.clone(),
@@ -255,6 +364,10 @@ 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,
transition_supersedes,
failed_sets,
rejected_scopes,
show_consent,
did: did.clone(),
handle: account_handle,
@@ -342,16 +455,51 @@ 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 client_scope = state
.client_metadata_cache
.get(&request_data.parameters.client_id)
.await
.ok()
.and_then(|m| m.scope);
let effective = scope_resolution::resolve_effective_scopes(
&*state.cache,
original_scope_str,
authority,
client_scope.as_deref(),
)
.await;
let include_token = |nsid: &str, aud: &Option<String>| -> String {
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 = effective.outcome.unexpanded_scopes();
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 +507,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,
@@ -368,20 +517,12 @@ pub async fn consent_post(
);
}
let approved_scope_str = final_approved.join(" ");
let has_valid_scope = final_approved.iter().all(|s| is_valid_scope(s));
if !has_valid_scope {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid scope format",
);
}
if form.remember {
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
@@ -80,7 +80,7 @@ pub async fn authorize_get(
"Authorization request has expired. Please start a new request.",
);
}
let client_cache = ClientMetadataCache::new(3600);
let client_cache = &state.client_metadata_cache;
let client_name = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -14,8 +14,7 @@ use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
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,
AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
};
use tranquil_pds::rate_limit::{
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
@@ -78,15 +77,6 @@ fn is_granular_scope(s: &str) -> bool {
|| s.starts_with("identity:")
}
fn is_valid_scope(s: &str) -> bool {
s == "atproto"
|| s == "transition:generic"
|| s == "transition:chat.bsky"
|| s == "transition:email"
|| is_granular_scope(s)
|| s.starts_with("include:")
}
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
headers
.get("cookie")
@@ -300,6 +290,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 (

Some files were not shown because too many files have changed in this diff Show More