Compare commits

..
Author SHA1 Message Date
Lewis 05246aec58 feat(tranquil-store): gc and hardening
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:47 +03:00
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
292 changed files with 85306 additions and 8618 deletions
+35
View File
@@ -13,9 +13,22 @@ fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "30s", terminate-after = 4 }
[profile.sim-pr]
retries = 0
fail-fast = true
test-threads = "num-cpus"
slow-timeout = { period = "60s", terminate-after = 4 }
[profile.sim-nightly]
retries = 0
fail-fast = false
test-threads = "num-cpus"
slow-timeout = { period = "300s", terminate-after = 2 }
[test-groups]
serial-env-tests = { max-threads = 1 }
heavy-load-tests = { max-threads = 4 }
io-heavy-sim = { max-threads = 2 }
[[profile.default.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
@@ -29,6 +42,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"
@@ -41,6 +58,20 @@ test-group = "heavy-load-tests"
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
[[profile.default.overrides]]
filter = "binary(sim_soak)"
slow-timeout = { period = "300s", terminate-after = 10 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "binary(metastore_crash) | binary(sim_cross_store) | binary(sim_gc_concurrent) | binary(sim_reachability) | binary(sim_eventlog) | test(/sim_/)"
slow-timeout = { period = "120s", terminate-after = 8 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "test(/test_scale_/) | test(/full_backup_and_restore/)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -53,6 +84,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"
@@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT seq, did, commit_cid\n FROM repo_seq\n WHERE event_type = 'commit'\n AND prev_cid IS NULL\n AND (blocks_cids IS NULL OR array_length(blocks_cids, 1) IS NULL OR array_length(blocks_cids, 1) = 0)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "commit_cid",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
true
]
},
"hash": "033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -94,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811"
"hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57"
}
@@ -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"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Int8"
]
},
"nullable": []
},
"hash": "3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"describe": {
"columns": [
{
@@ -50,33 +50,42 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
@@ -94,8 +103,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b"
"hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev)\n VALUES ($1, 'sync', $2, $3)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -13,12 +13,14 @@
"Left": [
"Text",
"Text",
"Text"
"Text",
"ByteaArray",
"ByteaArray"
]
},
"nullable": [
false
]
},
"hash": "bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710"
"hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b"
}
@@ -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,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM repo_seq WHERE created_at < $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz"
]
},
"nullable": []
},
"hash": "746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -50,32 +50,43 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
@@ -93,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903"
"hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -17,7 +17,8 @@
"Text",
"Jsonb",
"TextArray",
"TextArray",
"ByteaArray",
"ByteaArray",
"Text",
"Text"
]
@@ -26,5 +27,5 @@
false
]
},
"hash": "40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0"
"hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -16,7 +16,8 @@
"Text",
"Jsonb",
"TextArray",
"TextArray",
"ByteaArray",
"ByteaArray",
"Text"
]
},
@@ -24,5 +25,5 @@
false
]
},
"hash": "52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c"
"hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -94,8 +104,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a"
"hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256"
}
@@ -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"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT blocks_cids, commit_cid\n FROM repo_seq\n WHERE did = $1 AND rev > $2\n ORDER BY seq DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 1,
"name": "commit_cid",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -50,26 +50,36 @@
},
{
"ordinal": 9,
"name": "block_cids",
"type_info": "ByteaArray"
},
{
"ordinal": 10,
"name": "block_data",
"type_info": "ByteaArray"
},
{
"ordinal": 11,
"name": "blocks_cids",
"type_info": "TextArray"
},
{
"ordinal": 10,
"ordinal": 12,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 11,
"ordinal": 13,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 12,
"ordinal": 14,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 15,
"name": "rev",
"type_info": "Text"
}
@@ -93,8 +103,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f"
"hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426"
}
@@ -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
+1227 -634
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()
+3 -2
View File
@@ -11,6 +11,7 @@ pub use info::{
};
pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts};
pub use update::{
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
update_account_email, update_account_handle, update_account_password,
SetAdminStatusInput, UpdateAccountEmailInput, UpdateAccountHandleInput,
UpdateAccountPasswordInput, set_admin_status, update_account_email, update_account_handle,
update_account_password,
};
@@ -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
+44 -11
View File
@@ -1,6 +1,6 @@
use axum::{Json, extract::State};
use serde::Deserialize;
use tracing::{error, warn};
use tracing::{error, info, warn};
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
@@ -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
{
@@ -164,3 +166,34 @@ pub async fn update_account_password(
}
}
}
#[derive(Deserialize)]
pub struct SetAdminStatusInput {
pub did: Did,
pub admin: bool,
}
pub async fn set_admin_status(
State(state): State<AppState>,
auth: Auth<Admin>,
Json(input): Json<SetAdminStatusInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
info!(
actor = %auth.did,
target = %input.did,
admin = input.admin,
"admin status change"
);
state
.repos
.user
.set_admin_status(&input.did, input.admin)
.await
.map_err(|e| {
error!("DB error setting admin status: {:?}", e);
ApiError::InternalError(None)
})?;
Ok(Json(EmptyResponse {}))
}
+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
{
+3 -1
View File
@@ -2,15 +2,17 @@ pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod signal;
pub mod status;
pub use account::{
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
update_account_email, update_account_handle, update_account_password,
set_admin_status, update_account_email, update_account_handle, update_account_password,
};
pub use config::{get_server_config, update_server_config};
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,
+7
View File
@@ -330,6 +330,13 @@ pub fn api_routes() -> axum::Router<AppState> {
get(admin::get_invite_codes),
)
.route("/_admin.getServerStats", get(admin::get_server_stats))
.route("/_admin.setAdminStatus", post(admin::set_admin_status))
.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")?;
+41 -15
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| {
@@ -312,7 +328,8 @@ pub async fn import_repo(
new_root_str, new_rev_str
);
if !is_migration
&& let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await
&& let Err(e) =
sequence_import_event(&state, did, &new_root_cid_link, &commit_bytes).await
{
warn!("Failed to sequence import event: {:?}", e);
}
@@ -322,7 +339,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",
@@ -378,20 +396,28 @@ async fn sequence_import_event(
state: &AppState,
did: &Did,
commit_cid: &CidLink,
) -> Result<(), tranquil_db::DbError> {
let data = tranquil_db::CommitEventData {
commit_bytes: &[u8],
) -> Result<(), tranquil_db_traits::DbError> {
let commit_cid_parsed = commit_cid
.to_cid()
.expect("CidLink invariant: validated at construction");
let inline_commit = tranquil_db_traits::EventBlockInline {
cid_bytes: commit_cid_parsed.to_bytes(),
data: commit_bytes.to_vec(),
};
let data = tranquil_db_traits::CommitEventData {
did: did.clone(),
event_type: tranquil_db::RepoEventType::Commit,
event_type: tranquil_db_traits::RepoEventType::Commit,
commit_cid: Some(commit_cid.clone()),
prev_cid: None,
ops: Some(serde_json::json!([])),
blobs: Some(vec![]),
blocks_cids: Some(vec![]),
blocks: Some(vec![inline_commit]),
prev_data_cid: None,
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();
+48 -9
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(),
@@ -87,7 +94,7 @@ async fn process_single_write(
ops.push(RecordOp::Create {
collection: collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
@@ -95,6 +102,8 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Update {
@@ -125,12 +134,22 @@ async fn process_single_write(
.map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?;
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.ok_or_else(|| {
ApiError::InvalidRequest("Update target record does not exist".into())
})?;
let new_mst = mst
.update(&key, record_cid)
.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(),
@@ -139,8 +158,8 @@ async fn process_single_write(
ops.push(RecordOp::Update {
collection: collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
prev: prev_record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
@@ -148,21 +167,32 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
WriteOp::Delete { collection, rkey } => {
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.ok_or_else(|| {
ApiError::InvalidRequest("Delete target record does not exist".into())
})?;
let new_mst = mst
.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(),
rkey: rkey.clone(),
prev: prev_record_cid,
prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid),
});
Ok(WriteAccumulator {
mst: new_mst,
@@ -170,6 +200,8 @@ async fn process_single_write(
ops,
modified_keys,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
})
}
}
@@ -189,6 +221,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 +338,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 +353,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 +409,8 @@ pub async fn apply_writes(
ops,
modified_keys: &modified_keys,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
+14 -138
View File
@@ -1,20 +1,16 @@
use crate::repo::record::write::{CommitInfo, prepare_repo_write};
use axum::{Json, extract::State};
use cid::Cid;
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
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_ops::{
CommitError, FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write,
};
use tranquil_pds::cid_types::RecordCid;
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
#[derive(Deserialize)]
pub struct DeleteRecordInput {
@@ -59,23 +55,27 @@ pub async fn delete_record(
}
}
let prev_record_cid = mst.get(&key).await.ok().flatten();
if prev_record_cid.is_none() {
let prev_record_cid = mst.get(&key).await.map_err(|e| {
error!("Failed to read prev record from MST: {}", e);
ApiError::InternalError(Some("Failed to read MST".into()))
})?;
let Some(prev_record_cid) = prev_record_cid else {
return Ok(Json(DeleteRecordOutput { commit: None }));
}
};
let new_mst = mst.delete(&key).await.map_err(|e| {
error!("Failed to delete from MST: {:?}", e);
error!("Failed to delete from MST: {}", e);
ApiError::InternalError(Some("Failed to delete from MST".into()))
})?;
let op = RecordOp::Delete {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
prev: prev_record_cid,
prev: RecordCid::from(prev_record_cid),
};
let modified_keys = [key];
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
&state,
@@ -95,19 +95,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(),
@@ -115,120 +108,3 @@ pub async fn delete_record(
}),
}))
}
use uuid::Uuid;
pub async fn delete_record_internal(
state: &AppState,
did: &Did,
user_id: Uuid,
collection: &Nsid,
rkey: &Rkey,
) -> Result<(), CommitError> {
use tranquil_pds::repo_ops::{CommitParams, RecordOp, commit_and_log};
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repo_repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
.ok_or(CommitError::RepoNotFound)?;
let current_root_cid =
Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?;
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store
.get(&current_root_cid)
.await
.map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?
.ok_or(CommitError::BlockStoreFailed(
"Commit block not found".into(),
))?;
let commit = Commit::from_cbor(&commit_bytes)
.map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?;
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let key = format!("{}/{}", collection, rkey);
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let Some(prev_cid) = prev_record_cid else {
return Ok(());
};
let new_mst = mst
.delete(&key)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let new_mst_root = new_mst
.persist()
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let op = RecordOp::Delete {
collection: collection.clone(),
rkey: rkey.clone(),
prev: Some(prev_cid),
};
let mut new_mst_blocks = std::collections::BTreeMap::new();
let mut old_mst_blocks = std::collections::BTreeMap::new();
new_mst
.blocks_for_path(&key, &mut new_mst_blocks)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
mst.blocks_for_path(&key, &mut old_mst_blocks)
.await
.map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?;
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
.chain(
old_mst_blocks
.keys()
.filter(|cid| !new_mst_blocks.contains_key(*cid))
.copied(),
)
.chain(std::iter::once(prev_cid))
.collect();
let mut relevant_blocks = new_mst_blocks;
relevant_blocks.extend(old_mst_blocks);
let written_cids: Vec<Cid> = tracking_store
.get_all_relevant_cids()
.into_iter()
.chain(relevant_blocks.keys().copied())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let written_cids_str: Vec<String> = written_cids.iter().map(ToString::to_string).collect();
commit_and_log(
state,
CommitParams {
did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
blobs: &[],
obsolete_cids,
},
)
.await?;
Ok(())
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub use pagination::PaginationDirection;
pub use validation_mode::ValidationMode;
pub use batch::apply_writes;
pub use delete::{DeleteRecordInput, delete_record, delete_record_internal};
pub use delete::{DeleteRecordInput, delete_record};
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
pub use tranquil_pds::repo_ops::*;
pub use write::{
+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)
+55 -55
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,24 +147,32 @@ 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,
prev: Some(prev_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_cid),
});
conflict_uris_to_cleanup.push(conflict_uri);
}
@@ -187,7 +197,7 @@ pub async fn create_record(
ops.push(RecordOp::Create {
collection: input.collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
});
let modified_keys: Vec<String> = ops
@@ -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(),
@@ -344,34 +338,38 @@ pub async fn put_record(
}));
}
let is_update = existing_cid.is_some();
let new_mst = if is_update {
mst.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?
} else {
mst.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?
};
let op = if is_update {
RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
prev: existing_cid,
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
Some(prev_cid) => {
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
let op = RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
prev: tranquil_pds::cid_types::RecordCid::from(prev_cid),
};
(new_mst, op, true, vec![record_uri.clone()])
}
} else {
RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
None => {
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: tranquil_pds::cid_types::RecordCid::from(record_cid),
};
(new_mst, op, false, vec![])
}
};
let modified_keys = [key];
let blob_cids = extract_blob_cids(&input.record);
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
@@ -391,12 +389,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));
@@ -393,19 +393,19 @@ pub async fn create_passkey_account(
Ok(token_meta) => {
let refresh_jti = uuid::Uuid::new_v4().to_string();
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
let session_data = tranquil_db::SessionTokenCreate {
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_typed.clone(),
access_jti: token_meta.jti.clone(),
refresh_jti,
access_expires_at: token_meta.expires_at,
refresh_expires_at: refresh_expires,
login_type: tranquil_db::LoginType::Modern,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic".to_string()),
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()),
))
}
}
+185 -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,55 @@ 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());
}
if self.tranquil_store.eventlog_max_event_payload == 0 {
errors.push(
"tranquil_store.eventlog_max_event_payload \
(TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD) must be at least 1; \
a value of 0 would reject every event"
.to_string(),
);
}
// -- scheduled / event retention --------------------------------------
const MAX_RETENTION_SECS: u64 = (i64::MAX / 1000) as u64;
if self.scheduled.event_retention_max_age_secs > MAX_RETENTION_SECS {
errors.push(format!(
"scheduled.event_retention_max_age_secs (EVENT_RETENTION_MAX_AGE_SECS) \
must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}",
self.scheduled.event_retention_max_age_secs
));
}
if self.scheduled.event_retention_interval_secs > 0 {
let backfill_secs = u64::try_from(self.firehose.backfill_hours.max(0))
.unwrap_or(0)
.saturating_mul(3600);
if self.scheduled.event_retention_max_age_secs < backfill_secs {
errors.push(format!(
"scheduled.event_retention_max_age_secs ({}) is shorter than \
firehose.backfill_hours ({}h = {backfill_secs}s): \
relays would receive cursor responses pointing at pruned events. \
Increase event_retention_max_age_secs or decrease firehose.backfill_hours.",
self.scheduled.event_retention_max_age_secs, self.firehose.backfill_hours,
));
}
}
// -- cache ------------------------------------------------------------
match self.cache.backend.as_str() {
"valkey" => {
@@ -561,6 +613,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 +659,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 +770,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 +1088,97 @@ 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 data file compaction scans (tranquil-store only).
/// Set to 0 to disable.
#[config(env = "COMPACTION_INTERVAL_SECS", default = 3600)]
pub compaction_interval_secs: u64,
/// Liveness ratio threshold below which a data file is compacted (0.0-1.0).
#[config(env = "COMPACTION_LIVENESS_THRESHOLD", default = 0.7)]
pub compaction_liveness_threshold: f64,
/// Grace period in milliseconds before a zero-refcount block can be removed by compaction.
#[config(env = "COMPACTION_GRACE_PERIOD_MS", default = 600000)]
pub compaction_grace_period_ms: u64,
/// Interval in seconds between reachability walk runs (tranquil-store only).
/// Set to 0 to disable. Default: weekly.
#[config(env = "REACHABILITY_WALK_INTERVAL_SECS", default = 604800)]
pub reachability_walk_interval_secs: u64,
/// Interval in seconds between continuous archival passes (tranquil-store only).
/// Sealed eventlog segments are copied to the archival destination each tick.
/// Set to 0 to disable. Default: 60 seconds.
#[config(env = "ARCHIVAL_INTERVAL_SECS", default = 60)]
pub archival_interval_secs: u64,
/// Archival destination directory for sealed eventlog segments.
/// If unset, archival is disabled.
#[config(env = "ARCHIVAL_DEST_DIR")]
pub archival_dest_dir: Option<String>,
/// Maximum age of events retained in the eventlog before pruning.
/// Per the atproto firehose spec, the relay backfill window only needs
/// to cover "hours or days". Default: 7 days.
#[config(env = "EVENT_RETENTION_MAX_AGE_SECS", default = 604800)]
pub event_retention_max_age_secs: u64,
/// Interval in seconds between event retention prune passes.
/// Set to 0 to disable. Default: hourly.
#[config(env = "EVENT_RETENTION_INTERVAL_SECS", default = 3600)]
pub event_retention_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>,
/// Maximum total bytes of pending (unsynced) eventlog payloads. Appenders
/// block once this budget is exhausted until in-flight events drain via
/// fsync. Set to 0 to disable backpressure (unbounded). Default: 1 GiB.
#[config(
env = "TRANQUIL_STORE_EVENTLOG_PENDING_BYTES_BUDGET",
default = 1_073_741_824
)]
pub eventlog_pending_bytes_budget: u64,
/// Maximum size of an individual eventlog payload in bytes. Single events
/// larger than this are rejected at append time. Default: 256 MiB.
#[config(
env = "TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD",
default = 268_435_456
)]
pub eventlog_max_event_payload: u32,
/// Maximum size of an individual blockstore data file in bytes. When the
/// active data file reaches this size it is rolled over and becomes
/// eligible for compaction. Default: 256 MiB.
#[config(env = "TRANQUIL_STORE_MAX_BLOCKSTORE_FILE_SIZE", default = 268_435_456)]
pub max_blockstore_file_size: u64,
/// Maximum size of an individual eventlog segment file in bytes. When the
/// active segment reaches this size it is sealed and a new one is created.
/// Safe to change on a running instance. Default: 256 MiB.
#[config(
env = "TRANQUIL_STORE_MAX_EVENTLOG_SEGMENT_SIZE",
default = 268_435_456
)]
pub max_eventlog_segment_size: u64,
}
/// 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)]
+10 -8
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::{
@@ -32,11 +33,12 @@ pub use oauth::{
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
};
pub use repo::{
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit,
CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier,
RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent,
RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData,
EventBlockInline, EventBlocks, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
PruneCount, RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo,
RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository,
RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
UserWithoutBlocks,
};
pub use scope::{DbScope, InvalidScopeError};
pub use sequence::{SequenceNumber, deserialize_optional_sequence};
+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>;
}
+65 -29
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)]
@@ -164,13 +165,6 @@ pub struct RepoWithoutRev {
pub repo_root_cid: CidLink,
}
#[derive(Debug, Clone)]
pub struct BrokenGenesisCommit {
pub seq: SequenceNumber,
pub did: Did,
pub commit_cid: Option<CidLink>,
}
#[derive(Debug, Clone)]
pub struct UserWithoutBlocks {
pub user_id: Uuid,
@@ -189,6 +183,51 @@ pub struct RepoSeqEvent {
pub seq: SequenceNumber,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneCount {
Rows(u64),
Segments(u64),
}
impl PruneCount {
pub fn is_zero(&self) -> bool {
match self {
Self::Rows(n) | Self::Segments(n) => *n == 0,
}
}
pub fn count(&self) -> u64 {
match self {
Self::Rows(n) | Self::Segments(n) => *n,
}
}
pub fn unit(&self) -> &'static str {
match self {
Self::Rows(_) => "rows",
Self::Segments(_) => "segments",
}
}
}
impl std::fmt::Display for PruneCount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.count(), self.unit())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventBlockInline {
pub cid_bytes: Vec<u8>,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EventBlocks {
Inline(Vec<EventBlockInline>),
LegacyCids(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequencedEvent {
pub seq: SequenceNumber,
@@ -200,7 +239,7 @@ pub struct SequencedEvent {
pub prev_data_cid: Option<CidLink>,
pub ops: Option<serde_json::Value>,
pub blobs: Option<Vec<String>>,
pub blocks_cids: Option<Vec<String>>,
pub blocks: Option<EventBlocks>,
pub handle: Option<Handle>,
pub active: Option<bool>,
pub status: Option<AccountStatus>,
@@ -215,17 +254,11 @@ pub struct CommitEventData {
pub prev_cid: Option<CidLink>,
pub ops: Option<serde_json::Value>,
pub blobs: Option<Vec<String>>,
pub blocks_cids: Option<Vec<String>>,
pub blocks: Option<Vec<EventBlockInline>>,
pub prev_data_cid: Option<CidLink>,
pub rev: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventBlocksCids {
pub blocks_cids: Option<Vec<String>>,
pub commit_cid: Option<CidLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoListItem {
pub did: Did,
@@ -279,6 +312,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 +335,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 +350,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>;
@@ -419,6 +464,7 @@ pub trait RepoRepository: Send + Sync {
did: &Did,
commit_cid: &CidLink,
rev: Option<&str>,
commit_bytes: &[u8],
) -> Result<SequenceNumber, DbError>;
async fn insert_genesis_commit_event(
@@ -427,20 +473,18 @@ pub trait RepoRepository: Send + Sync {
commit_cid: &CidLink,
mst_root_cid: &CidLink,
rev: &str,
commit_bytes: &[u8],
mst_root_bytes: &[u8],
) -> Result<SequenceNumber, DbError>;
async fn update_seq_blocks_cids(
&self,
seq: SequenceNumber,
blocks_cids: &[String],
) -> Result<(), DbError>;
async fn delete_sequences_except(
&self,
did: &Did,
keep_seq: SequenceNumber,
) -> Result<(), DbError>;
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError>;
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
async fn get_min_seq_since(
@@ -473,12 +517,6 @@ pub trait RepoRepository: Send + Sync {
limit: i64,
) -> Result<Vec<SequencedEvent>, DbError>;
async fn get_events_since_rev(
&self,
did: &Did,
since_rev: &str,
) -> Result<Vec<EventBlocksCids>, DbError>;
async fn list_repos_paginated(
&self,
cursor_did: Option<&Did>,
@@ -503,8 +541,6 @@ pub trait RepoRepository: Send + Sync {
input: ApplyCommitInput,
) -> Result<ApplyCommitResult, ApplyCommitError>;
async fn get_broken_genesis_commits(&self) -> Result<Vec<BrokenGenesisCommit>, DbError>;
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError>;
async fn get_users_needing_record_blobs_backfill(
@@ -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())),

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