Compare commits

...
27 Commits
Author SHA1 Message Date
Lewis 8d0e0c6810 feat(tranquil-store): whole test suite working
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:44 +03:00
Lewis 1720de8650 feat(tranquil-store): repository traits on MetastoreClient
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis a5c68a3506 feat(tranquil-store): metastore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis 9ea33def13 feat(tranquil-store): eventlog
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
Lewis a9e3ec63e1 feat(tranquil-store): blockstore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
HigherOrderLogicandTangled dbc3c23c95 fix(nix): allow fetch Git deps 2026-04-09 21:30:03 +00:00
nekomimi.petandTangled 531ca7c1c0 fix(lexicon): wildcard mime type handling and case insensitivity 2026-04-09 21:28:37 +00:00
nelindandTangled add1ff176b fix(auth): keep lxm optional and dont default to * when no lxm is given in getServiceAuth 2026-04-04 08:52:42 +00:00
nelind bdb200a30c fix(ci): dont run it at all for now. it doesnt work with the currently spindles 2026-04-01 23:45:03 +02:00
nelind e5faba3e73 fix(ci): define CI event run correctly so it actually runs 2026-04-01 23:38:19 +02:00
IslaandTangled 5dc810ceef Further harden systemd service 2026-03-31 22:25:11 +00:00
juneandTangled 6abc0eaca2 feat(plc): support AtprotoLabeler service 2026-03-28 20:54:43 +00:00
nelindandTangled 696940f732 fix(sync): dont keep websockets arround indefinetly if client abruptly closes connection 2026-03-25 16:47:38 +00:00
nelindandTangled f777c288da fix(pds): do service identifier resolution for proxying correctly 2026-03-23 03:00:57 +00:00
teqandTangled 9039648705 fix(nix): generate-lockfile for fix/signal-nix-build-deps 2026-03-23 03:00:23 +00:00
teqandTangled 770a16b043 fix(nix): add hashes for git deps 2026-03-23 03:00:23 +00:00
teqandTangled d37f38e496 fix(nix): add protobuf for spqr 2026-03-23 03:00:23 +00:00
Lewis 282f08f207 feat(rfc): outline tranquil-store idea for comment
Lewis: May this revision serve well! <lu5a@proton.me>
2026-03-22 18:54:51 +02:00
LewisandTangled f07bcb82f7 refactor(sync): extract MST parsing and serialization utilities 2026-03-22 12:51:44 +00:00
LewisandTangled e38343ce09 refactor(oauth): split authorize.rs into domain modules 2026-03-22 12:51:44 +00:00
LewisandTangled e454e99b8a refactor(api): migrate all endpoints to repos accessor pattern 2026-03-22 12:51:44 +00:00
LewisandTangled 19b0ea19c6 refactor(tranquil-pds): consolidate repos, rate limits, validation, and auth helpers 2026-03-22 12:51:44 +00:00
LewisandTangled 318818ad51 feat(signal): add admin UI, frontend, and build changes 2026-03-22 07:14:20 +00:00
LewisandTangled 7c55a5ceb9 feat(signal): add admin endpoints, config, and server wiring 2026-03-22 07:14:20 +00:00
LewisandTangled 9d31ee9ace refactor(comms): extract HTTP retry combinator and integrate signal sender 2026-03-22 07:14:20 +00:00
LewisandTangled e3efcb43ba test(signal): add protocol store integration tests 2026-03-22 07:14:20 +00:00
LewisandTangled eb46824220 feat(signal): add presage client, newtypes, and slot management 2026-03-22 07:14:20 +00:00
249 changed files with 67081 additions and 7662 deletions
+8
View File
@@ -29,6 +29,10 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -53,6 +57,10 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cid FROM blocks ORDER BY created_at ASC LIMIT $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)\n WHERE NOT EXISTS (\n SELECT 1 FROM user_blocks WHERE block_cid = t.cid\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"ByteaArray"
]
},
"nullable": [
null
]
},
"hash": "18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET is_admin = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "1e63d287c619a14e5c07d80e8e54193d2964c8b5e6a855256cb80c4d0cd2c6ea"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.expires_at\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "237c2d912e89b7e0e5baa83503a22f158ea1614b5157f6c9e2aba6017fef6b26"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO backlinks (uri, path, link_to, repo_id)\n SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4\n ON CONFLICT (uri, path) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"TextArray",
"Uuid"
]
},
"nullable": []
},
"hash": "47149c0577ad9e9b9b089820b0c93417769a4a37affe0e3972e324ec27ec532f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "5cee16f49a727d66b5231a8d07d7f4bcb6a1136fbf3e3d249fd33600772ac80f"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, did, expires_at FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "61f489b4fc42f5b0aaea287cde4415da6f5e96b3a0f36216bdc6dea924b09abd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, 1, TRUE, NOW())\n ON CONFLICT (did) DO UPDATE SET secret_encrypted = $2, verified = TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "7d3a9f0545943bc6a3a14fcd596aac5cc731c8177d74e504606d7e92c7d0c73f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET two_factor_enabled = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "84a1db51a98402323cb86bc19cd2b737f908222ea3426b8bf47d735aff5b6c75"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
]
},
"nullable": []
},
"hash": "89b0292d8d022fad8f9cda07b9a7870ca6a7ebe904b2d580956b0816b50bcdb7"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM blocks WHERE cid = ANY($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"ByteaArray"
]
},
"nullable": []
},
"hash": "8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\"\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "990bf50e60fc5566639c2c12cd968d154d7b0c6863ad69141653135f98fbc998"
}
@@ -0,0 +1,180 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n FROM comms_queue\n WHERE user_id = $1 AND comms_type = $2\n ORDER BY created_at DESC\n LIMIT $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 3,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
},
{
"ordinal": 4,
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
"kind": {
"Enum": [
"pending",
"processing",
"sent",
"failed"
]
}
}
}
},
{
"ordinal": 5,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "attempts",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "max_attempts",
"type_info": "Int4"
},
{
"ordinal": 11,
"name": "last_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 14,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 15,
"name": "processed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
},
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "9f3f2b36f11e9446915d3ca29ef81e4ada0c6a6d72764116dac4f99a4e09785e"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
{
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification",
"channel_verified"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "b364a2b202bab17c0cdc5f70d23b13841b4d9063d94cd0b09268c3dc41824fd2"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM backlinks WHERE uri = ANY($1::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": []
},
"hash": "cffe4c37fe949fbdc3d5cd83ccec5655aae248a0a69dc260d1da9cf1d9ed2c49"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at\n FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "public_key_did_key",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "private_key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false,
false,
false,
true
]
},
"hash": "f3b07f153284b6dd1f22c098af8628d85dcdf20dd6273443ff98d02b6f5ecbf1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382"
}
+4 -4
View File
@@ -1,6 +1,6 @@
when:
- event: ["push"]
branches: main
- event: []
branch: []
engine: nixery
@@ -20,5 +20,5 @@ steps:
- name: Publish image
command: |
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil-pds/tranquil:latest"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil-pds/tranquil:$TANGLED_COMMIT_SHA"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
Generated
+1201 -613
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -22,10 +22,11 @@ members = [
"crates/tranquil-api",
"crates/tranquil-lexicon",
"crates/tranquil-signal",
"crates/tranquil-store",
]
[workspace.package]
version = "0.4.7"
version = "0.5.0"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -50,7 +51,8 @@ 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" }
tranquil-signal = { path = "crates/tranquil-signal" }
tranquil-signal = { path = "crates/tranquil-signal", features = ["fjall-store"] }
tranquil-store = { path = "crates/tranquil-store" }
presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844ae51c3a9fa49cf80a7816a31a425", default-features = false }
@@ -141,3 +143,8 @@ curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag
lto = "fat"
strip = true
codegen-units = 1
panic = "abort"
[profile.bench]
debug = 1
strip = false
+5 -13
View File
@@ -4,7 +4,7 @@ COPY frontend/ ./
RUN deno task build
FROM rust:1.92-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
WORKDIR /app
ARG SLIM="false"
@@ -29,6 +29,8 @@ COPY crates/tranquil-pds ./crates/tranquil-pds
COPY crates/tranquil-sync ./crates/tranquil-sync
COPY crates/tranquil-api ./crates/tranquil-api
COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
COPY crates/tranquil-store ./crates/tranquil-store
COPY crates/tranquil-signal ./crates/tranquil-signal
COPY crates/tranquil-server ./crates/tranquil-server
COPY migrations ./crates/tranquil-pds/migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
@@ -40,23 +42,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
fi && \
cp target/release/tranquil-server /tmp/tranquil-pds
FROM alpine:3.23 AS signal-cli
RUN apk add --no-cache curl tar
ARG SIGNAL_CLI_VERSION=0.13.24
RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar xz -C /usr/local/bin
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
FROM alpine:3.23
RUN apk add --no-cache msmtp ca-certificates \
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli
VOLUME /var/lib/signal-cli
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
COPY migrations /app/migrations
WORKDIR /app
ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli
ENV SERVER_HOST=0.0.0.0
ENV SERVER_PORT=3000
EXPOSE 3000
+169
View File
@@ -0,0 +1,169 @@
tranquil-store: embedded storage engine for Tranquil PDS
RFC draft, 2026-03-22
By Lewis!
-- TLDR --
Add an embedded storage engine as an alternative to postgres (and leapfrog SQLite-per-actor)
that treats Tranquil's 3 types of storage workloads as 3 separate problems:
- BlockStore: bitcask-esque append log for immutable CID-keyed blocks [4]
- MetaStore: Fjall LSM keyspaces for mutable metadata [5]
- EventLog: segmented append log for the firehose
Group commit across users, content dedup, sub-ms firehose delivery.
We will use deterministic simulation testing [18][19].
Postgres will of course stay as the existing alternative backend.
-- Intro --
The ref PDS hits structural limits around 300k accounts [2].
SQLite-per-actor means no cross-user write batching.
tranquil-store is an embedded rust library. It lives in-process, no external deps.
Postgres remains supported; we plan to enable a storage transition path
such that users can seamlessly snapshot-n-switch between the backends.
The BlockStore is a bitcask-style append log [4] with a Fjall key index [5] that
maps each CID to a (file, offset, length) tuple. We use key-value separation
as per WiscKey [6]. Because blocks are immutable and keyed by CID, the value log
never needs compaction. An LRU hot tier keeps frequently-accessed blocks in
mem, and hint files allow fast index reconstruction on restart [4].
The main throughput enabler is group commit [7]. The ref PDS fsyncs once per user
per mutation [1], but BlockStore batches all concurrent commits into a single
write-and-sync cycle.
Content dedup occurs naturally: identical MST subtrees across users share
one CID-keyed block instead of N copies [3].
MetaStore uses Fjall [5] keyspaces for all mutable data. We chose Fjall over
redb and LMDB because both of those are single-writer [8][9]. Each keyspace
compacts independently.
For cross-store atomicity we use an intent log. Each mutation writes a single
intent record containing the BlockStore refcount updates, MetaStore changes,
and the serialized EventLog payload, fsynced via the group commit. After fsync,
the changes are applied to MetaStore and the event is appended to the EventLog,
then the intent is marked committed. Recovery replays any incomplete intents,
re-applying both metadata changes and event appends. This gives us crash-atomic
mutations across all three stores without full MVCC, since mutations
are already serialized per-user [3].
EventLog stores the firehose as segmented append-only files. Live subscribers
receive events via tokio broadcast, and consumers that are catching up will
read from mmap'ed segments [10]. Each event receives a monotonic u64 sequence number.
Segment headers store the base sequence number; a per-segment index maps sequence
ranges to byte offsets. This decouples consumer cursors from physical layout,
allowing transparent addition of per-segment zstd compression per the loom-v2
spec [11] without invalidating checkpoints. Retention is just deleting old
segments! :P
GC uses refcounted key index entries. GC is epoch-gated by the group commit
cycle: a block is only eligible for collection if its refcount reached zero in
a prior completed commit cycle. This prevents races between concurrent dedup
(which skips the block write but increments the refcount in the same batch) and
collection. Blocks past the epoch gate are collected by rewriting any data
files that fall below a liveness threshold.
For serialization we use postcard on disk and rkyv [12] for in-mem caches
only. All data files carry a version tag.
Memory is divided into fixed slices from a configurable total budget: Fjall
block cache, BlockStore hot tier, and CID index each receive a configured
percentage. Actual usage per component is exposed as metrics. The EventLog's
mmap pages live in the OS page cache and are excluded from the budget.
Backup acquires the group commit lock, which quiesces all writes at the next
commit boundary. Under the lock, the system notes the EventLog position, the
BlockStore file list, and takes a Fjall snapshot, then releases the lock.
Sealed data files and segments are immutable and can be copied without
co-ordination after the snapshot. The quiesce window is bounded by one commit
cycle. Point-in-time recovery replays the EventLog against a prior snapshot.
For continuous replication, a background process tails the EventLog and copies
sealed files to remote storage.
-- Runtime --
The storage core runs on tokio. It is synchronous internally, accessed through
dedicated handler threads that communicate via async channels [13]. Requests
are dispatched by hashing the DID, which gives us per-user write serialization
without locks. Global operations use round-robin. All disk IO goes through
pread/pwrite directly [13].
We rejected io_uring for three reasons: it creates orphan kernel operations
when futures are cancelled [22], it is blocked by default in both Docker [16]
and Podman [17] seccomp profiles, and it accounts for 60% of Google's kernel
vulnerability rewards [15].
We also rejected thread-per-core runtimes (glommio, etc.) because they are
incompatible with the tokio ecosystem. DID-sharded handler threads give us
the same shared-nothing property without a runtime split.
-- Testing --
We use deterministic simulation testing, following FoundationDB [18] and
TigerBeetle's VOPR [19]. All IO sits behind a StorageIO trait, and tests use an
in-memory implementation that injects faults: partial writes, bit flips, sync
failures, and misdirected writes. A single seed controls the entire fault
schedule, so any failure reproduces exactly [20][21].
-- Why these choices --
Bitcask for blocks:
Key-val separation [6] using Bitcask [4] for immutable CID blocks:
O(1) writes, O(1) reads, zero write amplification, & no compaction!
Fjall for metadata:
Only pure-Rust embedded engine with concurrent writers [5].
Otherwise we'd write our own.
Segmented log for events:
Write once -> scan forward -> delete by age.
Quite straightforward!
Postcard on disk:
rkyv is apparently faster [12] but couples on-disk format to library version.
Tokio & handler threads:
spawn_blocking & pread matches io_uring without security/compat costs [13][14][15][16].
Deterministic simulation:
Catches bug classes conventional testing can't reach [18][19].
StorageIO trait is needed anyway; but being harness-first is a one-time cost [20][21].
-- References --
[1] Bluesky PDS SQLite migration. github.com/bluesky-social/atproto/pull/1705
[2] G. Orosz. Building Bluesky: a Distributed Social Network. Pragmatic Engineer, April 2024.
newsletter.pragmaticengineer.com/p/bluesky
K. Suder. Introduction to AT Protocol. August 2025. mackuba.eu/2025/08/20/introduction-to-atproto
Bluesky PDS "Going to Production" guide. atproto.com/guides/going-to-production
[3] AT Protocol repository spec. atproto.com/specs/repository
[4] Bitcask: A Log-Structured Hash Table for Fast KV Data. Riak, 2010. riak.com/assets/bitcask-intro.pdf
[5] Fjall: LSM-based embedded storage engine. github.com/fjall-rs/fjall
[6] Lu et al. WiscKey: Separating Keys from Values in SSD-Conscious Storage. USENIX FAST 2016.
usenix.org/conference/fast16/technical-sessions/presentation/lu
[7] Phil Eaton. A Write-Ahead Log Is Not a Universal Part of Durability. July 2024.
notes.eatonphil.com/2024-07-01-a-write-ahead-log-is-not-a-universal-part-of-durability.html
[8] redb design document. github.com/cberner/redb/blob/master/docs/design.md
[9] LMDB source repository. github.com/LMDB/lmdb
[10] Crotty et al. Are You Sure You Want to Use MMAP in Your DBMS? CIDR 2022.
cs.brown.edu/people/acrotty/pubs/p13-crotty.pdf
[11] ybzeek. RFC: com.atproto.sync.getZstdStream (zstd-compressed relay streams).
github.com/bluesky-social/atproto/discussions/4582
[12] rkyv: zero-copy deserialization framework for Rust. rkyv.org
[13] Tonbo. Exploring Better Async Rust Disk IO. tonbo.io/blog/exploring-better-async-rust-disk-io
[14] Iroh. Async Rust Challenges in Iroh. iroh.computer/blog/async-rust-challenges-in-iroh
[15] Google restricting io_uring. phoronix.com/news/Google-Restricting-IO_uring
[16] Docker 4.42.0 and io_uring. forums.docker.com/t/4-42-0-and-io-uring/148620
[17] Podman io_uring discussion. github.com/containers/podman/discussions/27772
[18] FoundationDB simulation testing. apple.github.io/foundationdb/testing.html
[19] TigerBeetle VOPR. tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness
[20] DST in Rust (S2). s2.dev/blog/dst
[21] Phil Eaton. What's the big deal about Deterministic Simulation Testing? August 2024.
notes.eatonphil.com/2024-08-20-deterministic-simulation-testing.html
[22] Tonbo. Async Rust Is Not Safe with io_uring. tonbo.io/blog/async-rust-is-not-safe-with-io-uring
Thank you for reading! Let's do some great work together.
+3
View File
@@ -12,12 +12,15 @@ 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 }
base64 = { workspace = true }
image = { workspace = true }
qrcodegen = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
+5 -4
View File
@@ -34,13 +34,13 @@ pub struct GetPreferencesOutput {
}
pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
let prefs = match state.infra_repo.get_account_preferences(user_id).await {
let prefs = match state.repos.infra.get_account_preferences(user_id).await {
Ok(rows) => rows,
Err(_) => {
return ApiError::InternalError(Some("Failed to fetch preferences".into()))
@@ -93,7 +93,7 @@ pub async fn put_preferences(
Json(input): Json<PutPreferencesInput>,
) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
@@ -188,7 +188,8 @@ pub async fn put_preferences(
.collect();
if state
.infra_repo
.repos
.infra
.replace_namespace_preferences(user_id, APP_BSKY_NAMESPACE, prefs_to_save)
.await
.is_err()
@@ -19,7 +19,8 @@ pub async fn delete_account(
) -> Result<Json<EmptyResponse>, ApiError> {
let did = &input.did;
let (user_id, handle) = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("in delete_account")?
@@ -27,7 +28,8 @@ pub async fn delete_account(
.map(|row| (row.id, row.handle))?;
state
.user_repo
.repos
.user
.admin_delete_account_complete(user_id, did)
.await
.log_db_err("deleting account")?;
@@ -31,7 +31,8 @@ pub async fn send_email(
return Err(ApiError::InvalidRequest("content is required".into()));
}
let user = state
.user_repo
.repos
.user
.get_by_did(&input.recipient_did)
.await
.log_db_err("in send_email")?
@@ -45,7 +46,8 @@ pub async fn send_email(
.clone()
.unwrap_or_else(|| format!("Message from {}", hostname));
let result = state
.infra_repo
.repos
.infra
.enqueue_comms(
Some(user_id),
tranquil_db_traits::CommsChannel::Email,
+19 -10
View File
@@ -69,7 +69,8 @@ pub async fn get_account_info(
Query(params): Query<GetAccountInfoParams>,
) -> Result<Json<AccountInfo>, ApiError> {
let account = state
.infra_repo
.repos
.infra
.get_admin_account_info_by_did(&params.did)
.await
.log_db_err("in get_account_info")?
@@ -98,7 +99,8 @@ pub async fn get_account_info(
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
let code = state
.infra_repo
.repos
.infra
.get_invite_code_used_by_user(user_id)
.await
.ok()??;
@@ -111,7 +113,8 @@ async fn get_invites_for_user(
user_id: uuid::Uuid,
) -> Option<Vec<InviteCodeInfo>> {
let invite_codes = state
.infra_repo
.repos
.infra
.get_invites_created_by_user(user_id)
.await
.ok()?;
@@ -123,7 +126,8 @@ async fn get_invites_for_user(
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.ok()?;
@@ -154,10 +158,11 @@ async fn get_invites_for_user(
}
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
let info = state.infra_repo.get_invite_code_info(code).await.ok()??;
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses(code)
.await
.ok()
@@ -197,7 +202,8 @@ pub async fn get_account_infos(
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
let accounts = state
.infra_repo
.repos
.infra
.get_admin_account_infos_by_dids(&dids_typed)
.await
.log_db_err("fetching account infos")?;
@@ -205,7 +211,8 @@ pub async fn get_account_infos(
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
let all_invite_codes = state
.infra_repo
.repos
.infra
.get_invite_codes_by_users(&user_ids)
.await
.unwrap_or_default();
@@ -217,7 +224,8 @@ pub async fn get_account_infos(
let all_invite_uses = if !all_codes.is_empty() {
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&all_codes)
.await
.unwrap_or_default()
@@ -226,7 +234,8 @@ pub async fn get_account_infos(
};
let invited_by_map: HashMap<uuid::Uuid, String> = state
.infra_repo
.repos
.infra
.get_invite_code_uses_by_users(&user_ids)
.await
.unwrap_or_default()
@@ -51,15 +51,14 @@ pub async fn search_accounts(
Query(params): Query<SearchAccountsParams>,
) -> Result<Json<SearchAccountsOutput>, ApiError> {
let limit = params.limit.clamp(1, 100);
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
let rows = state
.user_repo
.repos
.user
.search_accounts(
cursor_did.as_ref(),
email_filter.as_deref(),
handle_filter.as_deref(),
params.email.as_deref(),
params.handle.as_deref(),
limit + 1,
)
.await
+12 -10
View File
@@ -30,7 +30,8 @@ pub async fn update_account_email(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.admin_update_email(&account_did, email)
.await
{
@@ -71,9 +72,10 @@ pub async fn update_account_handle(
} else {
input_handle.to_string()
};
let old_handle = state.user_repo.get_handle_by_did(did).await.ok().flatten();
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.ok()
@@ -81,14 +83,16 @@ pub async fn update_account_handle(
.ok_or(ApiError::AccountNotFound)?;
let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
if let Ok(true) = state
.user_repo
.repos
.user
.check_handle_exists(&handle_for_check, user_id)
.await
{
return Err(ApiError::HandleTaken);
}
match state
.user_repo
.repos
.user
.admin_update_handle(did, &handle_for_check)
.await
{
@@ -146,13 +150,11 @@ pub async fn update_account_password(
if password.is_empty() {
return Err(ApiError::InvalidRequest("password is required".into()));
}
let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let password_hash = crate::common::hash_or_internal_error(password)?;
match state
.user_repo
.repos
.user
.admin_update_password(did, &password_hash)
.await
{
+32 -16
View File
@@ -53,7 +53,8 @@ pub async fn get_server_config(
];
let rows = state
.infra_repo
.repos
.infra
.get_server_configs(keys)
.await
.log_db_err("fetching server config")?;
@@ -86,7 +87,8 @@ pub async fn update_server_config(
));
}
state
.infra_repo
.repos
.infra
.upsert_server_config("server_name", trimmed)
.await
.log_db_err("upserting server_name")?;
@@ -95,13 +97,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color")
.await
.log_db_err("deleting primary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color", color)
.await
.log_db_err("upserting primary_color")?;
@@ -115,13 +119,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color_dark")
.await
.log_db_err("deleting primary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color_dark", color)
.await
.log_db_err("upserting primary_color_dark")?;
@@ -135,13 +141,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color")
.await
.log_db_err("deleting secondary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color", color)
.await
.log_db_err("upserting secondary_color")?;
@@ -155,13 +163,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color_dark")
.await
.log_db_err("deleting secondary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color_dark", color)
.await
.log_db_err("upserting secondary_color_dark")?;
@@ -174,7 +184,8 @@ pub async fn update_server_config(
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid = state
.infra_repo
.repos
.infra
.get_server_config("logo_cid")
.await
.ok()
@@ -189,13 +200,16 @@ pub async fn update_server_config(
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.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
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.infra_repo.delete_blob_by_cid(&old_cid).await {
if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
}
}
@@ -211,13 +225,15 @@ pub async fn update_server_config(
if logo_cid.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("logo_cid")
.await
.log_db_err("deleting logo_cid")?;
} else {
state
.infra_repo
.repos
.infra
.upsert_server_config("logo_cid", logo_cid)
.await
.log_db_err("upserting logo_cid")?;
+13 -7
View File
@@ -24,7 +24,7 @@ pub async fn disable_invite_codes(
Json(input): Json<DisableInviteCodesInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
if let Some(codes) = &input.codes
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
&& let Err(e) = state.repos.infra.disable_invite_codes_by_code(codes).await
{
error!("DB error disabling invite codes: {:?}", e);
}
@@ -32,7 +32,8 @@ pub async fn disable_invite_codes(
let accounts_typed: Vec<tranquil_types::Did> =
accounts.iter().filter_map(|a| a.parse().ok()).collect();
if let Err(e) = state
.infra_repo
.repos
.infra
.disable_invite_codes_by_account(&accounts_typed)
.await
{
@@ -87,7 +88,8 @@ pub async fn get_invite_codes(
};
let codes_rows = state
.infra_repo
.repos
.infra
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
.await
.log_db_err("fetching invite codes")?;
@@ -96,7 +98,8 @@ pub async fn get_invite_codes(
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
.infra_repo
.repos
.infra
.get_user_dids_by_ids(&user_ids)
.await
.unwrap_or_default()
@@ -108,7 +111,8 @@ pub async fn get_invite_codes(
} else {
common::group_invite_uses_by_code(
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.unwrap_or_default(),
@@ -168,7 +172,8 @@ pub async fn disable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, true)
.await
{
@@ -200,7 +205,8 @@ pub async fn enable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, false)
.await
{
+2
View File
@@ -2,6 +2,7 @@ pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod signal;
pub mod status;
pub use account::{
@@ -13,4 +14,5 @@ pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};
pub use server_stats::get_server_stats;
pub use signal::{get_signal_status, link_signal_device, unlink_signal_device};
pub use status::{get_subject_status, update_subject_status};
@@ -17,10 +17,10 @@ pub async fn get_server_stats(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<ServerStatsOutput>, ApiError> {
let user_count = state.user_repo.count_users().await.unwrap_or(0);
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
let user_count = state.repos.user.count_users().await.unwrap_or(0);
let repo_count = state.repos.repo.count_repos().await.unwrap_or(0);
let record_count = state.repos.repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.repos.blob.sum_blob_storage().await.unwrap_or(0);
Ok(Json(ServerStatsOutput {
user_count,
+171
View File
@@ -0,0 +1,171 @@
use axum::{Json, extract::State};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{ImageBuffer, Luma};
use serde::Serialize;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub enabled: bool,
pub linked: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalLinkOutput {
pub qr_base64: String,
}
pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let enabled = tranquil_config::get().signal.enabled;
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { enabled, linked }))
}
pub async fn link_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalLinkOutput>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
if slot.is_linked().await {
return Err(ApiError::InvalidRequest(
"Signal device already linked".into(),
));
}
let (generation, link_cancel) = slot.begin_link().await;
let device_name = tranquil_signal::DeviceName::new("tranquil-pds".to_string())
.map_err(|e| ApiError::InternalError(Some(format!("invalid device name: {e}"))))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
let link_result = signal_store
.link_signal_device(
device_name,
state.shutdown.clone(),
link_cancel,
slot.linking_flag(),
)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Signal linking failed: {e}"))))?;
let qr_base64 = url_to_qr_png_base64(link_result.url.as_str())
.map_err(|e| ApiError::InternalError(Some(format!("QR generation failed: {e}"))))?;
let slot_for_task = slot.clone();
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("Signal linking aborted due to server shutdown");
return;
}
r = link_result.completion => r,
};
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("Signal device linked successfully");
} else {
tracing::warn!(
"Signal link completed but generation mismatch or already linked; discarding"
);
}
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Signal device linking failed");
}
Err(_) => {
tracing::error!("Signal linking task dropped without completing");
}
}
});
Ok(Json(SignalLinkOutput { qr_base64 }))
}
pub async fn unlink_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<serde_json::Value>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
signal_store
.clear_signal_data()
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to clear signal data: {e}"))))?;
slot.unlink().await;
Ok(Json(serde_json::json!({})))
}
const QR_MODULE_SCALE: u32 = 8;
const QR_QUIET_ZONE_MODULES: u32 = 4;
fn url_to_qr_png_base64(url: &str) -> Result<String, String> {
let qr = qrcodegen::QrCode::encode_text(url, qrcodegen::QrCodeEcc::Medium)
.map_err(|e| format!("QR encode failed: {e:?}"))?;
let size = u32::try_from(qr.size()).map_err(|_| "QR size is negative".to_string())?;
let img_size = size
.checked_add(
QR_QUIET_ZONE_MODULES
.checked_mul(2)
.ok_or("border overflow")?,
)
.ok_or("image size overflow")?
.checked_mul(QR_MODULE_SCALE)
.ok_or("scaled size overflow")?;
let img: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(img_size, img_size, |x, y| {
let module_x = x / QR_MODULE_SCALE;
let module_y = y / QR_MODULE_SCALE;
match (
module_x.checked_sub(QR_QUIET_ZONE_MODULES),
module_y.checked_sub(QR_QUIET_ZONE_MODULES),
) {
(Some(mx), Some(my)) if mx < size && my < size => {
if qr.get_module(mx as i32, my as i32) {
Luma([0u8])
} else {
Luma([255u8])
}
}
_ => Luma([255u8]),
}
});
let mut png_bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_bytes);
img.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("PNG encode failed: {e}"))?;
Ok(STANDARD.encode(&png_bytes))
}
+30 -17
View File
@@ -45,7 +45,7 @@ pub async fn get_subject_status(
let did: Did = did_str
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state.user_repo.get_status_by_did(&did).await {
match state.repos.user.get_status_by_did(&did).await {
Ok(Some(status)) => {
let deactivated = status.deactivated_at.map(|_| StatusAttr {
applied: true,
@@ -77,7 +77,7 @@ pub async fn get_subject_status(
let cid: CidLink = uri_str
.parse()
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
match state.repo_repo.get_record_by_cid(&cid).await {
match state.repos.repo.get_record_by_cid(&cid).await {
Ok(Some(record)) => {
let takedown = record.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
@@ -109,7 +109,7 @@ pub async fn get_subject_status(
let did = params.did.as_ref().ok_or_else(|| {
ApiError::InvalidRequest("Must provide a did to request blob state".into())
})?;
match state.blob_repo.get_blob_with_takedown(&blob_cid).await {
match state.repos.blob.get_blob_with_takedown(&blob_cid).await {
Ok(Some(blob)) => {
let takedown = blob.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
@@ -172,7 +172,8 @@ pub async fn update_subject_status(
None
};
state
.user_repo
.repos
.user
.set_user_takedown(&did, takedown_ref)
.await
.map_err(|e| {
@@ -182,9 +183,9 @@ pub async fn update_subject_status(
}
if let Some(deactivated) = &input.deactivated {
let result = if deactivated.applied {
state.user_repo.deactivate_account(&did, None).await
state.repos.user.deactivate_account(&did, None).await
} else {
state.user_repo.activate_account(&did).await
state.repos.user.activate_account(&did).await
};
result.map_err(|e| {
error!(
@@ -194,11 +195,22 @@ pub async fn update_subject_status(
ApiError::InternalError(Some("Failed to update deactivation status".into()))
})?;
}
let takedown_update = input.takedown.as_ref().map(|t| t.applied);
let takedown_ref = input.takedown.as_ref().and_then(|t| t.r#ref.as_deref());
let deactivated_update = input.deactivated.as_ref().map(|d| d.applied);
if (takedown_update.is_some() || deactivated_update.is_some())
&& let Err(e) = state
.repos
.repo
.update_repo_status(&did, takedown_update, takedown_ref, deactivated_update)
.await
{
warn!("failed to sync status to repo backend: {e:?}");
}
if let Some(takedown) = &input.takedown {
let status = if takedown.applied {
tranquil_db_traits::AccountStatus::Takendown
} else {
tranquil_db_traits::AccountStatus::Active
let status = match takedown.applied {
true => tranquil_db_traits::AccountStatus::Takendown,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -207,10 +219,9 @@ pub async fn update_subject_status(
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.applied {
tranquil_db_traits::AccountStatus::Deactivated
} else {
tranquil_db_traits::AccountStatus::Active
let status = match deactivated.applied {
true => tranquil_db_traits::AccountStatus::Deactivated,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -218,7 +229,7 @@ pub async fn update_subject_status(
warn!("Failed to sequence account event for deactivation: {}", e);
}
}
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
if let Ok(Some(handle)) = state.repos.user.get_handle_by_did(&did).await {
let _ = state
.cache
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
@@ -249,7 +260,8 @@ pub async fn update_subject_status(
None
};
state
.repo_repo
.repos
.repo
.set_record_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
@@ -282,7 +294,8 @@ pub async fn update_subject_status(
None
};
state
.blob_repo
.repos
.blob
.update_blob_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
+3 -3
View File
@@ -68,8 +68,8 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
let http_uri = "/";
let auth_user = match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.oauth.as_ref(),
&extracted.token,
extracted.scheme,
dpop_proof,
@@ -89,7 +89,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
}
};
match state.user_repo.get_by_did(&auth_user.did).await {
match state.repos.user.get_by_did(&auth_user.did).await {
Ok(Some(user)) => {
tracing::debug!(created_at = ?user.created_at, "age assurance: got user");
Some(user.created_at.to_rfc3339())
+15 -1
View File
@@ -1,4 +1,4 @@
use bcrypt::DEFAULT_COST;
use bcrypt::{DEFAULT_COST, hash};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tracing::error;
@@ -245,6 +245,20 @@ pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
})
}
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
let password = password.to_string();
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})
}
pub fn validate_token_hash(
expires_at: Option<DateTime<Utc>>,
stored_hash: &str,
+38 -21
View File
@@ -24,7 +24,8 @@ pub async fn list_controllers(
auth: Auth<Active>,
) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> {
let controllers = state
.delegation_repo
.repos
.delegation
.get_delegations_for_account(&auth.did)
.await
.map_err(|e| {
@@ -37,8 +38,9 @@ pub async fn list_controllers(
async move {
if c.handle.is_none() {
c.handle = did_resolver
.resolve_did_document(c.did.as_str())
.fetch_did_document(c.did.as_str())
.await
.ok()
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
.map(Into::into);
}
@@ -64,7 +66,7 @@ pub async fn add_controller(
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
if !resolved.is_local
&& let Some(ref pds_url) = resolved.pds_url
@@ -99,7 +101,8 @@ pub async fn add_controller(
if resolved.is_local
&& state
.delegation_repo
.repos
.delegation
.is_delegated_account(&input.controller_did)
.await
.unwrap_or(false)
@@ -110,7 +113,8 @@ pub async fn add_controller(
}
match state
.delegation_repo
.repos
.delegation
.create_delegation(
can_add.did(),
&input.controller_did,
@@ -121,7 +125,8 @@ pub async fn add_controller(
{
Ok(_) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
can_add.did(),
can_add.did(),
@@ -158,13 +163,15 @@ pub async fn remove_controller(
Json(input): Json<RemoveControllerInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.revoke_delegation(&auth.did, &input.controller_did, &auth.did)
.await
{
Ok(true) => {
let revoked_app_passwords = state
.session_repo
.repos
.session
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0)
@@ -172,13 +179,15 @@ pub async fn remove_controller(
.unwrap_or(0usize);
let revoked_oauth_tokens = state
.oauth_repo
.repos
.oauth
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0);
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
@@ -217,13 +226,15 @@ pub async fn update_controller_scopes(
Json(input): Json<UpdateControllerScopesInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
.await
{
Ok(true) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
@@ -254,7 +265,8 @@ pub async fn list_controlled_accounts(
auth: Auth<Active>,
) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> {
let accounts = state
.delegation_repo
.repos
.delegation
.get_accounts_controlled_by(&auth.did)
.await
.map_err(|e| {
@@ -286,7 +298,8 @@ pub async fn get_audit_log(
let offset = params.offset.max(0);
let entries = state
.delegation_repo
.repos
.delegation
.get_audit_log_for_account(&auth.did, limit, offset)
.await
.map_err(|e| {
@@ -295,7 +308,8 @@ pub async fn get_audit_log(
})?;
let total = state
.delegation_repo
.repos
.delegation
.count_audit_log_entries(&auth.did)
.await
.unwrap_or_default();
@@ -349,7 +363,7 @@ pub async fn create_delegated_account(
}
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
@@ -387,7 +401,8 @@ pub async fn create_delegated_account(
};
let user_id = match state
.user_repo
.repos
.user
.create_delegated_account(&create_input)
.await
{
@@ -406,7 +421,8 @@ pub async fn create_delegated_account(
if let Some(validated) = validated_invite_code
&& let Err(e) = state
.infra_repo
.repos
.infra
.record_invite_code_use(&validated, user_id)
.await
{
@@ -423,7 +439,8 @@ pub async fn create_delegated_account(
.await;
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&did,
&auth.did,
@@ -461,7 +478,7 @@ pub async fn resolve_controller(
} else {
let local_handle: Option<Handle> = identifier.parse().ok();
let local_user = match local_handle {
Some(ref h) => state.user_repo.get_by_handle(h).await.ok().flatten(),
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
None => None,
};
match local_user {
@@ -476,7 +493,7 @@ pub async fn resolve_controller(
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
Ok(Json(resolved))
}
+4 -3
View File
@@ -169,7 +169,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
);
match state
.user_repo
.repos
.user
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
.await
{
@@ -180,8 +181,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
"Verified Discord user and stored user ID"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
+27 -25
View File
@@ -6,7 +6,6 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info};
@@ -16,7 +15,6 @@ use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -68,14 +66,16 @@ async fn try_reactivate_migration(
new_email: email.clone(),
};
match state
.user_repo
.repos
.user
.reactivate_migration_account(&reactivate_input)
.await
{
Ok(reactivated) => {
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let secret_key_bytes = match state
.user_repo
.repos
.user
.get_user_key_by_id(reactivated.user_id)
.await
{
@@ -130,7 +130,7 @@ async fn try_reactivate_migration(
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
if let Err(e) = state.repos.session.create_session(&session_data).await {
error!("Error creating session: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
@@ -154,7 +154,12 @@ async fn try_reactivate_migration(
Json(CreateAccountOutput {
handle: handle.to_string().into(),
did: did_typed.clone(),
did_doc: state.did_resolver.resolve_did_document(did).await,
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
@@ -395,7 +400,8 @@ pub async fn create_account(
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_available = match state
.user_repo
.repos
.user
.check_handle_available_for_new_account(&handle_typed)
.await
{
@@ -410,7 +416,7 @@ pub async fn create_account(
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
if is_bootstrap {
match input.invite_code.as_deref() {
@@ -431,7 +437,7 @@ pub async fn create_account(
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
let valid = match state.repos.user.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
@@ -448,19 +454,10 @@ pub async fn create_account(
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let password_clone = input.password.clone();
let password_hash =
match tokio::task::spawn_blocking(move || hash(&password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Error hashing password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let password_hash = match crate::common::hash_password_async(&input.password).await {
Ok(h) => h,
Err(e) => return e.into_response(),
};
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
Some(chrono::Utc::now())
@@ -527,7 +524,12 @@ pub async fn create_account(
birthdate_pref,
};
let create_result = match state.user_repo.create_password_account(&create_input).await {
let create_result = match state
.repos
.user
.create_password_account(&create_input)
.await
{
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return ApiError::HandleNotAvailable(None).into_response();
@@ -590,7 +592,7 @@ pub async fn create_account(
Err(e) => return e.into_response(),
};
let did_doc = state.did_resolver.resolve_did_document(&did).await;
let did_doc = state.did_resolver.fetch_did_document(&did).await.ok();
if is_migration {
info!(
@@ -604,7 +606,7 @@ pub async fn create_account(
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: did_for_commit,
did_doc,
did_doc: did_doc.map(|f| (*f).clone()),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
+20 -12
View File
@@ -54,7 +54,7 @@ pub async fn resolve_handle(
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => {
let _ = state
@@ -165,7 +165,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
};
let user = match state
.user_repo
.repos
.user
.get_user_for_did_doc_build(&expected_did_typed)
.await
{
@@ -182,7 +183,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
let did = expected_did;
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -218,7 +220,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
};
let user = match state
.user_repo
.repos
.user
.get_did_web_info_by_handle(&current_handle_typed)
.await
{
@@ -246,7 +249,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -295,7 +299,7 @@ async fn build_override_or_key_verification_methods(
.collect());
}
let key_info = match state.user_repo.get_user_key_by_id(user_id).await {
let key_info = match state.repos.user.get_user_key_by_id(user_id).await {
Ok(Some(k)) => k,
_ => return Err(ApiError::InternalError(None).into_response()),
};
@@ -468,7 +472,8 @@ pub async fn get_recommended_did_credentials(
auth: Auth<NotTakendown>,
) -> Result<Json<GetRecommendedDidCredentialsOutput>, ApiError> {
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.log_db_err("fetching handle for DID credentials")?
@@ -539,7 +544,8 @@ pub async fn update_handle(
)
.await?;
let user_row = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(&did)
.await
.log_db_err("fetching user for handle update")?
@@ -661,7 +667,8 @@ pub async fn update_handle(
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
let handle_exists = state
.user_repo
.repos
.user
.check_handle_exists(&handle_typed, user_id)
.await
.log_db_err("checking handle existence")?;
@@ -669,7 +676,8 @@ pub async fn update_handle(
return Err(ApiError::HandleTaken);
}
state
.user_repo
.repos
.user
.update_handle(user_id, &handle_typed)
.await
.map_err(|e| {
@@ -706,7 +714,7 @@ pub async fn update_plc_handle(
if !did.as_str().starts_with("did:plc:") {
return Ok(());
}
let user_row = match state.user_repo.get_user_with_key_by_did(did).await? {
let user_row = match state.repos.user.get_user_with_key_by_did(did).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -733,7 +741,7 @@ pub async fn well_known_atproto_did(State(state): State<AppState>, headers: Head
Ok(h) => h,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid handle format").into_response(),
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => row.did.to_string().into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Handle not found").into_response(),
@@ -20,25 +20,27 @@ pub async fn request_plc_operation_signature(
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
)?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
let plc_token = generate_plc_token();
let expires_at = Utc::now() + Duration::minutes(10);
state
.infra_repo
.repos
.infra
.insert_plc_token(user_id, &plc_token, expires_at)
.await
.log_db_err("creating PLC token")?;
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&plc_token,
hostname,
+8 -5
View File
@@ -55,25 +55,28 @@ pub async fn sign_plc_operation(
})?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let token_expiry = state
.infra_repo
.repos
.infra
.get_plc_token_expiry(user_id, token)
.await
.log_db_err("fetching PLC token expiry")?
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
if Utc::now() > token_expiry {
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
return Err(ApiError::ExpiredToken(Some("Token has expired".into())));
}
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user_id)
.await
.log_db_err("fetching user key")?
@@ -136,7 +139,7 @@ pub async fn sign_plc_operation(
ApiError::InternalError(None)
})?;
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
info!("Signed PLC operation for user {}", did);
Ok(Json(SignPlcOperationOutput {
operation: signed_op,
@@ -38,14 +38,16 @@ pub async fn submit_plc_operation(
let hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user.id)
.await
.log_db_err("fetching user key")?
@@ -128,12 +130,13 @@ pub async fn submit_plc_operation(
.map_err(ApiError::from)?;
match state
.repo_repo
.repos
.repo
.insert_identity_event(did, Some(&user.handle))
.await
{
Ok(seq) => {
if let Err(e) = state.repo_repo.notify_update(seq).await {
if let Err(e) = state.repos.repo.notify_update(seq).await {
warn!("Failed to notify identity event: {:?}", e);
}
}
@@ -153,7 +156,7 @@ pub async fn submit_plc_operation(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(did))
.await;
if state.did_resolver.refresh_did(did).await.is_none() {
if state.did_resolver.refresh_did(did).await.is_err() {
warn!(did = %did, "Failed to refresh DID cache after PLC update");
}
info!(did = %did, "PLC operation submitted successfully");
@@ -136,7 +136,8 @@ pub async fn resolve_signing_key(
match signing_key_did {
Some(key_did) => {
let key = state
.infra_repo
.repos
.infra
.get_reserved_signing_key(key_did)
.await
.map_err(|e| {
@@ -295,7 +296,8 @@ pub async fn create_and_store_session(
app_password_name: None,
};
state
.session_repo
.repos
.session
.create_session(&session_data)
.await
.map_err(|e| {
@@ -320,8 +322,8 @@ pub async fn enqueue_signup_verification(
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
@@ -346,8 +348,8 @@ pub async fn enqueue_migration_verification(
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
+6
View File
@@ -330,6 +330,12 @@ pub fn api_routes() -> axum::Router<AppState> {
get(admin::get_invite_codes),
)
.route("/_admin.getServerStats", get(admin::get_server_stats))
.route("/_admin.getSignalStatus", get(admin::get_signal_status))
.route("/_admin.linkSignalDevice", post(admin::link_signal_device))
.route(
"/_admin.unlinkSignalDevice",
post(admin::unlink_signal_device),
)
.route("/_server.getConfig", get(admin::get_server_config))
.route(
"/_admin.updateServerConfig",
+4 -3
View File
@@ -107,7 +107,7 @@ async fn proxy_to_report_service(
let key_bytes = match &auth_user.key_bytes {
Some(kb) => kb.clone(),
None => match state.user_repo.get_with_key_by_did(&auth_user.did).await {
None => match state.repos.user.get_with_key_by_did(&auth_user.did).await {
Ok(Some(user_with_key)) => {
match tranquil_pds::config::decrypt_key(
&user_with_key.key_bytes,
@@ -138,7 +138,7 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
service_did,
"com.atproto.moderation.createReport",
Some("com.atproto.moderation.createReport"),
&key_bytes,
) {
Ok(t) => t,
@@ -226,7 +226,8 @@ async fn create_report_locally(
let subject_json = json!(input.subject);
if let Err(e) = state
.infra_repo
.repos
.infra
.insert_report(
report_id,
input.reason_type.as_str(),
+31 -16
View File
@@ -26,7 +26,8 @@ pub async fn get_notification_prefs(
auth: Auth<Active>,
) -> Result<Json<NotificationPrefsOutput>, ApiError> {
let prefs = state
.user_repo
.repos
.user
.get_notification_prefs(&auth.did)
.await
.log_db_err("get notification prefs")?
@@ -65,14 +66,16 @@ pub async fn get_notification_history(
auth: Auth<Active>,
) -> Result<Json<GetNotificationHistoryOutput>, ApiError> {
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("get user id by did")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.infra_repo
.repos
.infra
.get_notification_history(user_id, 50)
.await
.log_db_err("get notification history")?;
@@ -146,7 +149,7 @@ pub async fn request_channel_verification(
let hostname = &tranquil_config::get().server.hostname;
let handle_str = handle.unwrap_or("user");
tranquil_pds::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
state.repos.infra.as_ref(),
user_id,
identifier,
handle_str,
@@ -165,7 +168,8 @@ pub async fn request_channel_verification(
hostname, encoded_token, encoded_identifier
);
let prefs = state
.user_repo
.repos
.user
.get_comms_prefs(user_id)
.await
.ok()
@@ -185,7 +189,8 @@ pub async fn request_channel_verification(
);
let recipient = match channel {
CommsChannel::Telegram => state
.user_repo
.repos
.user
.get_telegram_chat_id(user_id)
.await
.ok()
@@ -195,7 +200,8 @@ pub async fn request_channel_verification(
_ => identifier.to_string(),
};
state
.infra_repo
.repos
.infra
.enqueue_comms(
Some(user_id),
channel,
@@ -238,17 +244,20 @@ async fn process_messaging_channel_update(
}
match channel {
CommsChannel::Discord => state
.user_repo
.repos
.user
.clear_discord(user_id)
.await
.log_db_err("clear discord")?,
CommsChannel::Telegram => state
.user_repo
.repos
.user
.clear_telegram(user_id)
.await
.log_db_err("clear telegram")?,
CommsChannel::Signal => state
.user_repo
.repos
.user
.clear_signal(user_id)
.await
.log_db_err("clear signal")?,
@@ -281,17 +290,20 @@ async fn process_messaging_channel_update(
match channel {
CommsChannel::Discord => state
.user_repo
.repos
.user
.set_unverified_discord(user_id, &clean)
.await
.log_db_err("set unverified discord")?,
CommsChannel::Telegram => state
.user_repo
.repos
.user
.set_unverified_telegram(user_id, &clean)
.await
.log_db_err("set unverified telegram")?,
CommsChannel::Signal => state
.user_repo
.repos
.user
.set_unverified_signal(user_id, &clean)
.await
.log_db_err("set unverified signal")?,
@@ -313,7 +325,8 @@ pub async fn update_notification_prefs(
Json(input): Json<UpdateNotificationPrefsInput>,
) -> Result<Json<UpdateNotificationPrefsOutput>, ApiError> {
let user_row = state
.user_repo
.repos
.user
.get_id_handle_email_by_did(&auth.did)
.await
.log_db_err("get user by did")?
@@ -324,7 +337,8 @@ pub async fn update_notification_prefs(
let current_email = user_row.email;
let current_prefs = state
.user_repo
.repos
.user
.get_notification_prefs(&auth.did)
.await
.log_db_err("get notification prefs for update")?
@@ -347,7 +361,8 @@ pub async fn update_notification_prefs(
if input.preferred_channel.is_some() {
state
.user_repo
.repos
.user
.update_preferred_comms_channel(&auth.did, effective_channel)
.await
.log_db_err("update preferred channel")?;
+13 -7
View File
@@ -66,7 +66,8 @@ pub async fn upload_blob(
};
if state
.user_repo
.repos
.user
.is_account_migrated(&did)
.await
.unwrap_or(false)
@@ -78,7 +79,8 @@ pub async fn upload_blob(
get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream");
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&did)
.await
.log_db_err("fetching user id for blob upload")?
@@ -143,7 +145,8 @@ pub async fn upload_blob(
);
match state
.blob_repo
.repos
.blob
.insert_blob(
&cid_link,
&mime_type,
@@ -163,7 +166,7 @@ pub async fn upload_blob(
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.blob_repo.delete_blob_by_cid(&cid_link).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
@@ -177,7 +180,8 @@ pub async fn upload_blob(
if let Some(ref controller) = controller_did
&& let Err(e) = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&did,
controller,
@@ -236,7 +240,8 @@ pub async fn list_missing_blobs(
) -> Result<Json<ListMissingBlobsOutput>, ApiError> {
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_by_did(did)
.await
.log_db_err("fetching user")?
@@ -245,7 +250,8 @@ pub async fn list_missing_blobs(
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
let cursor = params.cursor.as_deref();
let missing = state
.blob_repo
.repos
.blob
.list_missing_blobs(user.id, cursor, limit + 1)
.await
.log_db_err("fetching missing blobs")?;
+27 -10
View File
@@ -34,7 +34,8 @@ pub async fn import_repo(
}
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_by_did(did)
.await
.log_db_err("fetching user")?
@@ -44,7 +45,8 @@ pub async fn import_repo(
}
let user_id = user.id;
let expected_root_cid = state
.repo_repo
.repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| {
@@ -193,8 +195,18 @@ pub async fn import_repo(
}
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
state
.block_store
.put_many(blocks.clone())
.await
.map_err(|e| {
error!("Failed to store import blocks: {:?}", e);
ApiError::InternalError(None)
})?;
match apply_import(
&state.repo_repo,
&state.repos.repo,
user_id,
root,
blocks.clone(),
@@ -232,7 +244,8 @@ pub async fn import_repo(
blob_refs.into_iter().unzip();
match state
.blob_repo
.repos
.blob
.insert_record_blobs(user_id, &record_uris, &blob_cids)
.await
{
@@ -248,7 +261,8 @@ pub async fn import_repo(
}
}
let key_row = state
.user_repo
.repos
.user
.get_user_with_key_by_did(did)
.await
.map_err(|e| {
@@ -289,7 +303,8 @@ pub async fn import_repo(
})?;
let new_root_cid_link = CidLink::from(&new_root_cid);
state
.repo_repo
.repos
.repo
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
.await
.map_err(|e| {
@@ -299,7 +314,8 @@ pub async fn import_repo(
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
.repo_repo
.repos
.repo
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
.await
.map_err(|e| {
@@ -322,7 +338,8 @@ pub async fn import_repo(
"birthDate": "1998-05-06T00:00:00.000Z"
});
if let Err(e) = state
.infra_repo
.repos
.infra
.insert_account_preference_if_not_exists(
user_id,
"app.bsky.actor.defs#personalDetailsPref",
@@ -391,7 +408,7 @@ async fn sequence_import_event(
rev: None,
};
let seq = state.repo_repo.insert_commit_event(&data).await?;
state.repo_repo.notify_update(seq).await?;
let seq = state.repos.repo.insert_commit_event(&data).await?;
state.repos.repo.notify_update(seq).await?;
Ok(())
}
+3 -2
View File
@@ -18,12 +18,13 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let resolved = match common::resolve_repo(state.user_repo.as_ref(), &input.repo).await {
let resolved = match common::resolve_repo(state.repos.user.as_ref(), &input.repo).await {
Ok(r) => r,
Err(e) => return e.into_response(),
};
let collections = state
.repo_repo
.repos
.repo
.list_collections(resolved.user_id)
.await
.unwrap_or_default();
+26 -3
View File
@@ -6,14 +6,16 @@ use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use tranquil_db_traits::Backlink;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated,
verify_batch_write_scopes,
};
use tranquil_pds::repo::tracking::TrackingBlockStore;
use tranquil_pds::repo::TrackingBlockStore;
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_blob_cids, finalize_repo_write,
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -27,6 +29,8 @@ struct WriteAccumulator {
ops: Vec<RecordOp>,
modified_keys: Vec<String>,
all_blob_cids: Vec<String>,
backlinks_to_add: Vec<Backlink>,
backlinks_to_remove: Vec<AtUri>,
}
async fn process_single_write(
@@ -42,6 +46,8 @@ async fn process_single_write(
mut ops,
mut modified_keys,
mut all_blob_cids,
mut backlinks_to_add,
mut backlinks_to_remove,
} = acc;
match write {
@@ -79,6 +85,7 @@ async fn process_single_write(
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
let uri = AtUri::from_parts(did, collection, &rkey);
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::CreateResult {
uri,
cid: record_cid.to_string(),
@@ -95,6 +102,8 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Update {
@@ -131,6 +140,8 @@ async fn process_single_write(
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
let uri = AtUri::from_parts(did, collection, rkey);
backlinks_to_remove.push(uri.clone());
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::UpdateResult {
uri,
cid: record_cid.to_string(),
@@ -148,6 +159,8 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Delete { collection, rkey } => {
@@ -158,6 +171,7 @@ async fn process_single_write(
.delete(&key)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
results.push(WriteResult::DeleteResult {});
ops.push(RecordOp::Delete {
collection: collection.clone(),
@@ -170,6 +184,8 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
}
@@ -189,6 +205,8 @@ async fn process_writes(
ops: Vec::new(),
modified_keys: Vec::new(),
all_blob_cids: Vec::new(),
backlinks_to_add: Vec::new(),
backlinks_to_remove: Vec::new(),
};
stream::iter(writes.iter().map(Ok::<_, ApiError>))
.try_fold(initial_acc, |acc, write| async move {
@@ -304,7 +322,8 @@ pub async fn apply_writes(
require_verified_or_delegated(&state, batch_proof.user()).await?;
let user_id: uuid::Uuid = state
.user_repo
.repos
.user
.get_id_by_did(&did)
.await
.log_db_err("fetching user for batch write")?
@@ -318,6 +337,8 @@ pub async fn apply_writes(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(
&input.writes,
mst,
@@ -372,6 +393,8 @@ pub async fn apply_writes(
ops,
modified_keys: &modified_keys,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
+9 -11
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use tracing::error;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Active, Auth, VerifyScope};
use tranquil_pds::repo::tracking::TrackingBlockStore;
use tranquil_pds::repo::TrackingBlockStore;
use tranquil_pds::repo_ops::{
CommitError, FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write,
};
@@ -76,6 +76,7 @@ pub async fn delete_record(
};
let modified_keys = [key];
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
&state,
@@ -95,19 +96,12 @@ pub async fn delete_record(
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &[],
backlinks_to_add: vec![],
backlinks_to_remove: vec![deleted_uri],
},
)
.await?;
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
if let Err(e) = state
.backlink_repo
.remove_backlinks_by_uri(&deleted_uri)
.await
{
error!("Failed to remove backlinks for {}: {}", deleted_uri, e);
}
Ok(Json(DeleteRecordOutput {
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
@@ -130,7 +124,8 @@ pub async fn delete_record_internal(
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repo_repo
.repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
@@ -214,6 +209,7 @@ pub async fn delete_record_internal(
let written_cids_str: Vec<String> = written_cids.iter().map(ToString::to_string).collect();
let deleted_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str());
commit_and_log(
state,
CommitParams {
@@ -226,6 +222,8 @@ pub async fn delete_record_internal(
blocks_cids: &written_cids_str,
blobs: &[],
obsolete_cids,
backlinks_to_add: vec![],
backlinks_to_remove: vec![deleted_uri],
},
)
.await?;
+7 -5
View File
@@ -60,12 +60,13 @@ pub async fn get_record(
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let user_id = match common::resolve_repo_user_id(state.user_repo.as_ref(), &input.repo).await {
let user_id = match common::resolve_repo_user_id(state.repos.user.as_ref(), &input.repo).await {
Ok(id) => id,
Err(e) => return e.into_response(),
};
let record_row = state
.repo_repo
.repos
.repo
.get_record_cid(user_id, &input.collection, &input.rkey)
.await;
let record_cid_link = match record_row {
@@ -128,7 +129,7 @@ pub async fn list_records(
State(state): State<AppState>,
Query(input): Query<ListRecordsInput>,
) -> Response {
let user_id = match common::resolve_repo_user_id(state.user_repo.as_ref(), &input.repo).await {
let user_id = match common::resolve_repo_user_id(state.repos.user.as_ref(), &input.repo).await {
Ok(id) => id,
Err(e) => return e.into_response(),
};
@@ -139,7 +140,8 @@ pub async fn list_records(
.as_ref()
.and_then(|c| c.parse::<tranquil_pds::types::Rkey>().ok());
let rows = match state
.repo_repo
.repos
.repo
.list_records(
user_id,
&input.collection,
@@ -176,7 +178,7 @@ pub async fn list_records(
};
let records: Vec<Value> = parsed_rows
.iter()
.zip(blocks.into_iter())
.zip(blocks)
.filter_map(|((_, rkey, cid_str), block_opt)| {
block_opt.and_then(|block| {
serde_ipld_dagcbor::from_slice::<Ipld>(&block)
+34 -31
View File
@@ -46,7 +46,8 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
let _account_verified = require_verified_or_delegated(state, user).await?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(principal_did.as_did())
.await
.log_db_err("fetching user for repo write")?
@@ -128,7 +129,8 @@ pub async fn create_record(
if !backlinks.is_empty() {
let conflicts = state
.backlink_repo
.repos
.backlink
.get_backlink_conflicts(user_id, &input.collection, &backlinks)
.await
.log_db_err("checking backlink conflicts")?;
@@ -145,20 +147,28 @@ pub async fn create_record(
let prev_cid = match mst.get(&conflict_key).await {
Ok(Some(cid)) => cid,
_ => continue,
};
mst = match mst.delete(&conflict_key).await {
Ok(m) => m,
Ok(None) => continue,
Err(e) => {
error!(
"Failed to delete conflict from MST {}: {:?}",
"Failed to read conflict record from MST {}: {:?}",
conflict_uri, e
);
continue;
return Err(ApiError::InternalError(Some(
"Failed to read conflicting record from MST".into(),
)));
}
};
mst = mst.delete(&conflict_key).await.map_err(|e| {
error!(
"Failed to delete conflict from MST {}: {:?}",
conflict_uri, e
);
ApiError::InternalError(Some(
"Failed to delete conflicting record from MST".into(),
))
})?;
ops.push(RecordOp::Delete {
collection: conflict_collection,
rkey: conflict_rkey,
@@ -206,6 +216,9 @@ pub async fn create_record(
.collect();
let blob_cids = extract_blob_cids(&input.record);
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
ctx,
@@ -224,31 +237,12 @@ pub async fn create_record(
ops,
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove: conflict_uris_to_cleanup,
},
)
.await?;
{
let backlink_repo = state.backlink_repo.clone();
futures::future::join_all(conflict_uris_to_cleanup.iter().map(|uri| {
let backlink_repo = backlink_repo.clone();
async move {
if let Err(e) = backlink_repo.remove_backlinks_by_uri(uri).await {
error!("Failed to remove backlinks for {}: {}", uri, e);
}
}
}))
.await;
}
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let backlinks = extract_backlinks(&created_uri, &input.record);
if !backlinks.is_empty()
&& let Err(e) = state.backlink_repo.add_backlinks(user_id, &backlinks).await
{
error!("Failed to add backlinks for {}: {}", created_uri, e);
}
Ok(Json(CreateRecordOutput {
uri: created_uri,
cid: record_cid.to_string(),
@@ -373,6 +367,13 @@ pub async fn put_record(
let modified_keys = [key];
let blob_cids = extract_blob_cids(&input.record);
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
let backlinks_to_remove = match is_update {
true => vec![record_uri.clone()],
false => vec![],
};
let commit_result = finalize_repo_write(
&state,
ctx,
@@ -391,12 +392,14 @@ pub async fn put_record(
ops: vec![op],
modified_keys: &modified_keys,
blob_cids: &blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
Ok(Json(PutRecordOutput {
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
uri: record_uri,
cid: record_cid.to_string(),
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
@@ -41,24 +41,27 @@ pub async fn check_account_status(
) -> Result<Json<CheckAccountStatusOutput>, ApiError> {
let did = &auth.did;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user ID for account status")?
.ok_or(ApiError::InternalError(None))?;
let is_active = state
.user_repo
.repos
.user
.is_account_active_by_did(did)
.await
.ok()
.flatten()
.unwrap_or(false);
let repo_info = state.repo_repo.get_repo(user_id).await.ok().flatten();
let repo_info = state.repos.repo.get_repo(user_id).await.ok().flatten();
let (repo_commit, repo_rev_from_db) = repo_info
.map(|r| (r.repo_root_cid.to_string(), r.repo_rev))
.unwrap_or_else(|| (String::new(), None));
let block_count: i64 = state
.repo_repo
.repos
.repo
.count_user_blocks(user_id)
.await
.unwrap_or(0);
@@ -80,19 +83,21 @@ pub async fn check_account_status(
} else {
String::new()
};
let record_count: i64 = state.repo_repo.count_records(user_id).await.unwrap_or(0);
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
let imported_blobs: i64 = state
.blob_repo
.repos
.blob
.count_blobs_by_user(user_id)
.await
.unwrap_or(0);
let expected_blobs: i64 = state
.blob_repo
.repos
.blob
.count_distinct_record_blobs(user_id)
.await
.unwrap_or(0);
let valid_did =
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await;
is_valid_did_for_service(state.repos.user.as_ref(), state.cache.clone(), did).await;
Ok(Json(CheckAccountStatusOutput {
activated: is_active,
valid_did,
@@ -319,7 +324,7 @@ pub async fn activate_account(
);
let did_validation_start = std::time::Instant::now();
if let Err(e) = assert_valid_did_document_for_service(
state.user_repo.as_ref(),
state.repos.user.as_ref(),
state.cache.clone(),
&did,
true,
@@ -339,12 +344,18 @@ pub async fn activate_account(
did_validation_start.elapsed()
);
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
let handle = state
.repos
.user
.get_handle_by_did(&did)
.await
.ok()
.flatten();
info!(
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
did, handle
);
let result = state.user_repo.activate_account(&did).await;
let result = state.repos.user.activate_account(&did).await;
match result {
Ok(_) => {
info!(
@@ -365,12 +376,20 @@ pub async fn activate_account(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
.await;
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
did
);
}
if let Err(e) = state
.repos
.repo
.update_repo_status(&did, None, None, Some(false))
.await
{
warn!("failed to sync activation to repo backend: {e:?}");
}
info!(
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
did
@@ -406,7 +425,8 @@ pub async fn activate_account(
info!("[MIGRATION] activateAccount: Identity event sequenced successfully");
}
let repo_root = state
.repo_repo
.repos
.repo
.get_repo_root_by_did(&did)
.await
.ok()
@@ -480,9 +500,19 @@ pub async fn deactivate_account(
let did = auth.did.clone();
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
let handle = state
.repos
.user
.get_handle_by_did(&did)
.await
.ok()
.flatten();
let result = state.user_repo.deactivate_account(&did, delete_after).await;
let result = state
.repos
.user
.deactivate_account(&did, delete_after)
.await;
match result {
Ok(true) => {
@@ -492,6 +522,14 @@ pub async fn deactivate_account(
.delete(&tranquil_pds::cache_keys::handle_key(h))
.await;
}
if let Err(e) = state
.repos
.repo
.update_repo_status(&did, None, None, Some(true))
.await
{
warn!("failed to sync deactivation to repo backend: {e:?}");
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
&state,
&did,
@@ -518,7 +556,8 @@ pub async fn request_account_delete(
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(session_mfa.did())
.await
.ok()
@@ -527,14 +566,15 @@ pub async fn request_account_delete(
let confirmation_token = Uuid::new_v4().to_string();
let expires_at = Utc::now() + Duration::minutes(15);
state
.infra_repo
.repos
.infra
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
.await
.log_db_err("creating deletion token")?;
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&confirmation_token,
hostname,
@@ -572,7 +612,8 @@ pub async fn delete_account(
return Err(ApiError::InvalidToken(Some("token is required".into())));
}
let user = state
.user_repo
.repos
.user
.get_user_for_deletion(did)
.await
.map_err(|e| {
@@ -582,7 +623,7 @@ pub async fn delete_account(
.ok_or(ApiError::InvalidRequest("account not found".into()))?;
let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle);
if crate::common::verify_credential(
state.session_repo.as_ref(),
state.repos.session.as_ref(),
user_id,
password,
password_hash.as_deref(),
@@ -595,7 +636,8 @@ pub async fn delete_account(
)));
}
let deletion_request = state
.infra_repo
.repos
.infra
.get_deletion_request(token)
.await
.map_err(|e| {
@@ -611,11 +653,12 @@ pub async fn delete_account(
)));
}
if Utc::now() > deletion_request.expires_at {
let _ = state.infra_repo.delete_deletion_request(token).await;
let _ = state.repos.infra.delete_deletion_request(token).await;
return Err(ApiError::ExpiredToken(None));
}
state
.user_repo
.repos
.user
.delete_account_complete(user_id, did)
.await
.map_err(|e| {
@@ -630,7 +673,7 @@ pub async fn delete_account(
.await;
match account_seq {
Ok(seq) => {
if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
warn!(
"Failed to cleanup sequences for deleted account {}: {}",
did, e
+23 -24
View File
@@ -1,7 +1,6 @@
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
use tranquil_db_traits::AppPasswordCreate;
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
@@ -32,14 +31,16 @@ pub async fn list_app_passwords(
auth: Auth<Permissive>,
) -> Result<Json<ListAppPasswordsOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.session_repo
.repos
.session
.list_app_passwords(user.id)
.await
.log_db_err("listing app passwords")?;
@@ -84,7 +85,8 @@ pub async fn create_app_password(
Json(input): Json<CreateAppPasswordInput>,
) -> Result<Json<CreateAppPasswordOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
@@ -96,7 +98,8 @@ pub async fn create_app_password(
}
if state
.session_repo
.repos
.session
.get_app_password_by_name(user.id, name)
.await
.log_db_err("checking app password")?
@@ -107,7 +110,8 @@ pub async fn create_app_password(
let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did {
let grant = state
.delegation_repo
.repos
.delegation
.get_delegation(&auth.did, controller)
.await
.ok()
@@ -133,18 +137,7 @@ pub async fn create_app_password(
let password = generate_app_password();
let password_clone = password.clone();
let password_hash =
tokio::task::spawn_blocking(move || bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let password_hash = crate::common::hash_password_async(&password).await?;
let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag(
input.privileged.unwrap_or(false),
@@ -161,14 +154,16 @@ pub async fn create_app_password(
};
state
.session_repo
.repos
.session
.create_app_password(&create_data)
.await
.log_db_err("creating app password")?;
if let Some(ref controller) = controller_did {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
controller,
@@ -204,7 +199,8 @@ pub async fn revoke_app_password(
Json(input): Json<RevokeAppPasswordInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_by_did(&auth.did)
.await
.log_db_err("getting user")?
@@ -216,13 +212,15 @@ pub async fn revoke_app_password(
}
let sessions_to_invalidate = state
.session_repo
.repos
.session
.get_session_jtis_by_app_password(&auth.did, name)
.await
.unwrap_or_default();
state
.session_repo
.repos
.session
.delete_sessions_by_app_password(&auth.did, name)
.await
.log_db_err("revoking sessions for app password")?;
@@ -237,7 +235,8 @@ pub async fn revoke_app_password(
.await;
state
.session_repo
.repos
.session
.delete_app_password(user.id, name)
.await
.log_db_err("revoking app password")?;
+41 -44
View File
@@ -22,10 +22,6 @@ use tranquil_pds::state::AppState;
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
fn email_update_cache_key(did: &str) -> String {
tranquil_pds::cache_keys::email_update_key(did)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
@@ -39,6 +35,16 @@ struct PendingEmailUpdate {
authorized: bool,
}
async fn get_pending_email_update(
cache: &dyn tranquil_pds::cache::Cache,
did: &str,
) -> Option<PendingEmailUpdate> {
cache
.get(&tranquil_pds::cache_keys::email_update_key(did))
.await
.and_then(|json| serde_json::from_str(&json).ok())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
@@ -55,7 +61,8 @@ pub async fn request_email_update(
auth.check_account_scope(AccountAttr::Email, AccountAction::Manage)?;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(&auth.did)
.await
.log_db_err("getting email info")?
@@ -92,7 +99,7 @@ pub async fn request_email_update(
authorized: false,
};
if let Ok(json) = serde_json::to_string(&pending) {
let cache_key = email_update_cache_key(&auth.did);
let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did);
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to cache pending email update: {:?}", e);
}
@@ -102,8 +109,8 @@ pub async fn request_email_update(
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_short_token_email(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&token,
hostname,
@@ -135,7 +142,8 @@ pub async fn confirm_email(
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(did)
.await
.log_db_err("getting email info")?
@@ -179,7 +187,8 @@ pub async fn confirm_email(
}
state
.user_repo
.repos
.user
.set_email_verified(user.id, true)
.await
.log_db_err("confirming email")?;
@@ -206,7 +215,8 @@ pub async fn update_email(
let did = &auth.did;
let user = state
.user_repo
.repos
.user
.get_email_info_by_did(did)
.await
.log_db_err("getting email info")?
@@ -253,7 +263,8 @@ pub async fn update_email(
}
state
.infra_repo
.repos
.infra
.upsert_account_preference(user_id, "email_auth_factor", json!(email_auth_factor))
.await
.map_err(|e| {
@@ -267,7 +278,7 @@ pub async fn update_email(
if email_verified {
let mut authorized_via_link = false;
let cache_key = email_update_cache_key(did);
let cache_key = tranquil_pds::cache_keys::email_update_key(did);
if let Some(pending_json) = state.cache.get(&cache_key).await
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
&& pending.authorized
@@ -336,7 +347,8 @@ pub async fn update_email(
}
state
.user_repo
.repos
.user
.update_email(user_id, &new_email)
.await
.log_db_err("updating email")?;
@@ -350,8 +362,8 @@ pub async fn update_email(
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Email,
&new_email,
@@ -364,7 +376,8 @@ pub async fn update_email(
}
if let Err(e) = state
.infra_repo
.repos
.infra
.upsert_account_preference(
user_id,
"email_auth_factor",
@@ -390,7 +403,8 @@ pub async fn check_email_verified(
Json(input): Json<CheckEmailVerifiedInput>,
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.repos
.user
.check_email_verified_by_identifier(&input.identifier)
.await
.map_err(|e| {
@@ -414,7 +428,8 @@ pub async fn check_channel_verified(
Json(input): Json<CheckChannelVerifiedInput>,
) -> Result<Json<VerifiedResponse>, ApiError> {
let verified = state
.user_repo
.repos
.user
.check_channel_verified_by_did(&input.did, input.channel)
.await
.map_err(|e| {
@@ -470,9 +485,9 @@ pub async fn authorize_email_update(
let did = token_data.did;
info!("authorize_email_update: token valid for did={}", did);
let cache_key = email_update_cache_key(&did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
let cache_key = tranquil_pds::cache_keys::email_update_key(&did);
let mut pending = match get_pending_email_update(state.cache.as_ref(), &did).await {
Some(p) => p,
None => {
warn!(
"authorize_email_update: no pending email update in cache for did={}",
@@ -483,13 +498,6 @@ pub async fn authorize_email_update(
}
};
let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let token_hash = hash_token(&query.token);
if pending
.token_hash
@@ -528,9 +536,8 @@ pub async fn check_email_update_status(
) -> Result<Json<EmailUpdateStatusOutput>, ApiError> {
auth.check_account_scope(AccountAttr::Email, AccountAction::Read)?;
let cache_key = email_update_cache_key(&auth.did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
let pending = match get_pending_email_update(state.cache.as_ref(), &auth.did).await {
Some(p) => p,
None => {
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
@@ -540,17 +547,6 @@ pub async fn check_email_update_status(
}
};
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return Ok(Json(EmailUpdateStatusOutput {
pending: false,
authorized: false,
new_email: None,
}));
}
};
Ok(Json(EmailUpdateStatusOutput {
pending: true,
authorized: pending.authorized,
@@ -574,7 +570,8 @@ pub async fn check_email_in_use(
}
let count = state
.user_repo
.repos
.user
.count_accounts_by_email(&email)
.await
.map_err(|e| {
+9 -24
View File
@@ -1,5 +1,4 @@
use axum::{Json, extract::State};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::error;
use tranquil_pds::api::ApiError;
@@ -7,24 +6,7 @@ use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Admin, Auth, NotTakendown};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
pub(crate) fn gen_random_token() -> String {
let mut rng = rand::thread_rng();
let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String {
(0..len)
.map(|_| BASE32_ALPHABET[rng.gen_range(0..32)] as char)
.collect()
};
format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5))
}
pub fn gen_invite_code() -> String {
let hostname = &tranquil_config::get().server.hostname;
let hostname_prefix = hostname.replace('.', "-");
format!("{}-{}", hostname_prefix, gen_random_token())
}
use tranquil_pds::util::gen_invite_code;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -58,7 +40,8 @@ pub async fn create_invite_code(
let code = gen_invite_code();
match state
.infra_repo
.repos
.infra
.create_invite_code(&code, input.use_count, Some(&for_account))
.await
{
@@ -115,7 +98,8 @@ pub async fn create_invite_codes(
};
let admin_user_id = state
.user_repo
.repos
.user
.get_any_admin_user_id()
.await
.log_db_err("looking up admin user")?
@@ -125,7 +109,7 @@ pub async fn create_invite_codes(
})?;
let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| {
let infra_repo = state.infra_repo.clone();
let infra_repo = state.repos.infra.clone();
let use_count = input.use_count;
async move {
let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect();
@@ -192,7 +176,8 @@ pub async fn get_account_invite_codes(
let include_used = params.include_used.unwrap_or(true);
let codes_info = state
.infra_repo
.repos
.infra
.get_invite_codes_for_account(&auth.did)
.await
.log_db_err("fetching invite codes")?;
@@ -203,7 +188,7 @@ pub async fn get_account_invite_codes(
.collect();
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
let infra_repo = state.infra_repo.clone();
let infra_repo = state.repos.infra.clone();
async move {
let uses = infra_repo
.get_invite_code_uses(&info.code)
+2 -2
View File
@@ -9,7 +9,7 @@ use tracing::error;
use tranquil_pds::state::AppState;
pub async fn get_logo(State(state): State<AppState>) -> Response {
let logo_cid = match state.infra_repo.get_server_config("logo_cid").await {
let logo_cid = match state.repos.infra.get_server_config("logo_cid").await {
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
@@ -26,7 +26,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
let metadata = match state.repos.blob.get_blob_metadata(&cid).await {
Ok(Some(m)) => m,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
+2 -2
View File
@@ -14,7 +14,7 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if cfg.signal.sender_number.is_some() {
if cfg.signal.enabled {
channels.push(CommsChannel::Signal);
}
channels
@@ -98,7 +98,7 @@ pub struct HealthOutput {
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match state.infra_repo.health_check().await {
match state.repos.infra.health_check().await {
Ok(true) => (
StatusCode::OK,
Json(HealthOutput {
+11 -6
View File
@@ -42,7 +42,8 @@ pub async fn update_did_document(
}
let user = state
.user_repo
.repos
.user
.get_user_for_did_doc(&auth.did)
.await
.log_db_err("getting user")?
@@ -97,7 +98,8 @@ pub async fn update_did_document(
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
state
.user_repo
.repos
.user
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
.await
.log_db_err("upserting did_web_overrides")?;
@@ -105,7 +107,8 @@ pub async fn update_did_document(
if let Some(ref endpoint) = input.service_endpoint {
let endpoint_clean = endpoint.trim().trim_end_matches('/');
state
.user_repo
.repos
.user
.update_migrated_to_pds(&auth.did, endpoint_clean)
.await
.log_db_err("updating service endpoint")?;
@@ -139,7 +142,7 @@ pub async fn get_did_document(
async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) -> serde_json::Value {
let hostname = &tranquil_config::get().server.hostname;
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
let user = match state.repos.user.get_user_for_did_doc_build(did).await {
Ok(Some(row)) => row,
_ => {
return json!({
@@ -149,7 +152,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
};
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user.id)
.await
.ok()
@@ -193,7 +197,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
}
let key_info = state
.user_repo
.repos
.user
.get_user_key_by_id(user.id)
.await
.ok()
@@ -120,7 +120,7 @@ pub async fn create_passkey_account(
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
let _validated_invite_code = if is_bootstrap {
match input.invite_code.as_deref() {
@@ -128,7 +128,7 @@ pub async fn create_passkey_account(
_ => return Err(ApiError::InvalidInviteCode),
}
} else if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
@@ -351,7 +351,7 @@ pub async fn create_passkey_account(
birthdate_pref,
};
let create_result = match state.user_repo.create_passkey_account(&create_input).await {
let create_result = match state.repos.user.create_passkey_account(&create_input).await {
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Err(ApiError::HandleNotAvailable(None));
@@ -405,7 +405,7 @@ pub async fn create_passkey_account(
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
if let Err(e) = state.repos.session.create_session(&session_data).await {
warn!(did = %did, "Failed to insert migration session: {:?}", e);
}
info!(did = %did, "Generated migration access token for BYOD passkey account");
@@ -451,7 +451,12 @@ pub async fn complete_passkey_setup(
State(state): State<AppState>,
Json(input): Json<CompletePasskeySetupInput>,
) -> Result<Json<CompletePasskeySetupOutput>, ApiError> {
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
@@ -484,7 +489,8 @@ pub async fn complete_passkey_setup(
let webauthn = &state.webauthn_config;
let reg_state = match state
.user_repo
.repos
.user
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await
{
@@ -530,7 +536,8 @@ pub async fn complete_passkey_setup(
}
};
if let Err(e) = state
.user_repo
.repos
.user
.save_passkey(
&input.did,
&credential_id,
@@ -553,13 +560,14 @@ pub async fn complete_passkey_setup(
app_password_name: app_password_name.clone(),
app_password_hash: password_hash,
};
if let Err(e) = state.user_repo.complete_passkey_setup(&setup_input).await {
if let Err(e) = state.repos.user.complete_passkey_setup(&setup_input).await {
error!("Error completing passkey setup: {:?}", e);
return Err(ApiError::InternalError(None));
}
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
.await;
@@ -577,7 +585,12 @@ pub async fn start_passkey_registration_for_setup(
State(state): State<AppState>,
Json(input): Json<StartPasskeyRegistrationInput>,
) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> {
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
let user = match state
.repos
.user
.get_user_for_passkey_setup(&input.did)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::AccountNotFound);
@@ -610,7 +623,8 @@ pub async fn start_passkey_registration_for_setup(
let webauthn = &state.webauthn_config;
let existing_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&input.did)
.await
.unwrap_or_default();
@@ -643,7 +657,8 @@ pub async fn start_passkey_registration_for_setup(
}
};
if let Err(e) = state
.user_repo
.repos
.user
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
.await
{
@@ -682,7 +697,8 @@ pub async fn request_passkey_recovery(
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
let user = match state
.user_repo
.repos
.user
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
.await
{
@@ -697,7 +713,8 @@ pub async fn request_passkey_recovery(
let expires_at = Utc::now() + Duration::hours(1);
if let Err(e) = state
.user_repo
.repos
.user
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
.await
{
@@ -714,8 +731,8 @@ pub async fn request_passkey_recovery(
);
let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&recovery_url,
hostname,
@@ -742,7 +759,7 @@ pub async fn recover_passkey_account(
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.user_repo.get_user_for_recovery(&input.did).await {
let user = match state.repos.user.get_user_for_recovery(&input.did).await {
Ok(Some(u)) => u,
_ => {
return Err(ApiError::InvalidRecoveryLink);
@@ -771,7 +788,8 @@ pub async fn recover_passkey_account(
password_hash,
};
let result = match state
.user_repo
.repos
.user
.recover_passkey_account(&recover_input)
.await
{
@@ -785,11 +803,12 @@ pub async fn recover_passkey_account(
if result.passkeys_deleted > 0 {
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
}
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await {
let actual_channel =
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.repos
.user
.set_channel_verified(&input.did, actual_channel)
.await
{
+18 -10
View File
@@ -28,14 +28,16 @@ pub async fn start_passkey_registration(
let webauthn = &state.webauthn_config;
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let existing_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("fetching existing passkeys")?;
@@ -60,7 +62,8 @@ pub async fn start_passkey_registration(
})?;
state
.user_repo
.repos
.user
.save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json)
.await
.log_db_err("saving registration state")?;
@@ -94,7 +97,8 @@ pub async fn finish_passkey_registration(
let webauthn = &state.webauthn_config;
let reg_state_json = state
.user_repo
.repos
.user
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
.log_db_err("loading registration state")?
@@ -125,7 +129,8 @@ pub async fn finish_passkey_registration(
})?;
let passkey_id = state
.user_repo
.repos
.user
.save_passkey(
&auth.did,
passkey.cred_id(),
@@ -136,7 +141,8 @@ pub async fn finish_passkey_registration(
.log_db_err("saving passkey")?;
if let Err(e) = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration)
.await
{
@@ -177,7 +183,8 @@ pub async fn list_passkeys(
auth: Auth<Active>,
) -> Result<Json<ListPasskeysOutput>, ApiError> {
let passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("fetching passkeys")?;
@@ -215,7 +222,7 @@ pub async fn delete_passkey(
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
match state.user_repo.delete_passkey(id, reauth_mfa.did()).await {
match state.repos.user.delete_passkey(id, reauth_mfa.did()).await {
Ok(true) => {
info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted");
Ok(Json(EmptyResponse {}))
@@ -243,7 +250,8 @@ pub async fn update_passkey(
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
match state
.user_repo
.repos
.user
.update_passkey_name(id, &auth.did, &input.friendly_name)
.await
{
@@ -260,5 +268,5 @@ pub async fn update_passkey(
}
pub async fn has_passkeys_for_user(state: &AppState, did: &tranquil_pds::types::Did) -> bool {
state.user_repo.has_passkeys(did).await.unwrap_or(false)
state.repos.user.has_passkeys(did).await.unwrap_or(false)
}
+33 -54
View File
@@ -1,5 +1,4 @@
use axum::{Json, extract::State};
use bcrypt::{DEFAULT_COST, hash};
use chrono::{Duration, Utc};
use serde::Deserialize;
use tracing::{error, info, warn};
@@ -42,7 +41,7 @@ pub async fn request_password_reset(
let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles);
let multiple_accounts_warning = if is_email_lookup {
match state.user_repo.count_accounts_by_email(normalized).await {
match state.repos.user.count_accounts_by_email(normalized).await {
Ok(count) if count > 1 => Some(count),
_ => None,
}
@@ -51,7 +50,8 @@ pub async fn request_password_reset(
};
let user_id = match state
.user_repo
.repos
.user
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
.await
{
@@ -73,7 +73,8 @@ pub async fn request_password_reset(
let code = generate_reset_code();
let expires_at = Utc::now() + Duration::minutes(10);
if let Err(e) = state
.user_repo
.repos
.user
.set_password_reset_code(user_id, &code, expires_at)
.await
{
@@ -82,8 +83,8 @@ pub async fn request_password_reset(
}
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&code,
hostname,
@@ -132,7 +133,7 @@ pub async fn reset_password(
if let Err(e) = validate_password(password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.user_repo.get_user_by_reset_code(token).await {
let user = match state.repos.user.get_user_by_reset_code(token).await {
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::InvalidToken(None));
@@ -147,26 +148,15 @@ pub async fn reset_password(
return Err(ApiError::InvalidToken(None));
};
if Utc::now() > exp {
if let Err(e) = state.user_repo.clear_password_reset_code(user_id).await {
if let Err(e) = state.repos.user.clear_password_reset_code(user_id).await {
error!("Failed to clear expired reset code: {:?}", e);
}
return Err(ApiError::ExpiredToken(None));
}
let password_clone = password.to_string();
let password_hash =
match tokio::task::spawn_blocking(move || hash(password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return Err(ApiError::InternalError(None));
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let password_hash = crate::common::hash_password_async(password).await?;
let result = match state
.user_repo
.repos
.user
.reset_password_with_sessions(user_id, &password_hash)
.await
{
@@ -189,11 +179,12 @@ pub async fn reset_password(
}
}))
.await;
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user_id).await {
let actual_channel =
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
if let Err(e) = state
.user_repo
.repos
.user
.set_channel_verified(&user.did, actual_channel)
.await
{
@@ -238,26 +229,18 @@ pub async fn change_password(
let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?;
let user = state
.user_repo
.repos
.user
.get_id_and_password_hash_by_did(password_mfa.did())
.await
.log_db_err("in change_password")?
.ok_or(ApiError::AccountNotFound)?;
let new_password_clone = input.new_password.to_string();
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let new_hash = crate::common::hash_password_async(&input.new_password).await?;
state
.user_repo
.repos
.user
.update_password_hash(user.id, &new_hash)
.await
.log_db_err("updating password")?;
@@ -271,7 +254,8 @@ pub async fn get_password_status(
auth: Auth<Active>,
) -> Result<Json<HasPasswordResponse>, ApiError> {
let has = state
.user_repo
.repos
.user
.has_password_by_did(&auth.did)
.await
.log_db_err("checking password status")?
@@ -288,7 +272,8 @@ pub async fn remove_password(
let reauth_mfa = require_reauth_window(&state, &auth).await?;
let has_passkeys = state
.user_repo
.repos
.user
.has_passkeys(reauth_mfa.did())
.await
.unwrap_or(false);
@@ -299,7 +284,8 @@ pub async fn remove_password(
}
let user = state
.user_repo
.repos
.user
.get_password_info_by_did(reauth_mfa.did())
.await
.log_db_err("getting password info")?
@@ -312,7 +298,8 @@ pub async fn remove_password(
}
state
.user_repo
.repos
.user
.remove_user_password(user.id)
.await
.log_db_err("removing password")?;
@@ -345,7 +332,8 @@ pub async fn set_password(
let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did);
let user = state
.user_repo
.repos
.user
.get_password_info_by_did(did)
.await
.log_db_err("getting password info")?
@@ -357,20 +345,11 @@ pub async fn set_password(
));
}
let new_password_clone = new_password.to_string();
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let new_hash = crate::common::hash_password_async(new_password).await?;
state
.user_repo
.repos
.user
.set_new_user_password(user.id, &new_hash)
.await
.log_db_err("setting password")?;
+22 -13
View File
@@ -33,13 +33,14 @@ pub async fn get_reauth_status(
auth: Auth<Active>,
) -> Result<Json<ReauthStatusOutput>, ApiError> {
let last_reauth_at = state
.session_repo
.repos
.session
.get_last_reauth_at(&auth.did)
.await
.log_db_err("getting last reauth")?;
let reauth_required = is_reauth_required(last_reauth_at);
let available_methods = get_available_reauth_methods(&*state.user_repo, &auth.did).await;
let available_methods = get_available_reauth_methods(&*state.repos.user, &auth.did).await;
Ok(Json(ReauthStatusOutput {
last_reauth_at,
@@ -66,7 +67,8 @@ pub async fn reauth_password(
Json(input): Json<PasswordReauthInput>,
) -> Result<Json<ReauthOutput>, ApiError> {
let password_hash = state
.user_repo
.repos
.user
.get_password_hash_by_did(&auth.did)
.await
.log_db_err("fetching password hash")?
@@ -76,7 +78,8 @@ pub async fn reauth_password(
if !password_valid {
let app_password_hashes = state
.session_repo
.repos
.session
.get_app_password_hashes_by_did(&auth.did)
.await
.unwrap_or_default();
@@ -91,7 +94,7 @@ pub async fn reauth_password(
}
}
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;
@@ -127,7 +130,7 @@ pub async fn reauth_totp(
)));
}
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;
@@ -146,7 +149,8 @@ pub async fn reauth_passkey_start(
auth: Auth<Active>,
) -> Result<Json<PasskeyReauthStartOutput>, ApiError> {
let stored_passkeys = state
.user_repo
.repos
.user
.get_passkeys_for_user(&auth.did)
.await
.log_db_err("getting passkeys")?;
@@ -179,7 +183,8 @@ pub async fn reauth_passkey_start(
})?;
state
.user_repo
.repos
.user
.save_webauthn_challenge(
&auth.did,
WebauthnChallengeType::Authentication,
@@ -204,7 +209,8 @@ pub async fn reauth_passkey_finish(
Json(input): Json<PasskeyReauthFinishInput>,
) -> Result<Json<ReauthOutput>, ApiError> {
let auth_state_json = state
.user_repo
.repos
.user
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await
.log_db_err("loading authentication state")?
@@ -232,7 +238,8 @@ pub async fn reauth_passkey_finish(
let cred_id_bytes = auth_result.cred_id().as_ref();
match state
.user_repo
.repos
.user
.update_passkey_counter(
cred_id_bytes,
i32::try_from(auth_result.counter()).unwrap_or(i32::MAX),
@@ -242,7 +249,8 @@ pub async fn reauth_passkey_finish(
Ok(false) => {
warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key");
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
return Err(ApiError::PasskeyCounterAnomaly);
@@ -254,11 +262,12 @@ pub async fn reauth_passkey_finish(
}
let _ = state
.user_repo
.repos
.user
.delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
.await;
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did)
.await
.log_db_err("updating reauth")?;
@@ -72,7 +72,7 @@ pub async fn get_service_auth(
Some(kb) => kb.clone(),
None => {
warn!(did = %&auth.did, "getServiceAuth: no key_bytes in auth, fetching from DB");
match state.user_repo.get_user_info_by_did(&auth.did).await {
match state.repos.user.get_user_info_by_did(&auth.did).await {
Ok(Some(info)) => match info.key_bytes {
Some(key_bytes_enc) => {
match tranquil_pds::config::decrypt_key(
@@ -112,7 +112,6 @@ pub async fn get_service_auth(
};
let lxm = params.lxm.as_ref();
let lxm_for_token = lxm.map_or("*", |v| v.as_str());
if let Some(method) = lxm {
if let Err(e) = tranquil_pds::auth::scope_check::check_rpc_scope(
@@ -173,7 +172,7 @@ pub async fn get_service_auth(
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
params.aud.as_str(),
lxm_for_token,
lxm.map(|v| v.as_str()),
&key_bytes,
) {
Ok(t) => t,
+57 -38
View File
@@ -69,7 +69,8 @@ pub async fn create_session(
input.identifier, normalized_identifier
);
let row = match state
.user_repo
.repos
.user
.get_login_full_by_identifier(normalized_identifier.as_str())
.await
{
@@ -98,7 +99,7 @@ pub async fn create_session(
}
};
let credential = crate::common::verify_credential(
state.session_repo.as_ref(),
state.repos.session.as_ref(),
row.id,
&input.password,
row.password_hash.as_deref(),
@@ -130,7 +131,8 @@ pub async fn create_session(
}
let is_verified = row.channel_verification.has_any_verified();
let is_delegated = state
.delegation_repo
.repos
.delegation
.is_delegated_account(&row.did)
.await
.unwrap_or(false);
@@ -181,8 +183,8 @@ pub async fn create_session(
Ok(tranquil_pds::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_2fa_code(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
row.id,
code.as_str(),
hostname,
@@ -269,8 +271,8 @@ pub async fn create_session(
app_password_name: app_password_name.clone(),
};
let (insert_result, did_doc) = tokio::join!(
state.session_repo.create_session(&session_data),
did_resolver.resolve_did_document(&did_for_doc)
state.repos.session.create_session(&session_data),
did_resolver.fetch_did_document(&did_for_doc),
);
if let Err(e) = insert_result {
error!("Failed to insert session: {:?}", e);
@@ -284,8 +286,8 @@ pub async fn create_session(
);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_legacy_login(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
row.id,
hostname,
client_ip,
@@ -311,7 +313,7 @@ pub async fn create_session(
refresh_jwt: refresh_meta.token,
handle,
did: row.did,
did_doc,
did_doc: did_doc.ok().map(|f| (*f).clone()),
email: row.email,
email_confirmed: Some(row.channel_verification.email),
email_auth_factor: email_auth_factor_out,
@@ -359,8 +361,8 @@ pub async fn get_session(
let did_for_doc = auth.did.clone();
let did_resolver = state.did_resolver.clone();
let (db_result, did_doc) = tokio::join!(
state.user_repo.get_session_info_by_did(&auth.did),
did_resolver.resolve_did_document(&did_for_doc)
state.repos.user.get_session_info_by_did(&auth.did),
did_resolver.fetch_did_document(&did_for_doc)
);
match db_result {
Ok(Some(row)) => {
@@ -404,7 +406,7 @@ pub async fn get_session(
status: account_state.status_for_session().map(String::from),
migrated_to_pds,
migrated_at,
did_doc,
did_doc: did_doc.ok().map(|f| (*f).clone()),
}))
}
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
@@ -422,7 +424,7 @@ pub async fn delete_session(
) -> Result<Json<EmptyResponse>, ApiError> {
let jti = tranquil_pds::auth::extract_jti_from_headers(&headers)
.ok_or(ApiError::AuthenticationRequired)?;
match state.session_repo.delete_session_by_access_jti(&jti).await {
match state.repos.session.delete_session_by_access_jti(&jti).await {
Ok(rows) if rows > 0 => {
let session_cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &jti);
let _ = state.cache.delete(&session_cache_key).await;
@@ -476,7 +478,8 @@ pub async fn refresh_session(
}
};
if let Ok(Some(_)) = state
.session_repo
.repos
.session
.check_refresh_token_used(&refresh_jti)
.await
{
@@ -486,7 +489,8 @@ pub async fn refresh_session(
)));
}
let session_row = match state
.session_repo
.repos
.session
.get_session_for_refresh(&refresh_jti)
.await
{
@@ -548,7 +552,8 @@ pub async fn refresh_session(
new_refresh_expires_at: new_refresh_meta.expires_at,
};
match state
.session_repo
.repos
.session
.refresh_session_atomic(&refresh_data)
.await
{
@@ -576,8 +581,8 @@ pub async fn refresh_session(
let did_for_doc = session_row.did.clone();
let did_resolver = state.did_resolver.clone();
let (db_result, did_doc) = tokio::join!(
state.user_repo.get_session_info_by_did(&session_row.did),
did_resolver.resolve_did_document(&did_for_doc)
state.repos.user.get_session_info_by_did(&session_row.did),
did_resolver.fetch_did_document(&did_for_doc)
);
match db_result {
Ok(Some(u)) => {
@@ -599,7 +604,7 @@ pub async fn refresh_session(
preferred_locale: u.preferred_locale,
is_admin: u.is_admin,
active: account_state.is_active(),
did_doc,
did_doc: did_doc.ok().map(|f| (*f).clone()),
status: account_state.status_for_session().map(String::from),
}))
}
@@ -639,7 +644,7 @@ pub async fn confirm_signup(
Json(input): Json<ConfirmSignupInput>,
) -> Result<Json<ConfirmSignupOutput>, ApiError> {
info!("confirm_signup called for DID: {}", input.did);
let row = match state.user_repo.get_confirm_signup_by_did(&input.did).await {
let row = match state.repos.user.get_confirm_signup_by_did(&input.did).await {
Ok(Some(row)) => row,
Ok(None) => {
warn!("User not found for confirm_signup: {}", input.did);
@@ -702,7 +707,8 @@ pub async fn confirm_signup(
};
if let Err(e) = state
.user_repo
.repos
.user
.set_channel_verified(&input.did, row.channel)
.await
{
@@ -726,8 +732,8 @@ pub async fn confirm_signup(
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
row.id,
hostname,
)
@@ -757,7 +763,7 @@ pub struct AutoResendResult {
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
let debounce_key = tranquil_pds::cache_keys::auto_verify_sent_key(did.as_str());
let debounced = state.cache.get(&debounce_key).await.is_some();
let row = match state.user_repo.get_resend_verification_by_did(did).await {
let row = match state.repos.user.get_resend_verification_by_did(did).await {
Ok(Some(row)) => row,
Ok(None) => return None,
Err(e) => {
@@ -821,7 +827,8 @@ pub async fn resend_verification(
) -> Result<Json<SuccessResponse>, ApiError> {
info!("resend_verification called for DID: {}", input.did);
let row = match state
.user_repo
.repos
.user
.get_resend_verification_by_did(&input.did)
.await
{
@@ -895,13 +902,15 @@ pub async fn list_sessions(
let current_jti = tranquil_pds::auth::extract_jti_from_headers(&headers);
let jwt_rows = state
.session_repo
.repos
.session
.list_sessions_by_did(&auth.did)
.await
.log_db_err("fetching JWT sessions")?;
let oauth_rows = state
.oauth_repo
.repos
.oauth
.list_sessions_by_did(&auth.did)
.await
.log_db_err("fetching OAuth sessions")?;
@@ -962,13 +971,15 @@ pub async fn revoke_session(
.map(SessionId::new)
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
let access_jti = state
.session_repo
.repos
.session
.get_session_access_jti_by_id(session_id, &auth.did)
.await
.log_db_err("in revoke_session")?
.ok_or(ApiError::SessionNotFound)?;
state
.session_repo
.repos
.session
.delete_session_by_id(session_id)
.await
.log_db_err("deleting session")?;
@@ -983,7 +994,8 @@ pub async fn revoke_session(
.map(TokenFamilyId::new)
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
let deleted = state
.oauth_repo
.repos
.oauth
.delete_session_by_id(session_id, &auth.did)
.await
.log_db_err("deleting OAuth session")?;
@@ -1007,24 +1019,28 @@ pub async fn revoke_all_sessions(
if auth.is_oauth() {
state
.session_repo
.repos
.session
.delete_sessions_by_did(&auth.did)
.await
.log_db_err("revoking JWT sessions")?;
let jti_typed = TokenId::from(jti.clone());
state
.oauth_repo
.repos
.oauth
.delete_sessions_by_did_except(&auth.did, &jti_typed)
.await
.log_db_err("revoking OAuth sessions")?;
} else {
state
.session_repo
.repos
.session
.delete_sessions_by_did_except_jti(&auth.did, &jti)
.await
.log_db_err("revoking JWT sessions")?;
state
.oauth_repo
.repos
.oauth
.delete_sessions_by_did(&auth.did)
.await
.log_db_err("revoking OAuth sessions")?;
@@ -1046,7 +1062,8 @@ pub async fn get_legacy_login_preference(
auth: Auth<Active>,
) -> Result<Json<LegacyLoginPreferenceOutput>, ApiError> {
let pref = state
.user_repo
.repos
.user
.get_legacy_login_pref(&auth.did)
.await
.log_db_err("getting legacy login pref")?
@@ -1079,7 +1096,8 @@ pub async fn update_legacy_login_preference(
let reauth_mfa = require_reauth_window(&state, &auth).await?;
let updated = state
.user_repo
.repos
.user
.update_legacy_login(reauth_mfa.did(), input.allow_legacy_login)
.await
.log_db_err("updating legacy login")?;
@@ -1117,7 +1135,8 @@ pub async fn update_locale(
}
let updated = state
.user_repo
.repos
.user
.update_locale(&auth.did, &input.preferred_locale)
.await
.log_db_err("updating locale")?;
@@ -44,7 +44,8 @@ pub async fn reserve_signing_key(
let expires_at = Utc::now() + Duration::hours(24);
let private_bytes: &[u8] = &private_key_bytes;
match state
.infra_repo
.repos
.infra
.reserve_signing_key(
input.did.as_ref(),
&public_key_did_key,
+25 -14
View File
@@ -29,7 +29,7 @@ pub async fn create_totp_secret(
) -> Result<Json<CreateTotpSecretOutput>, ApiError> {
use tranquil_db_traits::TotpRecordState;
match state.user_repo.get_totp_record_state(&auth.did).await {
match state.repos.user.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => {}
Err(e) => {
@@ -41,7 +41,8 @@ pub async fn create_totp_secret(
let secret = generate_totp_secret();
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.log_db_err("fetching handle")?
@@ -61,7 +62,8 @@ pub async fn create_totp_secret(
})?;
state
.user_repo
.repos
.user
.upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION)
.await
.log_db_err("storing TOTP secret")?;
@@ -102,7 +104,7 @@ pub async fn enable_totp(
)
.await?;
let unverified_record = match state.user_repo.get_totp_record_state(&auth.did).await {
let unverified_record = match state.repos.user.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Unverified(record))) => record,
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
Ok(None) => return Err(ApiError::TotpNotEnabled),
@@ -139,7 +141,8 @@ pub async fn enable_totp(
})?;
state
.user_repo
.repos
.user
.enable_totp_with_backup_codes(&auth.did, &backup_hashes)
.await
.log_db_err("enabling TOTP")?;
@@ -173,7 +176,8 @@ pub async fn disable_totp(
let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?;
state
.user_repo
.repos
.user
.delete_totp_and_backup_codes(totp_mfa.did())
.await
.log_db_err("deleting TOTP")?;
@@ -199,7 +203,7 @@ pub async fn get_totp_status(
) -> Result<Json<GetTotpStatusOutput>, ApiError> {
use tranquil_db_traits::TotpRecordState;
let enabled = match state.user_repo.get_totp_record_state(&auth.did).await {
let enabled = match state.repos.user.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Verified(_))) => true,
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => false,
Err(e) => {
@@ -209,7 +213,8 @@ pub async fn get_totp_status(
};
let backup_count = state
.user_repo
.repos
.user
.count_unused_backup_codes(&auth.did)
.await
.log_db_err("counting backup codes")?;
@@ -259,7 +264,8 @@ pub async fn regenerate_backup_codes(
})?;
state
.user_repo
.repos
.user
.replace_backup_codes(totp_mfa.did(), &backup_hashes)
.await
.log_db_err("replacing backup codes")?;
@@ -276,7 +282,7 @@ async fn verify_backup_code_for_user(
) -> bool {
let code = code.trim().to_uppercase();
let backup_codes = match state.user_repo.get_unused_backup_codes(did).await {
let backup_codes = match state.repos.user.get_unused_backup_codes(did).await {
Ok(codes) => codes,
Err(e) => {
warn!("Failed to fetch backup codes: {:?}", e);
@@ -290,7 +296,7 @@ async fn verify_backup_code_for_user(
match matched {
Some(row) => {
let _ = state.user_repo.mark_backup_code_used(row.id).await;
let _ = state.repos.user.mark_backup_code_used(row.id).await;
true
}
None => false,
@@ -310,7 +316,7 @@ pub async fn verify_totp_or_backup_for_user(
return verify_backup_code_for_user(state, did, code).await;
}
let verified_record = match state.user_repo.get_totp_record_state(did).await {
let verified_record = match state.repos.user.get_totp_record_state(did).await {
Ok(Some(TotpRecordState::Verified(record))) => record,
_ => return false,
};
@@ -324,7 +330,7 @@ pub async fn verify_totp_or_backup_for_user(
};
if verify_totp_code(&secret, code) {
let _ = state.user_repo.update_totp_last_used(did).await;
let _ = state.repos.user.update_totp_last_used(did).await;
return true;
}
@@ -332,5 +338,10 @@ pub async fn verify_totp_or_backup_for_user(
}
pub async fn has_totp_enabled(state: &AppState, did: &tranquil_pds::types::Did) -> bool {
state.user_repo.has_totp_enabled(did).await.unwrap_or(false)
state
.repos
.user
.has_totp_enabled(did)
.await
.unwrap_or(false)
}
@@ -72,7 +72,8 @@ pub async fn list_trusted_devices(
auth: Auth<Active>,
) -> Result<Json<ListTrustedDevicesOutput>, ApiError> {
let rows = state
.oauth_repo
.repos
.oauth
.list_trusted_devices(&auth.did)
.await
.log_db_err("listing trusted devices")?;
@@ -108,7 +109,8 @@ pub async fn revoke_trusted_device(
Json(input): Json<RevokeTrustedDeviceInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.oauth_repo
.repos
.oauth
.device_belongs_to_user(&input.device_id, &auth.did)
.await
{
@@ -123,8 +125,9 @@ pub async fn revoke_trusted_device(
}
state
.oauth_repo
.revoke_device_trust(&input.device_id)
.repos
.oauth
.revoke_device_trust(&input.device_id, &auth.did)
.await
.log_db_err("revoking device trust")?;
@@ -145,7 +148,8 @@ pub async fn update_trusted_device(
Json(input): Json<UpdateTrustedDeviceInput>,
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.oauth_repo
.repos
.oauth
.device_belongs_to_user(&input.device_id, &auth.did)
.await
{
@@ -160,8 +164,9 @@ pub async fn update_trusted_device(
}
state
.oauth_repo
.update_device_friendly_name(&input.device_id, input.friendly_name.as_deref())
.repos
.oauth
.update_device_friendly_name(&input.device_id, &auth.did, input.friendly_name.as_deref())
.await
.log_db_err("updating device friendly name")?;
@@ -193,18 +198,22 @@ pub async fn is_device_trusted(
pub async fn trust_device(
oauth_repo: &dyn OAuthRepository,
device_id: &DeviceId,
did: &tranquil_types::Did,
) -> Result<(), tranquil_db_traits::DbError> {
let now = Utc::now();
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
oauth_repo.trust_device(device_id, now, trusted_until).await
oauth_repo
.trust_device(device_id, did, now, trusted_until)
.await
}
pub async fn extend_device_trust(
oauth_repo: &dyn OAuthRepository,
device_id: &DeviceId,
did: &tranquil_types::Did,
) -> Result<(), tranquil_db_traits::DbError> {
let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
oauth_repo
.extend_device_trust(device_id, trusted_until)
.extend_device_trust(device_id, did, trusted_until)
.await
}
@@ -59,7 +59,7 @@ pub async fn resend_migration_verification(
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let identifier = input.identifier.trim().to_lowercase();
let user = match state.user_repo.get_by_email(&identifier).await {
let user = match state.repos.user.get_by_email(&identifier).await {
Ok(Some(u)) => u,
Ok(None) => {
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
+39 -42
View File
@@ -79,7 +79,8 @@ async fn handle_migration_verification(
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_verification_info(did)
.await
.log_db_err("during migration verification")?
@@ -92,13 +93,14 @@ async fn handle_migration_verification(
}
if !user.channel_verification.email {
state
.user_repo
.repos
.user
.set_email_verified_flag(user.id)
.await
.log_db_err("updating email_verified status")?;
}
}
_ => common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?,
_ => common::set_channel_verified_flag(state.repos.user.as_ref(), user.id, channel).await?,
};
info!(did = %did, channel = ?channel, "Migration verification completed successfully");
@@ -118,7 +120,8 @@ async fn handle_channel_update(
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user id")?
@@ -127,7 +130,8 @@ async fn handle_channel_update(
match channel {
CommsChannel::Email => {
let success = state
.user_repo
.repos
.user
.verify_email_channel(user_id, identifier)
.await
.log_db_err("updating email channel")?;
@@ -137,21 +141,24 @@ async fn handle_channel_update(
}
CommsChannel::Discord => {
state
.user_repo
.repos
.user
.verify_discord_channel(user_id, identifier)
.await
.log_db_err("updating discord channel")?;
}
CommsChannel::Telegram => {
state
.user_repo
.repos
.user
.verify_telegram_channel(user_id, identifier)
.await
.log_db_err("updating telegram channel")?;
}
CommsChannel::Signal => {
state
.user_repo
.repos
.user
.verify_signal_channel(user_id, identifier)
.await
.log_db_err("updating signal channel")?;
@@ -160,19 +167,7 @@ async fn handle_channel_update(
info!(did = %did, channel = ?channel, "Channel verified successfully");
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
channel,
&recipient,
&tranquil_config::get().server.hostname,
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
notify_channel_verified(state, user_id, channel, identifier).await;
Ok(Json(VerifyTokenOutput {
success: true,
@@ -182,15 +177,16 @@ async fn handle_channel_update(
}))
}
async fn resolve_verified_recipient(
async fn notify_channel_verified(
state: &AppState,
user_id: uuid::Uuid,
channel: tranquil_db_traits::CommsChannel,
channel: CommsChannel,
identifier: &str,
) -> String {
match channel {
tranquil_db_traits::CommsChannel::Telegram => state
.user_repo
) {
let recipient = match channel {
CommsChannel::Telegram => state
.repos
.user
.get_telegram_chat_id(user_id)
.await
.ok()
@@ -198,6 +194,18 @@ async fn resolve_verified_recipient(
.map(|id| id.to_string())
.unwrap_or_else(|| identifier.to_string()),
_ => identifier.to_string(),
};
if let Err(e) = comms_repo::enqueue_channel_verified(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
&recipient,
&tranquil_config::get().server.hostname,
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
}
@@ -208,7 +216,8 @@ async fn handle_signup_verification(
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user = state
.user_repo
.repos
.user
.get_verification_info(did)
.await
.log_db_err("during signup verification")?
@@ -225,23 +234,11 @@ async fn handle_signup_verification(
}));
}
common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?;
common::set_channel_verified_flag(state.repos.user.as_ref(), user.id, channel).await?;
info!(did = %did, channel = ?channel, "Signup verified successfully");
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
channel,
&recipient,
&tranquil_config::get().server.hostname,
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
notify_channel_verified(state, user.id, channel, identifier).await;
Ok(Json(VerifyTokenOutput {
success: true,
+4 -3
View File
@@ -71,7 +71,8 @@ pub async fn handle_telegram_webhook(
"Received /start from Telegram user"
);
match state
.user_repo
.repos
.user
.store_telegram_chat_id(&username, from.id, handle.as_deref())
.await
{
@@ -82,8 +83,8 @@ pub async fn handle_telegram_webhook(
"Verified Telegram user and stored chat_id"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Telegram,
&from.id.to_string(),
+7 -2
View File
@@ -74,7 +74,12 @@ pub fn create_refresh_token_with_metadata(
)
}
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
pub fn create_service_token(
did: &str,
aud: &str,
lxm: Option<&str>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
@@ -89,7 +94,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
exp: expiration,
iat: Utc::now().timestamp(),
scope: None,
lxm: Some(lxm.to_string()),
lxm: lxm.map(ToOwned::to_owned),
jti: uuid::Uuid::new_v4().to_string(),
act: None,
};
+3
View File
@@ -6,12 +6,15 @@ license.workspace = true
[dependencies]
tranquil-config = { workspace = true }
tranquil-signal = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tranquil-db-traits = { workspace = true }
uuid = { workspace = true }
+87 -175
View File
@@ -6,7 +6,6 @@ use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::types::{CommsChannel, QueuedComms};
@@ -58,6 +57,49 @@ async fn retry_delay(attempt: u32) {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
async fn send_http_with_retry<F, Fut>(service_name: &str, send_request: F) -> Result<(), SendError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
match send_request().await {
Ok(response) => {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("{service_name} API returned {status}"));
retry_delay(attempt).await;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"{service_name} API returned {status}: {body}",
)));
}
Err(e) => {
if e.is_timeout() {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("{service_name} request timed out"));
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
return Err(SendError::ExternalService(format!(
"{service_name} request failed: {e}",
)));
}
}
}
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "unknown error".to_string()),
))
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], " ").trim().to_string()
}
@@ -90,22 +132,7 @@ pub fn is_valid_phone_number(number: &str) -> bool {
}
pub fn is_valid_signal_username(username: &str) -> bool {
if username.len() < 6 || username.len() > 35 {
return false;
}
let Some((base, discriminator)) = username.rsplit_once('.') else {
return false;
};
if base.len() < 3 || base.len() > 32 {
return false;
}
if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return false;
}
if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit())
tranquil_signal::SignalUsername::parse(username).is_ok()
}
pub struct EmailSender {
@@ -414,51 +441,14 @@ impl CommsSender for DiscordSender {
let payload = json!({ "content": content });
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = self
.http_client
send_http_with_retry("Discord", || {
self.http_client
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await;
match result {
Ok(response) => {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Discord API returned {}", status));
retry_delay(attempt).await;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord API returned {}: {}",
status, body
)));
}
Err(e) => {
if e.is_timeout() {
if attempt < MAX_RETRIES - 1 {
last_error = Some("Discord request timed out".to_string());
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
return Err(SendError::ExternalService(format!(
"Discord request failed: {}",
e
)));
}
}
}
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
))
})
.await
}
}
@@ -552,79 +542,22 @@ impl CommsSender for TelegramSender {
"text": text,
"parse_mode": "HTML"
});
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = self.http_client.post(&url).json(&payload).send().await;
match result {
Ok(response) => {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Telegram API returned {}", status));
retry_delay(attempt).await;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Telegram API returned {}: {}",
status, body
)));
}
Err(e) => {
if e.is_timeout() {
if attempt < MAX_RETRIES - 1 {
last_error = Some("Telegram request timed out".to_string());
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
return Err(SendError::ExternalService(format!(
"Telegram request failed: {}",
e
)));
}
}
}
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
))
send_http_with_retry("Telegram", || {
self.http_client.post(&url).json(&payload).send()
})
.await
}
}
pub struct SignalSender {
signal_cli_path: String,
sender_number: String,
slot: std::sync::Arc<tranquil_signal::SignalSlot>,
}
impl SignalSender {
pub fn new(signal_cli_path: String, sender_number: String) -> Self {
Self {
signal_cli_path,
sender_number,
}
pub fn new(slot: std::sync::Arc<tranquil_signal::SignalSlot>) -> Self {
Self { slot }
}
pub fn from_config(cfg: &tranquil_config::TranquilConfig) -> Option<Self> {
let signal_cli_path = cfg.signal.cli_path.clone();
let sender_number = cfg.signal.sender_number.clone()?;
Some(Self::new(signal_cli_path, sender_number))
}
}
const SIGNAL_TIMEOUT_SECS: u64 = 30;
fn is_retryable_signal_error(stderr: &str) -> bool {
let lower = stderr.to_lowercase();
lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("connection refused")
|| lower.contains("network")
|| lower.contains("temporarily")
|| lower.contains("try again")
|| lower.contains("rate limit")
}
#[async_trait]
@@ -634,67 +567,46 @@ impl CommsSender for SignalSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let recipient = &notification.recipient;
if !is_valid_signal_username(recipient) {
return Err(SendError::InvalidRecipient(format!(
"Invalid Signal username format: {}",
recipient
)));
}
let username = tranquil_signal::SignalUsername::parse(&notification.recipient)
.map_err(|e| SendError::InvalidRecipient(e.to_string()))?;
let client = self
.slot
.client()
.await
.ok_or(SendError::NotConfigured(CommsChannel::Signal))?;
let subject = notification.subject.as_deref().unwrap_or("Notification");
let message = format!("{}\n\n{}", subject, notification.body);
let raw_message = format!("{}\n\n{}", subject, notification.body);
let message = tranquil_signal::MessageBody::new(raw_message)
.map_err(|e| SendError::InvalidRecipient(e.to_string()))?;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let cmd_future = Command::new(&self.signal_cli_path)
.arg("-u")
.arg(&self.sender_number)
.arg("send")
.arg("--username")
.arg(recipient)
.arg("-m")
.arg(&message)
.output();
let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await;
match result {
Ok(Ok(output)) if output.status.success() => return Ok(()),
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli failed: {}", stderr));
retry_delay(attempt).await;
continue;
match client.send(&username, message.clone()).await {
Ok(()) => return Ok(()),
Err(e) => {
let err_str = e.to_string();
match &e {
tranquil_signal::SignalError::UsernameNotFound(_)
| tranquil_signal::SignalError::UsernameLookup(_)
| tranquil_signal::SignalError::NotLinked => {
return Err(SendError::ExternalService(format!(
"signal send failed: {err_str}"
)));
}
_ => {
last_error = Some(err_str);
if attempt < MAX_RETRIES - 1 {
retry_delay(attempt).await;
}
}
}
return Err(SendError::ExternalService(format!(
"signal-cli failed: {}",
stderr
)));
}
Ok(Err(e)) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli spawn failed: {}", e));
retry_delay(attempt).await;
continue;
}
return Err(SendError::ProcessSpawn {
command: self.signal_cli_path.clone(),
source: e,
});
}
Err(_) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some("signal-cli timed out".to_string());
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
}
}
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
last_error.unwrap_or_else(|| "unknown error".to_string()),
))
}
}
+85 -7
View File
@@ -143,6 +143,9 @@ pub struct TranquilConfig {
#[config(nested)]
pub scheduled: ScheduledConfig,
#[config(nested)]
pub tranquil_store: TranquilStoreConfig,
}
impl TranquilConfig {
@@ -250,6 +253,23 @@ impl TranquilConfig {
);
}
// -- repo backend -----------------------------------------------------
if let Err(e) = self.storage.repo_backend.parse::<RepoBackend>() {
errors.push(e);
}
// -- tranquil-store ---------------------------------------------------
if let Some(mb) = self.tranquil_store.memory_budget_mb
&& mb == 0
{
errors.push("tranquil_store.memory_budget_mb must be at least 1".to_string());
}
if let Some(threads) = self.tranquil_store.handler_threads
&& threads == 0
{
errors.push("tranquil_store.handler_threads must be at least 1".to_string());
}
// -- cache ------------------------------------------------------------
match self.cache.backend.as_str() {
"valkey" => {
@@ -561,6 +581,35 @@ impl SecretsConfig {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoBackend {
Postgres,
TranquilStore,
}
impl std::str::FromStr for RepoBackend {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"postgres" => Ok(Self::Postgres),
"tranquil-store" => Ok(Self::TranquilStore),
other => Err(format!(
"unknown repo backend \"{other}\", expected \"postgres\" or \"tranquil-store\""
)),
}
}
}
impl fmt::Display for RepoBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Postgres => f.write_str("postgres"),
Self::TranquilStore => f.write_str("tranquil-store"),
}
}
}
#[derive(Debug, Config)]
pub struct StorageConfig {
/// Storage backend: `filesystem` or `s3`.
@@ -578,6 +627,17 @@ pub struct StorageConfig {
/// Custom S3 endpoint URL (for MinIO, R2, etc.).
#[config(env = "S3_ENDPOINT")]
pub s3_endpoint: Option<String>,
#[config(env = "REPO_BACKEND", default = "postgres")]
pub repo_backend: String,
}
impl StorageConfig {
pub fn repo_backend(&self) -> RepoBackend {
self.repo_backend
.parse()
.expect("repo_backend must be validated before use")
}
}
#[derive(Debug, Config)]
@@ -678,13 +738,8 @@ pub struct TelegramConfig {
#[derive(Debug, Config)]
pub struct SignalConfig {
/// Path to the `signal-cli` binary.
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
pub cli_path: String,
/// Sender phone number. When unset, Signal integration is disabled.
#[config(env = "SIGNAL_SENDER_NUMBER")]
pub sender_number: Option<String>,
#[config(env = "SIGNAL_ENABLED", default = false)]
pub enabled: bool,
}
#[derive(Debug, Config)]
@@ -1001,6 +1056,29 @@ pub struct ScheduledConfig {
/// Interval in seconds between scheduled delete checks.
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
pub delete_check_interval_secs: u64,
/// Interval in seconds between block garbage collection cycles.
#[config(env = "BLOCK_GC_INTERVAL_SECS", default = 21600)]
pub block_gc_interval_secs: u64,
}
#[derive(Debug, Config)]
pub struct TranquilStoreConfig {
/// Directory for tranquil-store data (metastore, eventlog).
#[config(
env = "TRANQUIL_STORE_DATA_DIR",
default = "/var/lib/tranquil-pds/store"
)]
pub data_dir: String,
/// Fjall block cache size in megabytes. Defaults to 20% of system RAM
/// when unset.
#[config(env = "TRANQUIL_STORE_MEMORY_BUDGET_MB")]
pub memory_budget_mb: Option<u64>,
/// Number of handler threads. Defaults to available_parallelism / 2.
#[config(env = "TRANQUIL_STORE_HANDLER_THREADS")]
pub handler_threads: Option<usize>,
}
/// Generate a TOML configuration template with all available options,
+64
View File
@@ -185,12 +185,41 @@ pub struct ReservedSigningKey {
pub private_key_bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct ReservedSigningKeyFull {
pub id: Uuid,
pub did: Option<Did>,
pub public_key_did_key: String,
pub private_key_bytes: Vec<u8>,
pub expires_at: DateTime<Utc>,
pub used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct DeletionRequest {
pub did: Did,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct DeletionRequestWithToken {
pub token: String,
pub did: Did,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct PlcTokenInfo {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct PasswordResetInfo {
pub code: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
}
#[async_trait]
pub trait InfraRepository: Send + Sync {
#[allow(clippy::too_many_arguments)]
@@ -406,6 +435,41 @@ pub trait InfraRepository: Send + Sync {
&self,
user_ids: &[Uuid],
) -> Result<Vec<(Uuid, String)>, DbError>;
async fn get_deletion_request_by_did(
&self,
did: &Did,
) -> Result<Option<DeletionRequestWithToken>, DbError>;
async fn get_latest_comms_for_user(
&self,
user_id: Uuid,
comms_type: CommsType,
limit: i64,
) -> Result<Vec<QueuedComms>, DbError>;
async fn count_comms_by_type(
&self,
user_id: Uuid,
comms_type: CommsType,
) -> Result<i64, DbError>;
async fn delete_comms_by_type_for_user(
&self,
user_id: Uuid,
comms_type: CommsType,
) -> Result<u64, DbError>;
async fn expire_deletion_request(&self, token: &str) -> Result<(), DbError>;
async fn get_reserved_signing_key_full(
&self,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKeyFull>, DbError>;
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError>;
async fn count_plc_tokens_by_did(&self, did: &Did) -> Result<i64, DbError>;
}
#[derive(Debug, Clone)]
+4 -3
View File
@@ -22,9 +22,10 @@ pub use delegation::{
};
pub use error::DbError;
pub use infra::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository,
InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse,
NotificationHistoryRow, QueuedComms, ReservedSigningKey,
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull,
};
pub use invite_code::{InviteCodeError, ValidatedInviteCode};
pub use oauth::{
+9 -1
View File
@@ -291,21 +291,24 @@ pub trait OAuthRepository: Send + Sync {
device_id: &DeviceId,
did: &Did,
) -> Result<bool, DbError>;
async fn revoke_device_trust(&self, device_id: &DeviceId) -> Result<(), DbError>;
async fn revoke_device_trust(&self, device_id: &DeviceId, did: &Did) -> Result<(), DbError>;
async fn update_device_friendly_name(
&self,
device_id: &DeviceId,
did: &Did,
friendly_name: Option<&str>,
) -> Result<(), DbError>;
async fn trust_device(
&self,
device_id: &DeviceId,
did: &Did,
trusted_at: DateTime<Utc>,
trusted_until: DateTime<Utc>,
) -> Result<(), DbError>;
async fn extend_device_trust(
&self,
device_id: &DeviceId,
did: &Did,
trusted_until: DateTime<Utc>,
) -> Result<(), DbError>;
@@ -321,4 +324,9 @@ pub trait OAuthRepository: Send + Sync {
did: &Did,
except_token_id: &TokenId,
) -> Result<u64, DbError>;
async fn get_2fa_challenge_code(
&self,
request_uri: &RequestId,
) -> Result<Option<String>, DbError>;
}
+18
View File
@@ -5,6 +5,7 @@ use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
use uuid::Uuid;
use crate::DbError;
use crate::backlink::Backlink;
use crate::sequence::SequenceNumber;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
@@ -279,6 +280,8 @@ pub struct ApplyCommitInput {
pub obsolete_block_cids: Vec<Vec<u8>>,
pub record_upserts: Vec<RecordUpsert>,
pub record_deletes: Vec<RecordDelete>,
pub backlinks_to_add: Vec<Backlink>,
pub backlinks_to_remove: Vec<AtUri>,
pub commit_event: CommitEventData,
}
@@ -300,6 +303,8 @@ pub trait RepoRepository: Send + Sync {
async fn create_repo(
&self,
user_id: Uuid,
did: &Did,
handle: &Handle,
repo_root_cid: &CidLink,
repo_rev: &str,
) -> Result<(), DbError>;
@@ -313,6 +318,14 @@ pub trait RepoRepository: Send + Sync {
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError>;
async fn update_repo_status(
&self,
did: &Did,
takedown: Option<bool>,
takedown_ref: Option<&str>,
deactivated: Option<bool>,
) -> Result<(), DbError>;
async fn delete_repo(&self, user_id: Uuid) -> Result<(), DbError>;
async fn get_repo_root_for_update(&self, user_id: Uuid) -> Result<Option<CidLink>, DbError>;
@@ -400,6 +413,11 @@ pub trait RepoRepository: Send + Sync {
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
async fn find_unreferenced_blocks(
&self,
candidate_cids: &[Vec<u8>],
) -> Result<Vec<Vec<u8>>, DbError>;
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
async fn insert_identity_event(
@@ -23,6 +23,10 @@ impl SequenceNumber {
pub fn is_valid(&self) -> bool {
self.0 >= 0
}
pub fn as_u64(&self) -> Option<u64> {
u64::try_from(self.0).ok()
}
}
impl fmt::Display for SequenceNumber {
+14
View File
@@ -223,6 +223,8 @@ pub trait UserRepository: Send + Sync {
async fn admin_update_password(&self, did: &Did, password_hash: &str) -> Result<u64, DbError>;
async fn set_admin_status(&self, did: &Did, is_admin: bool) -> Result<(), DbError>;
async fn get_notification_prefs(&self, did: &Did)
-> Result<Option<NotificationPrefs>, DbError>;
@@ -584,6 +586,18 @@ pub trait UserRepository: Send + Sync {
&self,
input: &RecoverPasskeyAccountInput,
) -> Result<RecoverPasskeyAccountResult, DbError>;
async fn get_password_reset_info(
&self,
email: &str,
) -> Result<Option<crate::PasswordResetInfo>, DbError>;
async fn enable_totp_verified(&self, did: &Did, encrypted_secret: &[u8])
-> Result<(), DbError>;
async fn set_two_factor_enabled(&self, did: &Did, enabled: bool) -> Result<(), DbError>;
async fn expire_password_reset_code(&self, email: &str) -> Result<(), DbError>;
}
#[derive(Debug, Clone)]
+158 -3
View File
@@ -3,9 +3,9 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use tranquil_db_traits::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey,
ValidatedInviteCode,
DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow,
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
};
use tranquil_types::{CidLink, Did, Handle};
use uuid::Uuid;
@@ -1034,4 +1034,159 @@ impl InfraRepository for PostgresInfraRepository {
.map(|r| (r.used_by_user, r.code))
.collect())
}
async fn get_deletion_request_by_did(
&self,
did: &Did,
) -> Result<Option<DeletionRequestWithToken>, DbError> {
let row = sqlx::query!(
r#"SELECT token, did, expires_at FROM account_deletion_requests WHERE did = $1"#,
did.as_str()
)
.fetch_optional(&self.pool)
.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,
}))
}
async fn get_latest_comms_for_user(
&self,
user_id: Uuid,
comms_type: CommsType,
limit: i64,
) -> Result<Vec<QueuedComms>, DbError> {
let results = sqlx::query_as!(
QueuedComms,
r#"SELECT
id, user_id,
channel as "channel: CommsChannel",
comms_type as "comms_type: CommsType",
status as "status: CommsStatus",
recipient, subject, body, metadata,
attempts, max_attempts, last_error,
created_at, updated_at, scheduled_for, processed_at
FROM comms_queue
WHERE user_id = $1 AND comms_type = $2
ORDER BY created_at DESC
LIMIT $3"#,
user_id,
comms_type as CommsType,
limit
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(results)
}
async fn count_comms_by_type(
&self,
user_id: Uuid,
comms_type: CommsType,
) -> Result<i64, DbError> {
let count = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!" FROM comms_queue WHERE user_id = $1 AND comms_type = $2"#,
user_id,
comms_type as CommsType
)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(count)
}
async fn delete_comms_by_type_for_user(
&self,
user_id: Uuid,
comms_type: CommsType,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
user_id,
comms_type as CommsType
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn expire_deletion_request(&self, token: &str) -> Result<(), DbError> {
sqlx::query!(
"UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
token
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_reserved_signing_key_full(
&self,
public_key_did_key: &str,
) -> Result<Option<ReservedSigningKeyFull>, DbError> {
let row = sqlx::query!(
r#"SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at
FROM reserved_signing_keys WHERE public_key_did_key = $1"#,
public_key_did_key
)
.fetch_optional(&self.pool)
.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: r.public_key_did_key,
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
}))
}
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
let results = sqlx::query!(
r#"SELECT t.token, t.expires_at
FROM plc_operation_tokens t
JOIN users u ON t.user_id = u.id
WHERE u.did = $1"#,
did.as_str()
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(results
.into_iter()
.map(|r| PlcTokenInfo {
token: r.token,
expires_at: r.expires_at,
})
.collect())
}
async fn count_plc_tokens_by_did(&self, did: &Did) -> Result<i64, DbError> {
let count = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!"
FROM plc_operation_tokens t
JOIN users u ON t.user_id = u.id
WHERE u.did = $1"#,
did.as_str()
)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(count)
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ use tranquil_db_traits::{
pub use user::PostgresUserRepository;
pub struct PostgresRepositories {
pub pool: PgPool,
pub pool: Option<PgPool>,
pub user: Arc<dyn UserRepository>,
pub oauth: Arc<dyn OAuthRepository>,
pub session: Arc<dyn SessionRepository>,
@@ -44,7 +44,7 @@ pub struct PostgresRepositories {
impl PostgresRepositories {
pub fn new(pool: PgPool) -> Self {
Self {
pool: pool.clone(),
pool: Some(pool.clone()),
user: Arc::new(PostgresUserRepository::new(pool.clone())),
oauth: Arc::new(PostgresOAuthRepository::new(pool.clone())),
session: Arc::new(PostgresSessionRepository::new(pool.clone())),
+19 -1
View File
@@ -1194,7 +1194,7 @@ impl OAuthRepository for PostgresOAuthRepository {
Ok(exists.is_some())
}
async fn revoke_device_trust(&self, device_id: &DeviceId) -> Result<(), DbError> {
async fn revoke_device_trust(&self, device_id: &DeviceId, _did: &Did) -> Result<(), DbError> {
sqlx::query!(
"UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
device_id.as_str()
@@ -1208,6 +1208,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn update_device_friendly_name(
&self,
device_id: &DeviceId,
_did: &Did,
friendly_name: Option<&str>,
) -> Result<(), DbError> {
sqlx::query!(
@@ -1224,6 +1225,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn trust_device(
&self,
device_id: &DeviceId,
_did: &Did,
trusted_at: DateTime<Utc>,
trusted_until: DateTime<Utc>,
) -> Result<(), DbError> {
@@ -1242,6 +1244,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn extend_device_trust(
&self,
device_id: &DeviceId,
_did: &Did,
trusted_until: DateTime<Utc>,
) -> Result<(), DbError> {
sqlx::query!(
@@ -1320,4 +1323,19 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn get_2fa_challenge_code(
&self,
request_uri: &RequestId,
) -> Result<Option<String>, DbError> {
let code = sqlx::query_scalar!(
"SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1",
request_uri.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(code)
}
}
+83
View File
@@ -46,9 +46,21 @@ impl PostgresRepoRepository {
#[async_trait]
impl RepoRepository for PostgresRepoRepository {
async fn update_repo_status(
&self,
_did: &Did,
_takedown: Option<bool>,
_takedown_ref: Option<&str>,
_deactivated: Option<bool>,
) -> Result<(), DbError> {
Ok(())
}
async fn create_repo(
&self,
user_id: Uuid,
_did: &Did,
_handle: &Handle,
repo_root_cid: &CidLink,
repo_rev: &str,
) -> Result<(), DbError> {
@@ -606,6 +618,30 @@ impl RepoRepository for PostgresRepoRepository {
Ok(count)
}
async fn find_unreferenced_blocks(
&self,
candidate_cids: &[Vec<u8>],
) -> Result<Vec<Vec<u8>>, DbError> {
match candidate_cids.is_empty() {
true => Ok(Vec::new()),
false => {
let rows = sqlx::query!(
r#"
SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)
WHERE NOT EXISTS (
SELECT 1 FROM user_blocks WHERE block_cid = t.cid
)
"#,
candidate_cids,
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows.into_iter().filter_map(|r| r.cid).collect())
}
}
}
async fn get_user_block_cids_since_rev(
&self,
user_id: Uuid,
@@ -1367,6 +1403,53 @@ impl RepoRepository for PostgresRepoRepository {
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
}
if !input.backlinks_to_remove.is_empty() {
let remove_uris: Vec<&str> = input
.backlinks_to_remove
.iter()
.map(|u| u.as_str())
.collect();
sqlx::query!(
"DELETE FROM backlinks WHERE uri = ANY($1::text[])",
&remove_uris as &[&str],
)
.execute(&mut *tx)
.await
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
}
if !input.backlinks_to_add.is_empty() {
let uris: Vec<&str> = input
.backlinks_to_add
.iter()
.map(|b| b.uri.as_str())
.collect();
let paths: Vec<&str> = input
.backlinks_to_add
.iter()
.map(|b| b.path.as_str())
.collect();
let link_tos: Vec<&str> = input
.backlinks_to_add
.iter()
.map(|b| b.link_to.as_str())
.collect();
sqlx::query!(
r#"
INSERT INTO backlinks (uri, path, link_to, repo_id)
SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4
ON CONFLICT (uri, path) DO NOTHING
"#,
&uris as &[&str],
&paths as &[&str],
&link_tos as &[&str],
input.user_id,
)
.execute(&mut *tx)
.await
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
}
let event = &input.commit_event;
let seq: i64 = sqlx::query_scalar(
r#"
+78 -2
View File
@@ -234,6 +234,8 @@ impl UserRepository for PostgresUserRepository {
limit: i64,
) -> Result<Vec<AccountSearchResult>, DbError> {
let cursor_str = cursor_did.map(|d| d.as_str());
let email_like = email_filter.map(|e| format!("%{e}%"));
let handle_like = handle_filter.map(|h| format!("%{h}%"));
let rows = sqlx::query!(
r#"SELECT did, handle, email, created_at, email_verified, deactivated_at, invites_disabled
FROM users
@@ -243,8 +245,8 @@ impl UserRepository for PostgresUserRepository {
ORDER BY did ASC
LIMIT $4"#,
cursor_str,
email_filter,
handle_filter,
email_like.as_deref(),
handle_like.as_deref(),
limit
)
.fetch_all(&self.pool)
@@ -627,6 +629,18 @@ impl UserRepository for PostgresUserRepository {
Ok(result.rows_affected())
}
async fn set_admin_status(&self, did: &Did, is_admin: bool) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET is_admin = $1 WHERE did = $2",
is_admin,
did.as_str()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_notification_prefs(
&self,
did: &Did,
@@ -3306,4 +3320,66 @@ impl UserRepository for PostgresUserRepository {
.map_err(map_sqlx_error)?;
Ok(row.flatten())
}
async fn get_password_reset_info(
&self,
email: &str,
) -> Result<Option<tranquil_db_traits::PasswordResetInfo>, DbError> {
let row = sqlx::query!(
"SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
email
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| tranquil_db_traits::PasswordResetInfo {
code: r.password_reset_code,
expires_at: r.password_reset_code_expires_at,
}))
}
async fn enable_totp_verified(
&self,
did: &Did,
encrypted_secret: &[u8],
) -> Result<(), DbError> {
sqlx::query!(
r#"INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
VALUES ($1, $2, 1, TRUE, NOW())
ON CONFLICT (did) DO UPDATE SET secret_encrypted = $2, verified = TRUE"#,
did.as_str(),
encrypted_secret
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn set_two_factor_enabled(&self, did: &Did, enabled: bool) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET two_factor_enabled = $1 WHERE did = $2",
enabled,
did.as_str()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn expire_password_reset_code(&self, email: &str) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
email
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
}
+110 -9
View File
@@ -322,15 +322,7 @@ fn validate_blob_ref(
if let Some(ref accept) = lex_blob.accept {
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
let matched = accept
.iter()
.any(|pattern| match pattern.strip_suffix("/*") {
Some(prefix) => {
mime_type.starts_with(prefix)
&& mime_type.as_bytes().get(prefix.len()) == Some(&b'/')
}
None => mime_type == pattern,
});
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
if !mime_type.is_empty() && !matched {
return Err(LexValidationError::field(
path,
@@ -352,6 +344,34 @@ fn validate_blob_ref(
Ok(())
}
fn mime_type_matches_accept_pattern(mime_type: &str, pattern: &str) -> bool {
let normalized_mime = normalize_mime_for_match(mime_type);
let normalized = normalize_mime_for_match(pattern);
if normalized == "*/*" || normalized == "*" {
return true;
}
match normalized.strip_suffix("/*") {
Some(prefix) => {
!prefix.is_empty()
&& normalized_mime.starts_with(prefix)
&& normalized_mime.len() > prefix.len()
&& normalized_mime.as_bytes()[prefix.len()] == b'/'
}
None => normalized_mime == normalized,
}
}
fn normalize_mime_for_match(value: &str) -> String {
value
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
}
fn validate_bytes(
lex_bytes: &LexBytes,
value: &serde_json::Value,
@@ -733,6 +753,87 @@ mod tests {
);
}
#[test]
fn test_blob_accept_wildcard_allows_any_mime() {
let lex_blob = LexBlob {
accept: Some(vec!["*/*".to_string()]),
max_size: None,
};
let blob = json!({
"$type": "blob",
"ref": { "$link": "bafyreiabcdef" },
"mimeType": "application/gzip",
"size": 123
});
assert!(validate_blob_ref(&lex_blob, &blob, "root/entries/0/node/blob").is_ok());
}
#[test]
fn test_blob_accept_prefix_wildcard_matches_subtypes() {
let lex_blob = LexBlob {
accept: Some(vec!["image/*".to_string()]),
max_size: None,
};
let blob = json!({
"$type": "blob",
"ref": { "$link": "bafyreiabcdef" },
"mimeType": "image/png",
"size": 123
});
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
}
#[test]
fn test_blob_accept_exact_type_rejects_different_mime() {
let lex_blob = LexBlob {
accept: Some(vec!["image/png".to_string()]),
max_size: None,
};
let blob = json!({
"$type": "blob",
"ref": { "$link": "bafyreiabcdef" },
"mimeType": "application/gzip",
"size": 123
});
let err = validate_blob_ref(&lex_blob, &blob, "blob").unwrap_err();
assert!(matches!(err, LexValidationError::InvalidField { .. }));
}
#[test]
fn test_blob_accept_exact_type_ignores_params_and_case() {
let lex_blob = LexBlob {
accept: Some(vec!["text/html".to_string()]),
max_size: None,
};
let blob = json!({
"$type": "blob",
"ref": { "$link": "bafyreiabcdef" },
"mimeType": "Text/HTML; charset=utf-8",
"size": 123
});
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
}
#[test]
fn test_blob_accept_prefix_ignores_params_and_case() {
let lex_blob = LexBlob {
accept: Some(vec!["text/*".to_string()]),
max_size: None,
};
let blob = json!({
"$type": "blob",
"ref": { "$link": "bafyreiabcdef" },
"mimeType": "TEXT/HTML; charset=UTF-8",
"size": 123
});
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
}
#[test]
fn test_validate_cross_schema_local_ref_in_union() {
let registry = test_registry();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,548 @@
use super::*;
#[derive(Debug, Serialize)]
pub struct ScopeInfo {
pub scope: String,
pub category: String,
pub required: bool,
pub description: String,
pub display_name: String,
pub granted: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ConsentResponse {
pub request_uri: String,
pub client_id: String,
pub client_name: Option<String>,
pub client_uri: Option<String>,
pub logo_uri: Option<String>,
pub scopes: Vec<ScopeInfo>,
pub show_consent: bool,
pub did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_delegation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub controller_handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegation_level: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ConsentQuery {
pub request_uri: String,
}
#[derive(Debug, Deserialize)]
pub struct ConsentSubmit {
pub request_uri: String,
pub approved_scopes: Vec<String>,
pub remember: bool,
}
pub async fn consent_get(
State(state): State<AppState>,
Query(query): Query<ConsentQuery>,
) -> Response {
let consent_request_id = RequestId::from(query.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&consent_request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid or expired request_uri",
);
}
Err(e) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
&format!("Database error: {:?}", e),
);
}
};
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
Ok(flow) => match flow.require_user() {
Ok(u) => u,
Err(_) => {
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
}
},
Err(_) => {
return json_error(
StatusCode::BAD_REQUEST,
"expired_request",
"Authorization request has expired",
);
}
};
let did = flow_with_user.did().clone();
let client_cache = ClientMetadataCache::new(3600);
let client_metadata = client_cache
.get(&request_data.parameters.client_id)
.await
.ok();
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("atproto");
let controller_did_parsed: Option<Did> = request_data
.controller_did
.as_ref()
.and_then(|s| s.parse().ok());
let delegation_grant = if let Some(ref ctrl_did) = controller_did_parsed {
state
.repos
.delegation
.get_delegation(&did, ctrl_did)
.await
.ok()
.flatten()
} else {
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 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 consent_client_id = ClientId::from(request_data.parameters.client_id.clone());
let preferences = state
.repos
.oauth
.get_scope_preferences(&did, &consent_client_id)
.await
.unwrap_or_default();
let pref_map: std::collections::HashMap<_, _> = preferences
.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 show_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&consent_client_id,
&requested_scope_strings,
)
.await
.unwrap_or(true);
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let scopes: Vec<ScopeInfo> = requested_scopes
.iter()
.map(|scope| {
let (category, required, description, display_name) = if let Some(def) =
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
{
let desc = if *scope == "atproto" && has_granular_scopes {
"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 {
"AT Protocol Access".to_string()
} else {
def.display_name.to_string()
};
(
def.category.display_name().to_string(),
def.required,
desc,
name,
)
} else if scope.starts_with("ref:") {
(
"Reference".to_string(),
false,
"Referenced scope".to_string(),
scope.to_string(),
)
} else {
(
"Other".to_string(),
false,
format!("Access to {}", scope),
scope.to_string(),
)
};
let granted = pref_map.get(*scope).copied();
ScopeInfo {
scope: scope.to_string(),
category,
required,
description,
display_name,
granted,
}
})
.collect();
let account_handle = state
.repos
.user
.get_handle_by_did(&did)
.await
.ok()
.flatten()
.map(|h| h.to_string());
let (is_delegation, controller_did_resp, controller_handle, delegation_level) =
if let Some(ref ctrl_did) = controller_did_parsed {
let ctrl_handle = state
.repos
.user
.get_handle_by_did(ctrl_did)
.await
.ok()
.flatten()
.map(|h| h.to_string());
let level = if let Some(ref grant) = delegation_grant {
let preset = tranquil_pds::delegation::SCOPE_PRESETS
.iter()
.find(|p| p.scopes == grant.granted_scopes.as_str());
preset
.map(|p| p.label.to_string())
.unwrap_or_else(|| "Custom".to_string())
} else {
"Unknown".to_string()
};
(
Some(true),
Some(ctrl_did.to_string()),
ctrl_handle,
Some(level),
)
} else {
(None, None, None, None)
};
Json(ConsentResponse {
request_uri: query.request_uri.clone(),
client_id: request_data.parameters.client_id.clone(),
client_name: client_metadata.as_ref().and_then(|m| m.client_name.clone()),
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,
show_consent,
did: did.to_string(),
handle: account_handle,
is_delegation,
controller_did: controller_did_resp,
controller_handle,
delegation_level,
})
.into_response()
}
pub async fn consent_post(
State(state): State<AppState>,
Json(form): Json<ConsentSubmit>,
) -> Response {
tracing::info!(
"consent_post: approved_scopes={:?}, remember={}",
form.approved_scopes,
form.remember
);
let consent_post_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&consent_post_request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid or expired request_uri",
);
}
Err(e) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
&format!("Database error: {:?}", e),
);
}
};
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
Ok(flow) => match flow.require_user() {
Ok(u) => u,
Err(_) => {
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
}
},
Err(_) => {
let _ = state
.repos
.oauth
.delete_authorization_request(&consent_post_request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired",
);
}
};
let did = flow_with_user.did().clone();
let original_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let controller_did_parsed: Option<Did> = request_data
.controller_did
.as_ref()
.and_then(|s| s.parse().ok());
let delegation_grant = match controller_did_parsed.as_ref() {
Some(ctrl_did) => state
.repos
.delegation
.get_delegation(&did, ctrl_did)
.await
.ok()
.flatten(),
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 requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let user_denied_some_granular = has_granular_scopes
&& requested_scopes
.iter()
.filter(|s| is_granular_scope(s))
.any(|s| !form.approved_scopes.contains(&s.to_string()));
let atproto_was_requested = requested_scopes.contains(&"atproto");
if atproto_was_requested
&& !has_granular_scopes
&& !form.approved_scopes.contains(&"atproto".to_string())
{
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"The atproto scope was requested and must be approved",
);
}
let final_approved: Vec<String> = if user_denied_some_granular {
form.approved_scopes
.iter()
.filter(|s| *s != "atproto")
.cloned()
.collect()
} else {
form.approved_scopes.clone()
};
if final_approved.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"At least one scope must be approved",
);
}
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
.iter()
.map(|s| ScopePreference {
scope: s.to_string(),
granted: form.approved_scopes.contains(&s.to_string()),
})
.collect();
let consent_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
let _ = state
.repos
.oauth
.upsert_scope_preferences(&did, &consent_post_client_id, &preferences)
.await;
}
if let Err(e) = state
.repos
.oauth
.update_request_scope(&consent_post_request_id, &approved_scope_str)
.await
{
tracing::warn!("Failed to update request scope: {:?}", e);
}
let code = Code::generate();
let consent_post_device_id = request_data
.device_id
.as_ref()
.map(|d| DeviceIdType::new(d.0.clone()));
let consent_post_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
.update_authorization_request(
&consent_post_request_id,
&did,
consent_post_device_id.as_ref(),
&consent_post_code,
)
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Failed to complete authorization",
);
}
let redirect_uri = &request_data.parameters.redirect_uri;
let intermediate_url = build_intermediate_redirect_url(
redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
tracing::info!(
intermediate_url = %intermediate_url,
client_redirect = %redirect_uri,
"consent_post returning JSON with intermediate URL (for 303 redirect)"
);
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
}
#[derive(Debug, Deserialize)]
pub struct RenewRequest {
pub request_uri: String,
}
pub async fn authorize_renew(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
Json(form): Json<RenewRequest>,
) -> Response {
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Unknown authorization request",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Database error",
);
}
};
if request_data.did.is_none() {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request not yet authenticated",
);
}
let now = Utc::now();
if request_data.expires_at >= now {
return Json(serde_json::json!({
"request_uri": form.request_uri,
"renewed": false
}))
.into_response();
}
let staleness = now - request_data.expires_at;
if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS {
let _ = state
.repos
.oauth
.delete_authorization_request(&request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request expired too long ago to renew",
);
}
let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS);
match state
.repos
.oauth
.extend_authorization_request_expiry(&request_id, new_expires_at)
.await
{
Ok(true) => Json(serde_json::json!({
"request_uri": form.request_uri,
"renewed": true
}))
.into_response(),
Ok(false) => json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request could not be renewed",
),
Err(_) => json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Database error",
),
}
}
@@ -0,0 +1,971 @@
use super::*;
pub async fn authorize_get(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<AuthorizeQuery>,
) -> Response {
let request_uri = match query.request_uri {
Some(uri) => uri,
None => {
if wants_json(&headers) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Missing request_uri parameter. Use PAR to initiate authorization."
})),
).into_response();
}
return redirect_to_frontend_error(
"invalid_request",
"Missing request_uri parameter. Use PAR to initiate authorization.",
);
}
};
let request_id = RequestId::from(request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
if wants_json(&headers) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri. Please start a new authorization request."
})),
).into_response();
}
return redirect_to_frontend_error(
"invalid_request",
"Invalid or expired request_uri. Please start a new authorization request.",
);
}
Err(e) => {
if wants_json(&headers) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
)
.into_response();
}
return redirect_to_frontend_error("server_error", "A database error occurred.");
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&request_id)
.await;
if wants_json(&headers) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired. Please start a new request."
})),
).into_response();
}
return redirect_to_frontend_error(
"invalid_request",
"Authorization request has expired. Please start a new request.",
);
}
let client_cache = ClientMetadataCache::new(3600);
let client_name = client_cache
.get(&request_data.parameters.client_id)
.await
.ok()
.and_then(|m| m.client_name);
if wants_json(&headers) {
return Json(AuthorizeResponse {
client_id: request_data.parameters.client_id.clone(),
client_name: client_name.clone(),
scope: request_data.parameters.scope.clone(),
redirect_uri: request_data.parameters.redirect_uri.clone(),
state: request_data.parameters.state.clone(),
login_hint: request_data.parameters.login_hint.clone(),
})
.into_response();
}
let force_new_account = query.new_account.unwrap_or(false);
if let Some(ref login_hint) = request_data.parameters.login_hint {
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
tracing::info!(normalized = %normalized, "Normalized login_hint");
match state
.repos
.user
.get_login_check_by_handle_or_email(normalized.as_str())
.await
{
Ok(Some(user)) => {
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
let is_delegated = state
.repos
.delegation
.is_delegated_account(&user.did)
.await
.unwrap_or(false);
let has_password = user.password_hash.is_some();
tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check");
if is_delegated {
tracing::info!("Redirecting to delegation auth");
if let Err(e) = state
.repos
.oauth
.set_request_did(&request_id, &user.did)
.await
{
tracing::error!(error = %e, "Failed to set delegated DID on authorization request");
return redirect_to_frontend_error(
"server_error",
"Failed to initialize delegation flow",
);
}
return redirect_see_other(&format!(
"/app/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&request_uri),
url_encode(&user.did)
));
}
}
Ok(None) => {
tracing::info!(normalized = %normalized, "No user found for login_hint");
}
Err(e) => {
tracing::error!(error = %e, "Error looking up user for login_hint");
}
}
} else {
tracing::info!("No login_hint in request");
}
if request_data.parameters.prompt == Some(Prompt::Create) {
return redirect_see_other(&format!(
"/app/oauth/register?request_uri={}",
url_encode(&request_uri)
));
}
if !force_new_account
&& let Some(device_id) = extract_device_cookie(&headers)
&& let Ok(accounts) = state
.repos
.oauth
.get_device_accounts(&device_id.clone())
.await
&& !accounts.is_empty()
{
let login_hint_param = request_data
.parameters
.login_hint
.as_ref()
.map(|h| format!("&login_hint={}", url_encode(h)))
.unwrap_or_default();
return redirect_see_other(&format!(
"/app/oauth/accounts?request_uri={}{}",
url_encode(&request_uri),
login_hint_param
));
}
redirect_see_other(&format!(
"/app/oauth/login?request_uri={}",
url_encode(&request_uri)
))
}
pub async fn authorize_get_json(
State(state): State<AppState>,
Query(query): Query<AuthorizeQuery>,
) -> Result<Json<AuthorizeResponse>, OAuthError> {
let request_uri = query
.request_uri
.ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?;
let request_id_json = RequestId::from(request_uri.clone());
let request_data = state
.repos
.oauth
.get_authorization_request(&request_id_json)
.await
.map_err(tranquil_pds::oauth::db_err_to_oauth)?
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&request_id_json)
.await;
return Err(OAuthError::InvalidRequest(
"request_uri has expired".to_string(),
));
}
Ok(Json(AuthorizeResponse {
client_id: request_data.parameters.client_id.clone(),
client_name: None,
scope: request_data.parameters.scope.clone(),
redirect_uri: request_data.parameters.redirect_uri.clone(),
state: request_data.parameters.state.clone(),
login_hint: request_data.parameters.login_hint.clone(),
}))
}
#[derive(Debug, Serialize)]
pub struct AccountInfo {
pub did: String,
pub handle: Handle,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AccountsResponse {
pub accounts: Vec<AccountInfo>,
pub request_uri: String,
}
fn mask_email(email: &str) -> String {
if let Some(at_pos) = email.find('@') {
let local = &email[..at_pos];
let domain = &email[at_pos..];
if local.len() <= 2 {
format!("{}***{}", local.chars().next().unwrap_or('*'), domain)
} else {
let first = local.chars().next().unwrap_or('*');
let last = local.chars().last().unwrap_or('*');
format!("{}***{}{}", first, last, domain)
}
} else {
"***".to_string()
}
}
pub async fn authorize_accounts(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<AuthorizeQuery>,
) -> Response {
let request_uri = match query.request_uri {
Some(uri) => uri,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Missing request_uri parameter"
})),
)
.into_response();
}
};
let device_id = match extract_device_cookie(&headers) {
Some(id) => id,
None => {
return Json(AccountsResponse {
accounts: vec![],
request_uri,
})
.into_response();
}
};
let accounts = match state.repos.oauth.get_device_accounts(&device_id).await {
Ok(accts) => accts,
Err(_) => {
return Json(AccountsResponse {
accounts: vec![],
request_uri,
})
.into_response();
}
};
let account_infos: Vec<AccountInfo> = accounts
.into_iter()
.map(|row| AccountInfo {
did: row.did.to_string(),
handle: row.handle,
email: row.email.map(|e| mask_email(&e)),
})
.collect();
Json(AccountsResponse {
accounts: account_infos,
request_uri,
})
.into_response()
}
pub async fn authorize_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
Json(form): Json<AuthorizeSubmit>,
) -> Response {
let json_response = wants_json(&headers);
let form_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&form_request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
if json_response {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
)
.into_response();
}
return redirect_to_frontend_error(
"invalid_request",
"Invalid or expired request_uri. Please start a new authorization request.",
);
}
Err(e) => {
if json_response {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
)
.into_response();
}
return redirect_to_frontend_error("server_error", &format!("Database error: {:?}", e));
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&form_request_id)
.await;
if json_response {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
return redirect_to_frontend_error(
"invalid_request",
"Authorization request has expired. Please start a new request.",
);
}
let show_login_error = |error_msg: &str, json: bool| -> Response {
if json {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": error_msg
})),
)
.into_response();
}
redirect_see_other(&format!(
"/app/oauth/login?request_uri={}&error={}",
url_encode(&form.request_uri),
url_encode(error_msg)
))
};
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
let normalized_username =
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
tracing::debug!(
original_username = %form.username,
normalized_username = %normalized_username,
pds_hostname = %tranquil_config::get().server.hostname,
"Normalized username for lookup"
);
let user = match state
.repos
.user
.get_login_info_by_handle_or_email(normalized_username.as_str())
.await
{
Ok(Some(u)) => u,
Ok(None) => {
let _ = bcrypt::verify(
&form.password,
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
return show_login_error("Invalid handle/email or password.", json_response);
}
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
};
if user.deactivated_at.is_some() {
return show_login_error("This account has been deactivated.", json_response);
}
if user.takedown_ref.is_some() {
return show_login_error("This account has been taken down.", json_response);
}
if user.account_type.is_delegated() {
if state
.repos
.oauth
.set_authorization_did(&form_request_id, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = format!(
"/app/oauth/delegation?request_uri={}&delegated_did={}",
url_encode(&form.request_uri),
url_encode(&user.did)
);
if json_response {
return (
StatusCode::OK,
Json(serde_json::json!({
"next": "delegation",
"delegated_did": user.did,
"redirect": redirect_url
})),
)
.into_response();
}
return redirect_see_other(&redirect_url);
}
if !user.password_required {
if state
.repos
.oauth
.set_authorization_did(&form_request_id, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = format!(
"/app/oauth/passkey?request_uri={}",
url_encode(&form.request_uri)
);
if json_response {
return (
StatusCode::OK,
Json(serde_json::json!({
"next": "passkey",
"redirect": redirect_url
})),
)
.into_response();
}
return redirect_see_other(&redirect_url);
}
let password_valid = match &user.password_hash {
Some(hash) => match bcrypt::verify(&form.password, hash) {
Ok(valid) => valid,
Err(_) => {
return show_login_error("An error occurred. Please try again.", json_response);
}
},
None => false,
};
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
}
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
let handle = resend_info
.as_ref()
.map(|r| r.handle.to_string())
.unwrap_or_else(|| form.username.clone());
let channel = resend_info
.map(|r| r.channel.as_str().to_owned())
.unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned());
if json_response {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "account_not_verified",
"error_description": "Please verify your account before logging in.",
"did": user.did,
"handle": handle,
"channel": channel
})),
)
.into_response();
}
return redirect_see_other(&format!(
"/app/oauth/login?request_uri={}&error={}",
url_encode(&form.request_uri),
url_encode("account_not_verified")
));
}
let has_totp = tranquil_api::server::has_totp_enabled(&state, &user.did).await;
if has_totp {
let device_cookie = extract_device_cookie(&headers);
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), dev_id, &user.did)
.await
} else {
false
};
if device_is_trusted {
if let Some(ref dev_id) = device_cookie {
let _ = tranquil_api::server::extend_device_trust(
state.repos.oauth.as_ref(),
dev_id,
&user.did,
)
.await;
}
} else {
if state
.repos
.oauth
.set_authorization_did(&form_request_id, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
if json_response {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
return redirect_see_other(&format!(
"/app/oauth/totp?request_uri={}",
url_encode(&form.request_uri)
));
}
}
if user.two_factor_enabled {
let _ = state
.repos
.oauth
.delete_2fa_challenge_by_request_uri(&form_request_id)
.await;
match state
.repos
.oauth
.create_2fa_challenge(&user.did, &form_request_id)
.await
{
Ok(challenge) => {
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&challenge.code,
hostname,
)
.await
{
tracing::warn!(
did = %user.did,
error = %e,
"Failed to enqueue 2FA notification"
);
}
let channel_name = user.preferred_comms_channel.display_name();
if json_response {
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
}))
.into_response();
}
return redirect_see_other(&format!(
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
url_encode(channel_name)
));
}
Err(_) => {
return show_login_error("An error occurred. Please try again.", json_response);
}
}
}
let mut device_id: Option<DeviceIdType> = extract_device_cookie(&headers);
let mut new_cookie: Option<String> = None;
if form.remember_device {
let final_device_id = if let Some(existing_id) = &device_id {
existing_id.clone()
} else {
let new_id = DeviceId::generate();
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
if state
.repos
.oauth
.create_device(&new_device_id_typed, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_device_id_typed));
device_id = Some(new_device_id_typed.clone());
}
new_device_id_typed
};
let _ = state
.repos
.oauth
.upsert_account_device(&user.did, &final_device_id)
.await;
}
let set_auth_device_id = device_id.clone();
if state
.repos
.oauth
.set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref())
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&user.did,
&client_id_typed,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
if json_response {
if let Some(cookie) = new_cookie {
return (
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": consent_url})),
)
.into_response();
}
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
if let Some(cookie) = new_cookie {
return (
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, consent_url)],
)
.into_response();
}
return redirect_see_other(&consent_url);
}
let code = Code::generate();
let auth_post_device_id = device_id.clone();
let auth_post_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
.update_authorization_request(
&form_request_id,
&user.did,
auth_post_device_id.as_ref(),
&auth_post_code,
)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
if json_response {
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
if let Some(cookie) = new_cookie {
(
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": redirect_url})),
)
.into_response()
} else {
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
} else {
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
if let Some(cookie) = new_cookie {
(
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
)
.into_response()
} else {
redirect_see_other(&redirect_url)
}
}
}
pub async fn authorize_select(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<AuthorizeSelectSubmit>,
) -> Response {
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
(
status,
Json(serde_json::json!({
"error": error,
"error_description": description
})),
)
.into_response()
};
let select_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&select_request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid or expired request_uri. Please start a new authorization request.",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&select_request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired. Please start a new request.",
);
}
let device_id = match extract_device_cookie(&headers) {
Some(id) => id,
None => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No device session found. Please sign in.",
);
}
};
let did: Did = match form.did.parse() {
Ok(d) => d,
Err(_) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid DID format.",
);
}
};
let verify_device_id = device_id.clone();
let account_valid = match state
.repos
.oauth
.verify_account_on_device(&verify_device_id, &did)
.await
{
Ok(valid) => valid,
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
};
if !account_valid {
return json_error(
StatusCode::FORBIDDEN,
"access_denied",
"This account is not available on this device. Please sign in.",
);
}
let user = match state.repos.user.get_2fa_status_by_did(&did).await {
Ok(Some(u)) => u,
Ok(None) => {
return json_error(
StatusCode::FORBIDDEN,
"access_denied",
"Account not found. Please sign in.",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
};
let is_verified = user.channel_verification.has_any_verified();
if !is_verified {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "account_not_verified",
"error_description": "Please verify your account before logging in.",
"did": did,
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
})),
)
.into_response();
}
let has_totp = tranquil_api::server::has_totp_enabled(&state, &did).await;
let select_early_device_typed = device_id.clone();
if has_totp {
let device_is_trusted =
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), &device_id, &did)
.await;
if !device_is_trusted {
if state
.repos
.oauth
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
let _ =
tranquil_api::server::extend_device_trust(state.repos.oauth.as_ref(), &device_id, &did)
.await;
}
if user.two_factor_enabled {
let _ = state
.repos
.oauth
.delete_2fa_challenge_by_request_uri(&select_request_id)
.await;
match state
.repos
.oauth
.create_2fa_challenge(&did, &select_request_id)
.await
{
Ok(challenge) => {
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user.id,
&challenge.code,
hostname,
)
.await
{
tracing::warn!(
did = %form.did,
error = %e,
"Failed to enqueue 2FA notification"
);
}
let channel_name = user.preferred_comms_channel.display_name();
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
}))
.into_response();
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
}
}
let select_device_typed = device_id.clone();
let _ = state
.repos
.oauth
.upsert_account_device(&did, &select_device_typed)
.await;
if state
.repos
.oauth
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
Json(serde_json::json!({"redirect_uri": consent_url})).into_response()
}
@@ -0,0 +1,310 @@
use axum::{
Json,
extract::{Query, State},
http::{
HeaderMap, StatusCode,
header::{LOCATION, SET_COOKIE},
},
response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
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, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
db::should_show_consent, scopes::expand_include_scopes,
};
use tranquil_pds::rate_limit::{
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
check_user_rate_limit,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::util::extract_client_ip;
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
use urlencoding::encode as url_encode;
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
const RENEW_EXPIRY_SECONDS: i64 = 600;
const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600;
fn redirect_see_other(uri: &str) -> Response {
(
StatusCode::SEE_OTHER,
[
(LOCATION, uri.to_string()),
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
(
SET_COOKIE,
"bfCacheBypass=foo; max-age=1; SameSite=Lax".to_string(),
),
],
)
.into_response()
}
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
redirect_see_other(&format!(
"/app/oauth/error?error={}&error_description={}",
url_encode(error),
url_encode(description)
))
}
fn json_error(status: StatusCode, error: &str, description: &str) -> Response {
(
status,
Json(serde_json::json!({
"error": error,
"error_description": description
})),
)
.into_response()
}
fn is_granular_scope(s: &str) -> bool {
s.starts_with("repo:")
|| s.starts_with("repo?")
|| s == "repo"
|| s.starts_with("blob:")
|| s.starts_with("blob?")
|| s == "blob"
|| s.starts_with("rpc:")
|| s.starts_with("rpc?")
|| s.starts_with("account:")
|| 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")
.and_then(|v| v.to_str().ok())
.and_then(|cookie_str| {
cookie_str.split(';').map(|c| c.trim()).find_map(|cookie| {
cookie
.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME))
.and_then(|value| {
tranquil_pds::config::AuthConfig::get().verify_device_cookie(value)
})
.map(tranquil_types::DeviceId::new)
})
})
}
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String {
let signed_value =
tranquil_pds::config::AuthConfig::get().sign_device_cookie(device_id.as_str());
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
DEVICE_COOKIE_NAME, signed_value
)
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeQuery {
pub request_uri: Option<String>,
pub client_id: Option<String>,
pub new_account: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct AuthorizeResponse {
pub client_id: String,
pub client_name: Option<String>,
pub scope: Option<String>,
pub redirect_uri: String,
pub state: Option<String>,
pub login_hint: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeSubmit {
pub request_uri: String,
pub username: String,
pub password: PlainPassword,
#[serde(default)]
pub remember_device: bool,
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeSelectSubmit {
pub request_uri: String,
pub did: String,
}
fn wants_json(headers: &HeaderMap) -> bool {
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.map(|accept| accept.contains("application/json"))
.unwrap_or(false)
}
fn build_success_redirect(
redirect_uri: &str,
code: &str,
state: Option<&str>,
response_mode: Option<&str>,
) -> String {
let mut redirect_url = redirect_uri.to_string();
let use_fragment = response_mode == Some("fragment");
let separator = if use_fragment {
'#'
} else if redirect_url.contains('?') {
'&'
} else {
'?'
};
redirect_url.push(separator);
let pds_host = &tranquil_config::get().server.hostname;
redirect_url.push_str(&format!(
"iss={}",
url_encode(&format!("https://{}", pds_host))
));
if let Some(req_state) = state {
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
}
redirect_url.push_str(&format!("&code={}", url_encode(code)));
redirect_url
}
fn build_intermediate_redirect_url(
redirect_uri: &str,
code: &str,
state: Option<&str>,
response_mode: Option<&str>,
) -> String {
let pds_host = &tranquil_config::get().server.hostname;
let mut url = format!(
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
pds_host,
url_encode(redirect_uri),
url_encode(code)
);
if let Some(s) = state {
url.push_str(&format!("&state={}", url_encode(s)));
}
if let Some(rm) = response_mode {
url.push_str(&format!("&response_mode={}", url_encode(rm)));
}
url
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeRedirectParams {
redirect_uri: String,
code: String,
state: Option<String>,
response_mode: Option<String>,
}
pub async fn authorize_redirect(Query(params): Query<AuthorizeRedirectParams>) -> Response {
let final_url = build_success_redirect(
&params.redirect_uri,
&params.code,
params.state.as_deref(),
params.response_mode.as_deref(),
);
tracing::info!(
final_url = %final_url,
client_redirect = %params.redirect_uri,
"authorize_redirect performing 303 redirect"
);
(
StatusCode::SEE_OTHER,
[
(axum::http::header::LOCATION, final_url),
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
],
)
.into_response()
}
pub async fn authorize_deny(
State(state): State<AppState>,
Json(form): Json<AuthorizeDenyForm>,
) -> Response {
let deny_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&deny_request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid request_uri"
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred"
})),
)
.into_response();
}
};
let _ = state
.repos
.oauth
.delete_authorization_request(&deny_request_id)
.await;
let redirect_uri = &request_data.parameters.redirect_uri;
let mut redirect_url = redirect_uri.to_string();
let separator = if redirect_url.contains('?') { '&' } else { '?' };
redirect_url.push(separator);
redirect_url.push_str("error=access_denied");
redirect_url.push_str("&error_description=User%20denied%20the%20request");
if let Some(state) = &request_data.parameters.state {
redirect_url.push_str(&format!("&state={}", url_encode(state)));
}
Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeDenyForm {
pub request_uri: String,
}
mod consent;
mod login;
mod passkey;
mod registration;
mod two_factor;
pub use consent::*;
pub use login::*;
pub use passkey::*;
pub use registration::*;
pub use two_factor::*;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
use super::*;
#[derive(Debug, Deserialize)]
pub struct RegisterCompleteInput {
pub request_uri: String,
pub did: String,
pub app_password: String,
}
pub async fn register_complete(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
Json(form): Json<RegisterCompleteInput>,
) -> Response {
let did = Did::from(form.did.clone());
let request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&request_id)
.await
{
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(
request_uri = %form.request_uri,
error = ?e,
"register_complete: failed to fetch authorization request"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&request_id)
.await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
if request_data.parameters.prompt != Some(Prompt::Create) {
tracing::warn!(
request_uri = %form.request_uri,
prompt = ?request_data.parameters.prompt,
"register_complete called on non-registration OAuth flow"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "This endpoint is only for registration flows."
})),
)
.into_response();
}
if request_data.code.is_some() {
tracing::warn!(
request_uri = %form.request_uri,
"register_complete called on already-completed OAuth flow"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization has already been completed."
})),
)
.into_response();
}
if let Some(existing_did) = &request_data.did
&& existing_did != &form.did
{
tracing::warn!(
request_uri = %form.request_uri,
existing_did = %existing_did,
attempted_did = %form.did,
"register_complete attempted with different DID than already bound"
);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request is already bound to a different account."
})),
)
.into_response();
}
let password_hashes = match state
.repos
.session
.get_app_password_hashes_by_did(&did)
.await
{
Ok(hashes) => hashes,
Err(e) => {
tracing::error!(
did = %did,
error = ?e,
"register_complete: failed to fetch app password hashes"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let mut password_valid = password_hashes.iter().fold(false, |acc, hash| {
acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false)
});
if !password_valid
&& let Ok(Some(account_hash)) = state.repos.user.get_password_hash_by_did(&did).await
{
password_valid = bcrypt::verify(&form.app_password, &account_hash).unwrap_or(false);
}
if !password_valid {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Invalid credentials."
})),
)
.into_response();
}
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
Ok(Some(info)) => info.channel_verification.has_any_verified(),
Ok(None) => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Account not found."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(
did = %did,
error = ?e,
"register_complete: failed to fetch session info"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if !is_verified {
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "account_not_verified",
"error_description": "Please verify your account before continuing.",
"did": did,
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
})),
)
.into_response();
}
if let Err(e) = state
.repos
.oauth
.set_authorization_did(&request_id, &did, None)
.await
{
tracing::error!(
request_uri = %form.request_uri,
did = %did,
error = ?e,
"register_complete: failed to set authorization DID"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&client_id_typed,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
tracing::info!(
did = %did,
client_id = %request_data.parameters.client_id,
"OAuth registration complete, redirecting to consent"
);
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let auth_code = AuthorizationCode::from(code.0.clone());
if let Err(e) = state
.repos
.oauth
.update_authorization_request(&request_id, &did, None, &auth_code)
.await
{
tracing::error!(
request_uri = %form.request_uri,
did = %did,
error = ?e,
"register_complete: failed to update authorization request with code"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
tracing::info!(
did = %did,
client_id = %request_data.parameters.client_id,
"OAuth registration flow completed successfully"
);
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
pub async fn establish_session(
State(state): State<AppState>,
headers: HeaderMap,
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
) -> Response {
let did = &auth.did;
let existing_device = extract_device_cookie(&headers);
let (device_id, new_cookie) = match existing_device {
Some(id) => {
let _ = state.repos.oauth.upsert_account_device(did, &id).await;
(id, None)
}
None => {
let new_id = DeviceId::generate();
let device_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
if let Err(e) = state
.repos
.oauth
.create_device(&device_typed, &device_data)
.await
{
tracing::error!(error = ?e, "Failed to create device");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to establish session"
})),
)
.into_response();
}
if let Err(e) = state
.repos
.oauth
.upsert_account_device(did, &device_typed)
.await
{
tracing::error!(error = ?e, "Failed to link device to account");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to establish session"
})),
)
.into_response();
}
let cookie = make_device_cookie(&device_typed);
(device_typed, Some(cookie))
}
};
tracing::info!(did = %did, device_id = %device_id, "Device session established");
match new_cookie {
Some(cookie) => (
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({
"success": true,
"device_id": device_id
})),
)
.into_response(),
None => Json(serde_json::json!({
"success": true,
"device_id": device_id
}))
.into_response(),
}
}
@@ -0,0 +1,349 @@
use super::*;
#[derive(Debug, Deserialize)]
pub struct Authorize2faQuery {
pub request_uri: String,
pub channel: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Authorize2faSubmit {
pub request_uri: String,
pub code: String,
#[serde(default)]
pub trust_device: bool,
}
const MAX_2FA_ATTEMPTS: i32 = 5;
pub async fn authorize_2fa_get(
State(state): State<AppState>,
Query(query): Query<Authorize2faQuery>,
) -> Response {
let twofa_request_id = RequestId::from(query.request_uri.clone());
let challenge = match state.repos.oauth.get_2fa_challenge(&twofa_request_id).await {
Ok(Some(c)) => c,
Ok(None) => {
return redirect_to_frontend_error(
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
Err(_) => {
return redirect_to_frontend_error(
"server_error",
"An error occurred. Please try again.",
);
}
};
if challenge.expires_at < Utc::now() {
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
return redirect_to_frontend_error(
"invalid_request",
"2FA code has expired. Please start over.",
);
}
let _request_data = match state
.repos
.oauth
.get_authorization_request(&twofa_request_id)
.await
{
Ok(Some(d)) => d,
Ok(None) => {
return redirect_to_frontend_error(
"invalid_request",
"Authorization request not found. Please start over.",
);
}
Err(_) => {
return redirect_to_frontend_error(
"server_error",
"An error occurred. Please try again.",
);
}
};
let channel = query.channel.as_deref().unwrap_or("email");
redirect_see_other(&format!(
"/app/oauth/2fa?request_uri={}&channel={}",
url_encode(&query.request_uri),
url_encode(channel)
))
}
pub async fn authorize_2fa_post(
State(state): State<AppState>,
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
headers: HeaderMap,
Json(form): Json<Authorize2faSubmit>,
) -> Response {
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
(
status,
Json(serde_json::json!({
"error": error,
"error_description": description
})),
)
.into_response()
};
let twofa_post_request_id = RequestId::from(form.request_uri.clone());
let request_data = match state
.repos
.oauth
.get_authorization_request(&twofa_post_request_id)
.await
{
Ok(Some(d)) => d,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request not found.",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred.",
);
}
};
if request_data.expires_at < Utc::now() {
let _ = state
.repos
.oauth
.delete_authorization_request(&twofa_post_request_id)
.await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired.",
);
}
let challenge = state
.repos
.oauth
.get_2fa_challenge(&twofa_post_request_id)
.await
.ok()
.flatten();
if let Some(challenge) = challenge {
if challenge.expires_at < Utc::now() {
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"2FA code has expired. Please start over.",
);
}
if challenge.attempts >= MAX_2FA_ATTEMPTS {
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"access_denied",
"Too many failed attempts. Please start over.",
);
}
let code_valid: bool = form
.code
.trim()
.as_bytes()
.ct_eq(challenge.code.as_bytes())
.into();
if !code_valid {
let _ = state.repos.oauth.increment_2fa_attempts(challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"invalid_code",
"Invalid verification code. Please try again.",
);
}
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
let twofa_totp_device_id = device_id.clone();
let twofa_totp_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
.update_authorization_request(
&twofa_post_request_id,
&challenge.did,
twofa_totp_device_id.as_ref(),
&twofa_totp_code,
)
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
return Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response();
}
let did_str = match &request_data.did {
Some(d) => d.clone(),
None => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
};
let did: tranquil_types::Did = match did_str.parse() {
Ok(d) => d,
Err(_) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid DID format.",
);
}
};
if !tranquil_api::server::has_totp_enabled(&state, &did).await {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
let _rate_proof = match check_user_rate_limit::<TotpVerifyLimit>(&state, &did).await {
Ok(proof) => proof,
Err(_) => {
return json_error(
StatusCode::TOO_MANY_REQUESTS,
"RateLimitExceeded",
"Too many verification attempts. Please try again in a few minutes.",
);
}
};
let totp_valid =
tranquil_api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
if !totp_valid {
return json_error(
StatusCode::FORBIDDEN,
"invalid_code",
"Invalid verification code. Please try again.",
);
}
let mut device_id = extract_device_cookie(&headers);
let mut new_cookie: Option<String> = None;
if form.trust_device {
let trust_device_id = match &device_id {
Some(existing_id) => existing_id.clone(),
None => {
let new_id = DeviceId::generate();
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
let device_data = DeviceData {
session_id: SessionId::generate(),
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers, None),
last_seen_at: Utc::now(),
};
if state
.repos
.oauth
.create_device(&new_device_id_typed, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_device_id_typed));
device_id = Some(new_device_id_typed.clone());
}
new_device_id_typed
}
};
let _ = state
.repos
.oauth
.upsert_account_device(&did, &trust_device_id)
.await;
let _ =
tranquil_api::server::trust_device(state.repos.oauth.as_ref(), &trust_device_id, &did)
.await;
}
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let twofa_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.repos.oauth.as_ref(),
&did,
&twofa_post_client_id,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
if let Some(cookie) = new_cookie {
return (
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": consent_url})),
)
.into_response();
}
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let twofa_final_device_id = device_id.clone();
let twofa_final_code = AuthorizationCode::from(code.0.clone());
if state
.repos
.oauth
.update_authorization_request(
&twofa_post_request_id,
&did,
twofa_final_device_id.as_ref(),
&twofa_final_code,
)
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.map(|m| m.as_str()),
);
if let Some(cookie) = new_cookie {
(
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({"redirect_uri": redirect_url})),
)
.into_response()
} else {
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
}

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