mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 18:56:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0e0c6810 | ||
|
|
1720de8650 | ||
|
|
a5c68a3506 | ||
|
|
9ea33def13 | ||
|
|
a9e3ec63e1 | ||
|
|
dbc3c23c95 | ||
|
|
531ca7c1c0 | ||
|
|
add1ff176b | ||
|
|
bdb200a30c | ||
|
|
e5faba3e73 | ||
|
|
5dc810ceef | ||
|
|
6abc0eaca2 | ||
|
|
696940f732 | ||
|
|
f777c288da | ||
|
|
9039648705 | ||
|
|
770a16b043 | ||
|
|
d37f38e496 | ||
|
|
282f08f207 | ||
|
|
f07bcb82f7 | ||
|
|
e38343ce09 | ||
|
|
e454e99b8a | ||
|
|
19b0ea19c6 | ||
|
|
318818ad51 | ||
|
|
7c55a5ceb9 | ||
|
|
9d31ee9ace | ||
|
|
e3efcb43ba | ||
|
|
eb46824220 |
@@ -29,6 +29,10 @@ test-group = "serial-env-tests"
|
||||
filter = "binary(ripple_cluster)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(tranquil-signal)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
@@ -53,6 +57,10 @@ test-group = "serial-env-tests"
|
||||
filter = "binary(ripple_cluster)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "package(tranquil-signal)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(whole_story)"
|
||||
test-group = "heavy-load-tests"
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT cid FROM blocks ORDER BY created_at ASC LIMIT $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cid",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)\n WHERE NOT EXISTS (\n SELECT 1 FROM user_blocks WHERE block_cid = t.cid\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cid",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+36
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM blocks WHERE cid = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+180
@@ -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"
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM backlinks WHERE uri = ANY($1::text[])",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cffe4c37fe949fbdc3d5cd83ccec5655aae248a0a69dc260d1da9cf1d9ed2c49"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+52
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
when:
|
||||
- event: ["push"]
|
||||
branches: main
|
||||
- event: []
|
||||
branch: []
|
||||
|
||||
engine: nixery
|
||||
|
||||
@@ -20,5 +20,5 @@ steps:
|
||||
|
||||
- name: Publish image
|
||||
command: |
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil-pds/tranquil:latest"
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil-pds/tranquil:$TANGLED_COMMIT_SHA"
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
|
||||
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
|
||||
Generated
+1201
-613
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms.did)
|
||||
.await
|
||||
.log_db_err("in get_account_info")?
|
||||
@@ -98,7 +99,8 @@ pub async fn get_account_info(
|
||||
|
||||
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
|
||||
let code = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_used_by_user(user_id)
|
||||
.await
|
||||
.ok()??;
|
||||
@@ -111,7 +113,8 @@ async fn get_invites_for_user(
|
||||
user_id: uuid::Uuid,
|
||||
) -> Option<Vec<InviteCodeInfo>> {
|
||||
let invite_codes = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invites_created_by_user(user_id)
|
||||
.await
|
||||
.ok()?;
|
||||
@@ -123,7 +126,8 @@ async fn get_invites_for_user(
|
||||
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
|
||||
|
||||
let uses = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.await
|
||||
.ok()?;
|
||||
@@ -154,10 +158,11 @@ async fn get_invites_for_user(
|
||||
}
|
||||
|
||||
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
|
||||
let info = state.infra_repo.get_invite_code_info(code).await.ok()??;
|
||||
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
|
||||
|
||||
let uses = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses(code)
|
||||
.await
|
||||
.ok()
|
||||
@@ -197,7 +202,8 @@ pub async fn get_account_infos(
|
||||
|
||||
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
|
||||
let accounts = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_admin_account_infos_by_dids(&dids_typed)
|
||||
.await
|
||||
.log_db_err("fetching account infos")?;
|
||||
@@ -205,7 +211,8 @@ pub async fn get_account_infos(
|
||||
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
|
||||
|
||||
let all_invite_codes = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_codes_by_users(&user_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -217,7 +224,8 @@ pub async fn get_account_infos(
|
||||
|
||||
let all_invite_uses = if !all_codes.is_empty() {
|
||||
state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&all_codes)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@@ -226,7 +234,8 @@ pub async fn get_account_infos(
|
||||
};
|
||||
|
||||
let invited_by_map: HashMap<uuid::Uuid, String> = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_by_users(&user_ids)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -51,15 +51,14 @@ pub async fn search_accounts(
|
||||
Query(params): Query<SearchAccountsParams>,
|
||||
) -> Result<Json<SearchAccountsOutput>, ApiError> {
|
||||
let limit = params.limit.clamp(1, 100);
|
||||
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
|
||||
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
|
||||
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
|
||||
let rows = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.search_accounts(
|
||||
cursor_did.as_ref(),
|
||||
email_filter.as_deref(),
|
||||
handle_filter.as_deref(),
|
||||
params.email.as_deref(),
|
||||
params.handle.as_deref(),
|
||||
limit + 1,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -24,7 +24,7 @@ pub async fn disable_invite_codes(
|
||||
Json(input): Json<DisableInviteCodesInput>,
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
if let Some(codes) = &input.codes
|
||||
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
|
||||
&& let Err(e) = state.repos.infra.disable_invite_codes_by_code(codes).await
|
||||
{
|
||||
error!("DB error disabling invite codes: {:?}", e);
|
||||
}
|
||||
@@ -32,7 +32,8 @@ pub async fn disable_invite_codes(
|
||||
let accounts_typed: Vec<tranquil_types::Did> =
|
||||
accounts.iter().filter_map(|a| a.parse().ok()).collect();
|
||||
if let Err(e) = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.disable_invite_codes_by_account(&accounts_typed)
|
||||
.await
|
||||
{
|
||||
@@ -87,7 +88,8 @@ pub async fn get_invite_codes(
|
||||
};
|
||||
|
||||
let codes_rows = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
|
||||
.await
|
||||
.log_db_err("fetching invite codes")?;
|
||||
@@ -96,7 +98,8 @@ pub async fn get_invite_codes(
|
||||
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
|
||||
|
||||
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_user_dids_by_ids(&user_ids)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
@@ -108,7 +111,8 @@ pub async fn get_invite_codes(
|
||||
} else {
|
||||
common::group_invite_uses_by_code(
|
||||
state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
@@ -168,7 +172,8 @@ pub async fn disable_account_invites(
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.set_invites_disabled(&account_did, true)
|
||||
.await
|
||||
{
|
||||
@@ -200,7 +205,8 @@ pub async fn enable_account_invites(
|
||||
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
|
||||
|
||||
match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.set_invites_disabled(&account_did, false)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod account;
|
||||
pub mod config;
|
||||
pub mod invite;
|
||||
pub mod server_stats;
|
||||
pub mod signal;
|
||||
pub mod status;
|
||||
|
||||
pub use account::{
|
||||
@@ -13,4 +14,5 @@ pub use invite::{
|
||||
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
|
||||
};
|
||||
pub use server_stats::get_server_stats;
|
||||
pub use signal::{get_signal_status, link_signal_device, unlink_signal_device};
|
||||
pub use status::{get_subject_status, update_subject_status};
|
||||
|
||||
@@ -17,10 +17,10 @@ pub async fn get_server_stats(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Json<ServerStatsOutput>, ApiError> {
|
||||
let user_count = state.user_repo.count_users().await.unwrap_or(0);
|
||||
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
|
||||
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
|
||||
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
|
||||
let user_count = state.repos.user.count_users().await.unwrap_or(0);
|
||||
let repo_count = state.repos.repo.count_repos().await.unwrap_or(0);
|
||||
let record_count = state.repos.repo.count_all_records().await.unwrap_or(0);
|
||||
let blob_storage_bytes = state.repos.blob.sum_blob_storage().await.unwrap_or(0);
|
||||
|
||||
Ok(Json(ServerStatsOutput {
|
||||
user_count,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¤t_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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -330,6 +330,12 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
get(admin::get_invite_codes),
|
||||
)
|
||||
.route("/_admin.getServerStats", get(admin::get_server_stats))
|
||||
.route("/_admin.getSignalStatus", get(admin::get_signal_status))
|
||||
.route("/_admin.linkSignalDevice", post(admin::link_signal_device))
|
||||
.route(
|
||||
"/_admin.unlinkSignalDevice",
|
||||
post(admin::unlink_signal_device),
|
||||
)
|
||||
.route("/_server.getConfig", get(admin::get_server_config))
|
||||
.route(
|
||||
"/_admin.updateServerConfig",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -34,7 +34,8 @@ pub async fn import_repo(
|
||||
}
|
||||
let did = &auth.did;
|
||||
let user = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.log_db_err("fetching user")?
|
||||
@@ -44,7 +45,8 @@ pub async fn import_repo(
|
||||
}
|
||||
let user_id = user.id;
|
||||
let expected_root_cid = state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -193,8 +195,18 @@ pub async fn import_repo(
|
||||
}
|
||||
let max_blocks = tranquil_config::get().import.max_blocks as usize;
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
state
|
||||
.block_store
|
||||
.put_many(blocks.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to store import blocks: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
match apply_import(
|
||||
&state.repo_repo,
|
||||
&state.repos.repo,
|
||||
user_id,
|
||||
root,
|
||||
blocks.clone(),
|
||||
@@ -232,7 +244,8 @@ pub async fn import_repo(
|
||||
blob_refs.into_iter().unzip();
|
||||
|
||||
match state
|
||||
.blob_repo
|
||||
.repos
|
||||
.blob
|
||||
.insert_record_blobs(user_id, &record_uris, &blob_cids)
|
||||
.await
|
||||
{
|
||||
@@ -248,7 +261,8 @@ pub async fn import_repo(
|
||||
}
|
||||
}
|
||||
let key_row = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_user_with_key_by_did(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -289,7 +303,8 @@ pub async fn import_repo(
|
||||
})?;
|
||||
let new_root_cid_link = CidLink::from(&new_root_cid);
|
||||
state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -299,7 +314,8 @@ pub async fn import_repo(
|
||||
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
|
||||
all_block_cids.push(new_root_cid.to_bytes());
|
||||
state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.insert_user_blocks(user_id, &all_block_cids, &new_rev_str)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -322,7 +338,8 @@ pub async fn import_repo(
|
||||
"birthDate": "1998-05-06T00:00:00.000Z"
|
||||
});
|
||||
if let Err(e) = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.insert_account_preference_if_not_exists(
|
||||
user_id,
|
||||
"app.bsky.actor.defs#personalDetailsPref",
|
||||
@@ -391,7 +408,7 @@ async fn sequence_import_event(
|
||||
rev: None,
|
||||
};
|
||||
|
||||
let seq = state.repo_repo.insert_commit_event(&data).await?;
|
||||
state.repo_repo.notify_update(seq).await?;
|
||||
let seq = state.repos.repo.insert_commit_event(&data).await?;
|
||||
state.repos.repo.notify_update(seq).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,14 +6,16 @@ use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
use tranquil_db_traits::Backlink;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
use tranquil_pds::auth::{
|
||||
Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated,
|
||||
verify_batch_write_scopes,
|
||||
};
|
||||
use tranquil_pds::repo::tracking::TrackingBlockStore;
|
||||
use tranquil_pds::repo::TrackingBlockStore;
|
||||
use tranquil_pds::repo_ops::{
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_blob_cids, finalize_repo_write,
|
||||
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
|
||||
finalize_repo_write,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
@@ -27,6 +29,8 @@ struct WriteAccumulator {
|
||||
ops: Vec<RecordOp>,
|
||||
modified_keys: Vec<String>,
|
||||
all_blob_cids: Vec<String>,
|
||||
backlinks_to_add: Vec<Backlink>,
|
||||
backlinks_to_remove: Vec<AtUri>,
|
||||
}
|
||||
|
||||
async fn process_single_write(
|
||||
@@ -42,6 +46,8 @@ async fn process_single_write(
|
||||
mut ops,
|
||||
mut modified_keys,
|
||||
mut all_blob_cids,
|
||||
mut backlinks_to_add,
|
||||
mut backlinks_to_remove,
|
||||
} = acc;
|
||||
|
||||
match write {
|
||||
@@ -79,6 +85,7 @@ async fn process_single_write(
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
|
||||
let uri = AtUri::from_parts(did, collection, &rkey);
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
results.push(WriteResult::CreateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
@@ -95,6 +102,8 @@ async fn process_single_write(
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
})
|
||||
}
|
||||
WriteOp::Update {
|
||||
@@ -131,6 +140,8 @@ async fn process_single_write(
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
|
||||
let uri = AtUri::from_parts(did, collection, rkey);
|
||||
backlinks_to_remove.push(uri.clone());
|
||||
backlinks_to_add.extend(extract_backlinks(&uri, value));
|
||||
results.push(WriteResult::UpdateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
@@ -148,6 +159,8 @@ async fn process_single_write(
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
})
|
||||
}
|
||||
WriteOp::Delete { collection, rkey } => {
|
||||
@@ -158,6 +171,7 @@ async fn process_single_write(
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
|
||||
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
|
||||
results.push(WriteResult::DeleteResult {});
|
||||
ops.push(RecordOp::Delete {
|
||||
collection: collection.clone(),
|
||||
@@ -170,6 +184,8 @@ async fn process_single_write(
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -189,6 +205,8 @@ async fn process_writes(
|
||||
ops: Vec::new(),
|
||||
modified_keys: Vec::new(),
|
||||
all_blob_cids: Vec::new(),
|
||||
backlinks_to_add: Vec::new(),
|
||||
backlinks_to_remove: Vec::new(),
|
||||
};
|
||||
stream::iter(writes.iter().map(Ok::<_, ApiError>))
|
||||
.try_fold(initial_acc, |acc, write| async move {
|
||||
@@ -304,7 +322,8 @@ pub async fn apply_writes(
|
||||
require_verified_or_delegated(&state, batch_proof.user()).await?;
|
||||
|
||||
let user_id: uuid::Uuid = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_did(&did)
|
||||
.await
|
||||
.log_db_err("fetching user for batch write")?
|
||||
@@ -318,6 +337,8 @@ pub async fn apply_writes(
|
||||
ops,
|
||||
modified_keys,
|
||||
all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
} = process_writes(
|
||||
&input.writes,
|
||||
mst,
|
||||
@@ -372,6 +393,8 @@ pub async fn apply_writes(
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &all_blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{Active, Auth, VerifyScope};
|
||||
use tranquil_pds::repo::tracking::TrackingBlockStore;
|
||||
use tranquil_pds::repo::TrackingBlockStore;
|
||||
use tranquil_pds::repo_ops::{
|
||||
CommitError, FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write,
|
||||
};
|
||||
@@ -76,6 +76,7 @@ pub async fn delete_record(
|
||||
};
|
||||
|
||||
let modified_keys = [key];
|
||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
@@ -95,19 +96,12 @@ pub async fn delete_record(
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &[],
|
||||
backlinks_to_add: vec![],
|
||||
backlinks_to_remove: vec![deleted_uri],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
if let Err(e) = state
|
||||
.backlink_repo
|
||||
.remove_backlinks_by_uri(&deleted_uri)
|
||||
.await
|
||||
{
|
||||
error!("Failed to remove backlinks for {}: {}", deleted_uri, e);
|
||||
}
|
||||
|
||||
Ok(Json(DeleteRecordOutput {
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
@@ -130,7 +124,8 @@ pub async fn delete_record_internal(
|
||||
let _write_lock = state.repo_write_locks.lock(user_id).await;
|
||||
|
||||
let root_cid_str = state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.get_repo_root_cid_by_user_id(user_id)
|
||||
.await
|
||||
.map_err(|e| CommitError::DatabaseError(e.to_string()))?
|
||||
@@ -214,6 +209,7 @@ pub async fn delete_record_internal(
|
||||
|
||||
let written_cids_str: Vec<String> = written_cids.iter().map(ToString::to_string).collect();
|
||||
|
||||
let deleted_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str());
|
||||
commit_and_log(
|
||||
state,
|
||||
CommitParams {
|
||||
@@ -226,6 +222,8 @@ pub async fn delete_record_internal(
|
||||
blocks_cids: &written_cids_str,
|
||||
blobs: &[],
|
||||
obsolete_cids,
|
||||
backlinks_to_add: vec![],
|
||||
backlinks_to_remove: vec![deleted_uri],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,7 +46,8 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
|
||||
let _account_verified = require_verified_or_delegated(state, user).await?;
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_did(principal_did.as_did())
|
||||
.await
|
||||
.log_db_err("fetching user for repo write")?
|
||||
@@ -128,7 +129,8 @@ pub async fn create_record(
|
||||
|
||||
if !backlinks.is_empty() {
|
||||
let conflicts = state
|
||||
.backlink_repo
|
||||
.repos
|
||||
.backlink
|
||||
.get_backlink_conflicts(user_id, &input.collection, &backlinks)
|
||||
.await
|
||||
.log_db_err("checking backlink conflicts")?;
|
||||
@@ -145,20 +147,28 @@ pub async fn create_record(
|
||||
|
||||
let prev_cid = match mst.get(&conflict_key).await {
|
||||
Ok(Some(cid)) => cid,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
mst = match mst.delete(&conflict_key).await {
|
||||
Ok(m) => m,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to delete conflict from MST {}: {:?}",
|
||||
"Failed to read conflict record from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
continue;
|
||||
return Err(ApiError::InternalError(Some(
|
||||
"Failed to read conflicting record from MST".into(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
mst = mst.delete(&conflict_key).await.map_err(|e| {
|
||||
error!(
|
||||
"Failed to delete conflict from MST {}: {:?}",
|
||||
conflict_uri, e
|
||||
);
|
||||
ApiError::InternalError(Some(
|
||||
"Failed to delete conflicting record from MST".into(),
|
||||
))
|
||||
})?;
|
||||
|
||||
ops.push(RecordOp::Delete {
|
||||
collection: conflict_collection,
|
||||
rkey: conflict_rkey,
|
||||
@@ -206,6 +216,9 @@ pub async fn create_record(
|
||||
.collect();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
|
||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
ctx,
|
||||
@@ -224,31 +237,12 @@ pub async fn create_record(
|
||||
ops,
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove: conflict_uris_to_cleanup,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let backlink_repo = state.backlink_repo.clone();
|
||||
futures::future::join_all(conflict_uris_to_cleanup.iter().map(|uri| {
|
||||
let backlink_repo = backlink_repo.clone();
|
||||
async move {
|
||||
if let Err(e) = backlink_repo.remove_backlinks_by_uri(uri).await {
|
||||
error!("Failed to remove backlinks for {}: {}", uri, e);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
|
||||
let backlinks = extract_backlinks(&created_uri, &input.record);
|
||||
if !backlinks.is_empty()
|
||||
&& let Err(e) = state.backlink_repo.add_backlinks(user_id, &backlinks).await
|
||||
{
|
||||
error!("Failed to add backlinks for {}: {}", created_uri, e);
|
||||
}
|
||||
|
||||
Ok(Json(CreateRecordOutput {
|
||||
uri: created_uri,
|
||||
cid: record_cid.to_string(),
|
||||
@@ -373,6 +367,13 @@ pub async fn put_record(
|
||||
let modified_keys = [key];
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
|
||||
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
|
||||
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
|
||||
let backlinks_to_remove = match is_update {
|
||||
true => vec![record_uri.clone()],
|
||||
false => vec![],
|
||||
};
|
||||
|
||||
let commit_result = finalize_repo_write(
|
||||
&state,
|
||||
ctx,
|
||||
@@ -391,12 +392,14 @@ pub async fn put_record(
|
||||
ops: vec![op],
|
||||
modified_keys: &modified_keys,
|
||||
blob_cids: &blob_cids,
|
||||
backlinks_to_add,
|
||||
backlinks_to_remove,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(PutRecordOutput {
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
uri: record_uri,
|
||||
cid: record_cid.to_string(),
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
|
||||
@@ -41,24 +41,27 @@ pub async fn check_account_status(
|
||||
) -> Result<Json<CheckAccountStatusOutput>, ApiError> {
|
||||
let did = &auth.did;
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.log_db_err("fetching user ID for account status")?
|
||||
.ok_or(ApiError::InternalError(None))?;
|
||||
let is_active = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.is_account_active_by_did(did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false);
|
||||
let repo_info = state.repo_repo.get_repo(user_id).await.ok().flatten();
|
||||
let repo_info = state.repos.repo.get_repo(user_id).await.ok().flatten();
|
||||
let (repo_commit, repo_rev_from_db) = repo_info
|
||||
.map(|r| (r.repo_root_cid.to_string(), r.repo_rev))
|
||||
.unwrap_or_else(|| (String::new(), None));
|
||||
let block_count: i64 = state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.count_user_blocks(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
@@ -80,19 +83,21 @@ pub async fn check_account_status(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let record_count: i64 = state.repo_repo.count_records(user_id).await.unwrap_or(0);
|
||||
let record_count: i64 = state.repos.repo.count_records(user_id).await.unwrap_or(0);
|
||||
let imported_blobs: i64 = state
|
||||
.blob_repo
|
||||
.repos
|
||||
.blob
|
||||
.count_blobs_by_user(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let expected_blobs: i64 = state
|
||||
.blob_repo
|
||||
.repos
|
||||
.blob
|
||||
.count_distinct_record_blobs(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let valid_did =
|
||||
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await;
|
||||
is_valid_did_for_service(state.repos.user.as_ref(), state.cache.clone(), did).await;
|
||||
Ok(Json(CheckAccountStatusOutput {
|
||||
activated: is_active,
|
||||
valid_did,
|
||||
@@ -319,7 +324,7 @@ pub async fn activate_account(
|
||||
);
|
||||
let did_validation_start = std::time::Instant::now();
|
||||
if let Err(e) = assert_valid_did_document_for_service(
|
||||
state.user_repo.as_ref(),
|
||||
state.repos.user.as_ref(),
|
||||
state.cache.clone(),
|
||||
&did,
|
||||
true,
|
||||
@@ -339,12 +344,18 @@ pub async fn activate_account(
|
||||
did_validation_start.elapsed()
|
||||
);
|
||||
|
||||
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
|
||||
let handle = state
|
||||
.repos
|
||||
.user
|
||||
.get_handle_by_did(&did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
info!(
|
||||
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
|
||||
did, handle
|
||||
);
|
||||
let result = state.user_repo.activate_account(&did).await;
|
||||
let result = state.repos.user.activate_account(&did).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
@@ -365,12 +376,20 @@ pub async fn activate_account(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
|
||||
did
|
||||
);
|
||||
}
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.repo
|
||||
.update_repo_status(&did, None, None, Some(false))
|
||||
.await
|
||||
{
|
||||
warn!("failed to sync activation to repo backend: {e:?}");
|
||||
}
|
||||
info!(
|
||||
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
|
||||
did
|
||||
@@ -406,7 +425,8 @@ pub async fn activate_account(
|
||||
info!("[MIGRATION] activateAccount: Identity event sequenced successfully");
|
||||
}
|
||||
let repo_root = state
|
||||
.repo_repo
|
||||
.repos
|
||||
.repo
|
||||
.get_repo_root_by_did(&did)
|
||||
.await
|
||||
.ok()
|
||||
@@ -480,9 +500,19 @@ pub async fn deactivate_account(
|
||||
|
||||
let did = auth.did.clone();
|
||||
|
||||
let handle = state.user_repo.get_handle_by_did(&did).await.ok().flatten();
|
||||
let handle = state
|
||||
.repos
|
||||
.user
|
||||
.get_handle_by_did(&did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let result = state.user_repo.deactivate_account(&did, delete_after).await;
|
||||
let result = state
|
||||
.repos
|
||||
.user
|
||||
.deactivate_account(&did, delete_after)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(true) => {
|
||||
@@ -492,6 +522,14 @@ pub async fn deactivate_account(
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(h))
|
||||
.await;
|
||||
}
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.repo
|
||||
.update_repo_status(&did, None, None, Some(true))
|
||||
.await
|
||||
{
|
||||
warn!("failed to sync deactivation to repo backend: {e:?}");
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
@@ -518,7 +556,8 @@ pub async fn request_account_delete(
|
||||
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
|
||||
|
||||
let user_id = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_id_by_did(session_mfa.did())
|
||||
.await
|
||||
.ok()
|
||||
@@ -527,14 +566,15 @@ pub async fn request_account_delete(
|
||||
let confirmation_token = Uuid::new_v4().to_string();
|
||||
let expires_at = Utc::now() + Duration::minutes(15);
|
||||
state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
|
||||
.await
|
||||
.log_db_err("creating deletion token")?;
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
&confirmation_token,
|
||||
hostname,
|
||||
@@ -572,7 +612,8 @@ pub async fn delete_account(
|
||||
return Err(ApiError::InvalidToken(Some("token is required".into())));
|
||||
}
|
||||
let user = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_deletion(did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -582,7 +623,7 @@ pub async fn delete_account(
|
||||
.ok_or(ApiError::InvalidRequest("account not found".into()))?;
|
||||
let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle);
|
||||
if crate::common::verify_credential(
|
||||
state.session_repo.as_ref(),
|
||||
state.repos.session.as_ref(),
|
||||
user_id,
|
||||
password,
|
||||
password_hash.as_deref(),
|
||||
@@ -595,7 +636,8 @@ pub async fn delete_account(
|
||||
)));
|
||||
}
|
||||
let deletion_request = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_deletion_request(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -611,11 +653,12 @@ pub async fn delete_account(
|
||||
)));
|
||||
}
|
||||
if Utc::now() > deletion_request.expires_at {
|
||||
let _ = state.infra_repo.delete_deletion_request(token).await;
|
||||
let _ = state.repos.infra.delete_deletion_request(token).await;
|
||||
return Err(ApiError::ExpiredToken(None));
|
||||
}
|
||||
state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.delete_account_complete(user_id, did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -630,7 +673,7 @@ pub async fn delete_account(
|
||||
.await;
|
||||
match account_seq {
|
||||
Ok(seq) => {
|
||||
if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
|
||||
if let Err(e) = state.repos.repo.delete_sequences_except(did, seq).await {
|
||||
warn!(
|
||||
"Failed to cleanup sequences for deleted account {}: {}",
|
||||
did, e
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,7 +42,8 @@ pub async fn update_did_document(
|
||||
}
|
||||
|
||||
let user = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_did_doc(&auth.did)
|
||||
.await
|
||||
.log_db_err("getting user")?
|
||||
@@ -97,7 +98,8 @@ pub async fn update_did_document(
|
||||
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
|
||||
|
||||
state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
|
||||
.await
|
||||
.log_db_err("upserting did_web_overrides")?;
|
||||
@@ -105,7 +107,8 @@ pub async fn update_did_document(
|
||||
if let Some(ref endpoint) = input.service_endpoint {
|
||||
let endpoint_clean = endpoint.trim().trim_end_matches('/');
|
||||
state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.update_migrated_to_pds(&auth.did, endpoint_clean)
|
||||
.await
|
||||
.log_db_err("updating service endpoint")?;
|
||||
@@ -139,7 +142,7 @@ pub async fn get_did_document(
|
||||
async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) -> serde_json::Value {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
|
||||
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
|
||||
let user = match state.repos.user.get_user_for_did_doc_build(did).await {
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
return json!({
|
||||
@@ -149,7 +152,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
|
||||
};
|
||||
|
||||
let overrides = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_did_web_overrides(user.id)
|
||||
.await
|
||||
.ok()
|
||||
@@ -193,7 +197,8 @@ async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) ->
|
||||
}
|
||||
|
||||
let key_info = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_user_key_by_id(user.id)
|
||||
.await
|
||||
.ok()
|
||||
|
||||
@@ -120,7 +120,7 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
|
||||
let is_bootstrap = state.bootstrap_invite_code.is_some()
|
||||
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
|
||||
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
|
||||
|
||||
let _validated_invite_code = if is_bootstrap {
|
||||
match input.invite_code.as_deref() {
|
||||
@@ -128,7 +128,7 @@ pub async fn create_passkey_account(
|
||||
_ => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else if let Some(ref code) = input.invite_code {
|
||||
match state.infra_repo.validate_invite_code(code).await {
|
||||
match state.repos.infra.validate_invite_code(code).await {
|
||||
Ok(validated) => Some(validated),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
@@ -351,7 +351,7 @@ pub async fn create_passkey_account(
|
||||
birthdate_pref,
|
||||
};
|
||||
|
||||
let create_result = match state.user_repo.create_passkey_account(&create_input).await {
|
||||
let create_result = match state.repos.user.create_passkey_account(&create_input).await {
|
||||
Ok(r) => r,
|
||||
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
@@ -405,7 +405,7 @@ pub async fn create_passkey_account(
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
if let Err(e) = state.session_repo.create_session(&session_data).await {
|
||||
if let Err(e) = state.repos.session.create_session(&session_data).await {
|
||||
warn!(did = %did, "Failed to insert migration session: {:?}", e);
|
||||
}
|
||||
info!(did = %did, "Generated migration access token for BYOD passkey account");
|
||||
@@ -451,7 +451,12 @@ pub async fn complete_passkey_setup(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CompletePasskeySetupInput>,
|
||||
) -> Result<Json<CompletePasskeySetupOutput>, ApiError> {
|
||||
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_passkey_setup(&input.did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::AccountNotFound);
|
||||
@@ -484,7 +489,8 @@ pub async fn complete_passkey_setup(
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let reg_state = match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
|
||||
.await
|
||||
{
|
||||
@@ -530,7 +536,8 @@ pub async fn complete_passkey_setup(
|
||||
}
|
||||
};
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.save_passkey(
|
||||
&input.did,
|
||||
&credential_id,
|
||||
@@ -553,13 +560,14 @@ pub async fn complete_passkey_setup(
|
||||
app_password_name: app_password_name.clone(),
|
||||
app_password_hash: password_hash,
|
||||
};
|
||||
if let Err(e) = state.user_repo.complete_passkey_setup(&setup_input).await {
|
||||
if let Err(e) = state.repos.user.complete_passkey_setup(&setup_input).await {
|
||||
error!("Error completing passkey setup: {:?}", e);
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration)
|
||||
.await;
|
||||
|
||||
@@ -577,7 +585,12 @@ pub async fn start_passkey_registration_for_setup(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<StartPasskeyRegistrationInput>,
|
||||
) -> Result<Json<OptionsResponse<serde_json::Value>>, ApiError> {
|
||||
let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await {
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_passkey_setup(&input.did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(ApiError::AccountNotFound);
|
||||
@@ -610,7 +623,8 @@ pub async fn start_passkey_registration_for_setup(
|
||||
let webauthn = &state.webauthn_config;
|
||||
|
||||
let existing_passkeys = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_passkeys_for_user(&input.did)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -643,7 +657,8 @@ pub async fn start_passkey_registration_for_setup(
|
||||
}
|
||||
};
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json)
|
||||
.await
|
||||
{
|
||||
@@ -682,7 +697,8 @@ pub async fn request_passkey_recovery(
|
||||
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
|
||||
|
||||
let user = match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
|
||||
.await
|
||||
{
|
||||
@@ -697,7 +713,8 @@ pub async fn request_passkey_recovery(
|
||||
let expires_at = Utc::now() + Duration::hours(1);
|
||||
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.set_recovery_token(&user.did, &recovery_token_hash, expires_at)
|
||||
.await
|
||||
{
|
||||
@@ -714,8 +731,8 @@ pub async fn request_passkey_recovery(
|
||||
);
|
||||
|
||||
let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user.id,
|
||||
&recovery_url,
|
||||
hostname,
|
||||
@@ -742,7 +759,7 @@ pub async fn recover_passkey_account(
|
||||
return Err(ApiError::InvalidRequest(e.to_string()));
|
||||
}
|
||||
|
||||
let user = match state.user_repo.get_user_for_recovery(&input.did).await {
|
||||
let user = match state.repos.user.get_user_for_recovery(&input.did).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
return Err(ApiError::InvalidRecoveryLink);
|
||||
@@ -771,7 +788,8 @@ pub async fn recover_passkey_account(
|
||||
password_hash,
|
||||
};
|
||||
let result = match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.recover_passkey_account(&recover_input)
|
||||
.await
|
||||
{
|
||||
@@ -785,11 +803,12 @@ pub async fn recover_passkey_account(
|
||||
if result.passkeys_deleted > 0 {
|
||||
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
|
||||
}
|
||||
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
|
||||
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await {
|
||||
let actual_channel =
|
||||
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
|
||||
if let Err(e) = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.set_channel_verified(&input.did, actual_channel)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = ¬ification.recipient;
|
||||
if !is_valid_signal_username(recipient) {
|
||||
return Err(SendError::InvalidRecipient(format!(
|
||||
"Invalid Signal username format: {}",
|
||||
recipient
|
||||
)));
|
||||
}
|
||||
let username = tranquil_signal::SignalUsername::parse(¬ification.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()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,9 @@ pub struct TranquilConfig {
|
||||
|
||||
#[config(nested)]
|
||||
pub scheduled: ScheduledConfig,
|
||||
|
||||
#[config(nested)]
|
||||
pub tranquil_store: TranquilStoreConfig,
|
||||
}
|
||||
|
||||
impl TranquilConfig {
|
||||
@@ -250,6 +253,23 @@ impl TranquilConfig {
|
||||
);
|
||||
}
|
||||
|
||||
// -- repo backend -----------------------------------------------------
|
||||
if let Err(e) = self.storage.repo_backend.parse::<RepoBackend>() {
|
||||
errors.push(e);
|
||||
}
|
||||
|
||||
// -- tranquil-store ---------------------------------------------------
|
||||
if let Some(mb) = self.tranquil_store.memory_budget_mb
|
||||
&& mb == 0
|
||||
{
|
||||
errors.push("tranquil_store.memory_budget_mb must be at least 1".to_string());
|
||||
}
|
||||
if let Some(threads) = self.tranquil_store.handler_threads
|
||||
&& threads == 0
|
||||
{
|
||||
errors.push("tranquil_store.handler_threads must be at least 1".to_string());
|
||||
}
|
||||
|
||||
// -- cache ------------------------------------------------------------
|
||||
match self.cache.backend.as_str() {
|
||||
"valkey" => {
|
||||
@@ -561,6 +581,35 @@ impl SecretsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RepoBackend {
|
||||
Postgres,
|
||||
TranquilStore,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for RepoBackend {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"postgres" => Ok(Self::Postgres),
|
||||
"tranquil-store" => Ok(Self::TranquilStore),
|
||||
other => Err(format!(
|
||||
"unknown repo backend \"{other}\", expected \"postgres\" or \"tranquil-store\""
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RepoBackend {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Postgres => f.write_str("postgres"),
|
||||
Self::TranquilStore => f.write_str("tranquil-store"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct StorageConfig {
|
||||
/// Storage backend: `filesystem` or `s3`.
|
||||
@@ -578,6 +627,17 @@ pub struct StorageConfig {
|
||||
/// Custom S3 endpoint URL (for MinIO, R2, etc.).
|
||||
#[config(env = "S3_ENDPOINT")]
|
||||
pub s3_endpoint: Option<String>,
|
||||
|
||||
#[config(env = "REPO_BACKEND", default = "postgres")]
|
||||
pub repo_backend: String,
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn repo_backend(&self) -> RepoBackend {
|
||||
self.repo_backend
|
||||
.parse()
|
||||
.expect("repo_backend must be validated before use")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
@@ -678,13 +738,8 @@ pub struct TelegramConfig {
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct SignalConfig {
|
||||
/// Path to the `signal-cli` binary.
|
||||
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
|
||||
pub cli_path: String,
|
||||
|
||||
/// Sender phone number. When unset, Signal integration is disabled.
|
||||
#[config(env = "SIGNAL_SENDER_NUMBER")]
|
||||
pub sender_number: Option<String>,
|
||||
#[config(env = "SIGNAL_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
@@ -1001,6 +1056,29 @@ pub struct ScheduledConfig {
|
||||
/// Interval in seconds between scheduled delete checks.
|
||||
#[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)]
|
||||
pub delete_check_interval_secs: u64,
|
||||
|
||||
/// Interval in seconds between block garbage collection cycles.
|
||||
#[config(env = "BLOCK_GC_INTERVAL_SECS", default = 21600)]
|
||||
pub block_gc_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
pub struct TranquilStoreConfig {
|
||||
/// Directory for tranquil-store data (metastore, eventlog).
|
||||
#[config(
|
||||
env = "TRANQUIL_STORE_DATA_DIR",
|
||||
default = "/var/lib/tranquil-pds/store"
|
||||
)]
|
||||
pub data_dir: String,
|
||||
|
||||
/// Fjall block cache size in megabytes. Defaults to 20% of system RAM
|
||||
/// when unset.
|
||||
#[config(env = "TRANQUIL_STORE_MEMORY_BUDGET_MB")]
|
||||
pub memory_budget_mb: Option<u64>,
|
||||
|
||||
/// Number of handler threads. Defaults to available_parallelism / 2.
|
||||
#[config(env = "TRANQUIL_STORE_HANDLER_THREADS")]
|
||||
pub handler_threads: Option<usize>,
|
||||
}
|
||||
|
||||
/// Generate a TOML configuration template with all available options,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
use crate::backlink::Backlink;
|
||||
use crate::sequence::SequenceNumber;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
@@ -279,6 +280,8 @@ pub struct ApplyCommitInput {
|
||||
pub obsolete_block_cids: Vec<Vec<u8>>,
|
||||
pub record_upserts: Vec<RecordUpsert>,
|
||||
pub record_deletes: Vec<RecordDelete>,
|
||||
pub backlinks_to_add: Vec<Backlink>,
|
||||
pub backlinks_to_remove: Vec<AtUri>,
|
||||
pub commit_event: CommitEventData,
|
||||
}
|
||||
|
||||
@@ -300,6 +303,8 @@ pub trait RepoRepository: Send + Sync {
|
||||
async fn create_repo(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
did: &Did,
|
||||
handle: &Handle,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
) -> Result<(), DbError>;
|
||||
@@ -313,6 +318,14 @@ pub trait RepoRepository: Send + Sync {
|
||||
|
||||
async fn update_repo_rev(&self, user_id: Uuid, repo_rev: &str) -> Result<(), DbError>;
|
||||
|
||||
async fn update_repo_status(
|
||||
&self,
|
||||
did: &Did,
|
||||
takedown: Option<bool>,
|
||||
takedown_ref: Option<&str>,
|
||||
deactivated: Option<bool>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
async fn delete_repo(&self, user_id: Uuid) -> Result<(), DbError>;
|
||||
|
||||
async fn get_repo_root_for_update(&self, user_id: Uuid) -> Result<Option<CidLink>, DbError>;
|
||||
@@ -400,6 +413,11 @@ pub trait RepoRepository: Send + Sync {
|
||||
|
||||
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
|
||||
|
||||
async fn find_unreferenced_blocks(
|
||||
&self,
|
||||
candidate_cids: &[Vec<u8>],
|
||||
) -> Result<Vec<Vec<u8>>, DbError>;
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn insert_identity_event(
|
||||
|
||||
@@ -23,6 +23,10 @@ impl SequenceNumber {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.0 >= 0
|
||||
}
|
||||
|
||||
pub fn as_u64(&self) -> Option<u64> {
|
||||
u64::try_from(self.0).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SequenceNumber {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -1194,7 +1194,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn revoke_device_trust(&self, device_id: &DeviceId) -> Result<(), DbError> {
|
||||
async fn revoke_device_trust(&self, device_id: &DeviceId, _did: &Did) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
|
||||
device_id.as_str()
|
||||
@@ -1208,6 +1208,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn update_device_friendly_name(
|
||||
&self,
|
||||
device_id: &DeviceId,
|
||||
_did: &Did,
|
||||
friendly_name: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
@@ -1224,6 +1225,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn trust_device(
|
||||
&self,
|
||||
device_id: &DeviceId,
|
||||
_did: &Did,
|
||||
trusted_at: DateTime<Utc>,
|
||||
trusted_until: DateTime<Utc>,
|
||||
) -> Result<(), DbError> {
|
||||
@@ -1242,6 +1244,7 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
async fn extend_device_trust(
|
||||
&self,
|
||||
device_id: &DeviceId,
|
||||
_did: &Did,
|
||||
trusted_until: DateTime<Utc>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
@@ -1320,4 +1323,19 @@ impl OAuthRepository for PostgresOAuthRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn get_2fa_challenge_code(
|
||||
&self,
|
||||
request_uri: &RequestId,
|
||||
) -> Result<Option<String>, DbError> {
|
||||
let code = sqlx::query_scalar!(
|
||||
"SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1",
|
||||
request_uri.as_str()
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,21 @@ impl PostgresRepoRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl RepoRepository for PostgresRepoRepository {
|
||||
async fn update_repo_status(
|
||||
&self,
|
||||
_did: &Did,
|
||||
_takedown: Option<bool>,
|
||||
_takedown_ref: Option<&str>,
|
||||
_deactivated: Option<bool>,
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_repo(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
_did: &Did,
|
||||
_handle: &Handle,
|
||||
repo_root_cid: &CidLink,
|
||||
repo_rev: &str,
|
||||
) -> Result<(), DbError> {
|
||||
@@ -606,6 +618,30 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn find_unreferenced_blocks(
|
||||
&self,
|
||||
candidate_cids: &[Vec<u8>],
|
||||
) -> Result<Vec<Vec<u8>>, DbError> {
|
||||
match candidate_cids.is_empty() {
|
||||
true => Ok(Vec::new()),
|
||||
false => {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks WHERE block_cid = t.cid
|
||||
)
|
||||
"#,
|
||||
candidate_cids,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(rows.into_iter().filter_map(|r| r.cid).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_block_cids_since_rev(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -1367,6 +1403,53 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
if !input.backlinks_to_remove.is_empty() {
|
||||
let remove_uris: Vec<&str> = input
|
||||
.backlinks_to_remove
|
||||
.iter()
|
||||
.map(|u| u.as_str())
|
||||
.collect();
|
||||
sqlx::query!(
|
||||
"DELETE FROM backlinks WHERE uri = ANY($1::text[])",
|
||||
&remove_uris as &[&str],
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
if !input.backlinks_to_add.is_empty() {
|
||||
let uris: Vec<&str> = input
|
||||
.backlinks_to_add
|
||||
.iter()
|
||||
.map(|b| b.uri.as_str())
|
||||
.collect();
|
||||
let paths: Vec<&str> = input
|
||||
.backlinks_to_add
|
||||
.iter()
|
||||
.map(|b| b.path.as_str())
|
||||
.collect();
|
||||
let link_tos: Vec<&str> = input
|
||||
.backlinks_to_add
|
||||
.iter()
|
||||
.map(|b| b.link_to.as_str())
|
||||
.collect();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO backlinks (uri, path, link_to, repo_id)
|
||||
SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4
|
||||
ON CONFLICT (uri, path) DO NOTHING
|
||||
"#,
|
||||
&uris as &[&str],
|
||||
&paths as &[&str],
|
||||
&link_tos as &[&str],
|
||||
input.user_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| ApplyCommitError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
let event = &input.commit_event;
|
||||
let seq: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
|
||||
@@ -234,6 +234,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
limit: i64,
|
||||
) -> Result<Vec<AccountSearchResult>, DbError> {
|
||||
let cursor_str = cursor_did.map(|d| d.as_str());
|
||||
let email_like = email_filter.map(|e| format!("%{e}%"));
|
||||
let handle_like = handle_filter.map(|h| format!("%{h}%"));
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT did, handle, email, created_at, email_verified, deactivated_at, invites_disabled
|
||||
FROM users
|
||||
@@ -243,8 +245,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
ORDER BY did ASC
|
||||
LIMIT $4"#,
|
||||
cursor_str,
|
||||
email_filter,
|
||||
handle_filter,
|
||||
email_like.as_deref(),
|
||||
handle_like.as_deref(),
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -627,6 +629,18 @@ impl UserRepository for PostgresUserRepository {
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn set_admin_status(&self, did: &Did, is_admin: bool) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET is_admin = $1 WHERE did = $2",
|
||||
is_admin,
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_notification_prefs(
|
||||
&self,
|
||||
did: &Did,
|
||||
@@ -3306,4 +3320,66 @@ impl UserRepository for PostgresUserRepository {
|
||||
.map_err(map_sqlx_error)?;
|
||||
Ok(row.flatten())
|
||||
}
|
||||
|
||||
async fn get_password_reset_info(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<Option<tranquil_db_traits::PasswordResetInfo>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
|
||||
email
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(|r| tranquil_db_traits::PasswordResetInfo {
|
||||
code: r.password_reset_code,
|
||||
expires_at: r.password_reset_code_expires_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn enable_totp_verified(
|
||||
&self,
|
||||
did: &Did,
|
||||
encrypted_secret: &[u8],
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
|
||||
VALUES ($1, $2, 1, TRUE, NOW())
|
||||
ON CONFLICT (did) DO UPDATE SET secret_encrypted = $2, verified = TRUE"#,
|
||||
did.as_str(),
|
||||
encrypted_secret
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_two_factor_enabled(&self, did: &Did, enabled: bool) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET two_factor_enabled = $1 WHERE did = $2",
|
||||
enabled,
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expire_password_reset_code(&self, email: &str) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
|
||||
email
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,15 +322,7 @@ fn validate_blob_ref(
|
||||
|
||||
if let Some(ref accept) = lex_blob.accept {
|
||||
let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let matched = accept
|
||||
.iter()
|
||||
.any(|pattern| match pattern.strip_suffix("/*") {
|
||||
Some(prefix) => {
|
||||
mime_type.starts_with(prefix)
|
||||
&& mime_type.as_bytes().get(prefix.len()) == Some(&b'/')
|
||||
}
|
||||
None => mime_type == pattern,
|
||||
});
|
||||
let matched = accept.iter().any(|pattern| mime_type_matches_accept_pattern(mime_type, pattern));
|
||||
if !mime_type.is_empty() && !matched {
|
||||
return Err(LexValidationError::field(
|
||||
path,
|
||||
@@ -352,6 +344,34 @@ fn validate_blob_ref(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_type_matches_accept_pattern(mime_type: &str, pattern: &str) -> bool {
|
||||
let normalized_mime = normalize_mime_for_match(mime_type);
|
||||
let normalized = normalize_mime_for_match(pattern);
|
||||
|
||||
if normalized == "*/*" || normalized == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
match normalized.strip_suffix("/*") {
|
||||
Some(prefix) => {
|
||||
!prefix.is_empty()
|
||||
&& normalized_mime.starts_with(prefix)
|
||||
&& normalized_mime.len() > prefix.len()
|
||||
&& normalized_mime.as_bytes()[prefix.len()] == b'/'
|
||||
}
|
||||
None => normalized_mime == normalized,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mime_for_match(value: &str) -> String {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn validate_bytes(
|
||||
lex_bytes: &LexBytes,
|
||||
value: &serde_json::Value,
|
||||
@@ -733,6 +753,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_accept_wildcard_allows_any_mime() {
|
||||
let lex_blob = LexBlob {
|
||||
accept: Some(vec!["*/*".to_string()]),
|
||||
max_size: None,
|
||||
};
|
||||
let blob = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "application/gzip",
|
||||
"size": 123
|
||||
});
|
||||
|
||||
assert!(validate_blob_ref(&lex_blob, &blob, "root/entries/0/node/blob").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_accept_prefix_wildcard_matches_subtypes() {
|
||||
let lex_blob = LexBlob {
|
||||
accept: Some(vec!["image/*".to_string()]),
|
||||
max_size: None,
|
||||
};
|
||||
let blob = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "image/png",
|
||||
"size": 123
|
||||
});
|
||||
|
||||
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_accept_exact_type_rejects_different_mime() {
|
||||
let lex_blob = LexBlob {
|
||||
accept: Some(vec!["image/png".to_string()]),
|
||||
max_size: None,
|
||||
};
|
||||
let blob = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "application/gzip",
|
||||
"size": 123
|
||||
});
|
||||
|
||||
let err = validate_blob_ref(&lex_blob, &blob, "blob").unwrap_err();
|
||||
assert!(matches!(err, LexValidationError::InvalidField { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_accept_exact_type_ignores_params_and_case() {
|
||||
let lex_blob = LexBlob {
|
||||
accept: Some(vec!["text/html".to_string()]),
|
||||
max_size: None,
|
||||
};
|
||||
let blob = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "Text/HTML; charset=utf-8",
|
||||
"size": 123
|
||||
});
|
||||
|
||||
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_accept_prefix_ignores_params_and_case() {
|
||||
let lex_blob = LexBlob {
|
||||
accept: Some(vec!["text/*".to_string()]),
|
||||
max_size: None,
|
||||
};
|
||||
let blob = json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyreiabcdef" },
|
||||
"mimeType": "TEXT/HTML; charset=UTF-8",
|
||||
"size": 123
|
||||
});
|
||||
|
||||
assert!(validate_blob_ref(&lex_blob, &blob, "blob").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cross_schema_local_ref_in_union() {
|
||||
let registry = test_registry();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScopeInfo {
|
||||
pub scope: String,
|
||||
pub category: String,
|
||||
pub required: bool,
|
||||
pub description: String,
|
||||
pub display_name: String,
|
||||
pub granted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConsentResponse {
|
||||
pub request_uri: String,
|
||||
pub client_id: String,
|
||||
pub client_name: Option<String>,
|
||||
pub client_uri: Option<String>,
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_delegation: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_did: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegation_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConsentQuery {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConsentSubmit {
|
||||
pub request_uri: String,
|
||||
pub approved_scopes: Vec<String>,
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
pub async fn consent_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ConsentQuery>,
|
||||
) -> Response {
|
||||
let consent_request_id = RequestId::from(query.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&consent_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
|
||||
Ok(flow) => match flow.require_user() {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"expired_request",
|
||||
"Authorization request has expired",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_with_user.did().clone();
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok();
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("atproto");
|
||||
|
||||
let controller_did_parsed: Option<Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let delegation_grant = if let Some(ref ctrl_did) = controller_did_parsed {
|
||||
state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
requested_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
requested_scope_str.to_string()
|
||||
};
|
||||
|
||||
let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Failed to expand permission set: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
|
||||
let consent_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let preferences = state
|
||||
.repos
|
||||
.oauth
|
||||
.get_scope_preferences(&did, &consent_client_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let pref_map: std::collections::HashMap<_, _> = preferences
|
||||
.iter()
|
||||
.map(|p| (p.scope.as_str(), p.granted))
|
||||
.collect();
|
||||
let requested_scope_strings: Vec<String> =
|
||||
requested_scopes.iter().map(|s| s.to_string()).collect();
|
||||
let show_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&consent_client_id,
|
||||
&requested_scope_strings,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let scopes: Vec<ScopeInfo> = requested_scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
let (category, required, description, display_name) = if let Some(def) =
|
||||
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
|
||||
{
|
||||
let desc = if *scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
.to_string()
|
||||
} else {
|
||||
def.description.to_string()
|
||||
};
|
||||
let name = if *scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol Access".to_string()
|
||||
} else {
|
||||
def.display_name.to_string()
|
||||
};
|
||||
(
|
||||
def.category.display_name().to_string(),
|
||||
def.required,
|
||||
desc,
|
||||
name,
|
||||
)
|
||||
} else if scope.starts_with("ref:") {
|
||||
(
|
||||
"Reference".to_string(),
|
||||
false,
|
||||
"Referenced scope".to_string(),
|
||||
scope.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Other".to_string(),
|
||||
false,
|
||||
format!("Access to {}", scope),
|
||||
scope.to_string(),
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(*scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos
|
||||
.user
|
||||
.get_handle_by_did(&did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| h.to_string());
|
||||
|
||||
let (is_delegation, controller_did_resp, controller_handle, delegation_level) =
|
||||
if let Some(ref ctrl_did) = controller_did_parsed {
|
||||
let ctrl_handle = state
|
||||
.repos
|
||||
.user
|
||||
.get_handle_by_did(ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| h.to_string());
|
||||
|
||||
let level = if let Some(ref grant) = delegation_grant {
|
||||
let preset = tranquil_pds::delegation::SCOPE_PRESETS
|
||||
.iter()
|
||||
.find(|p| p.scopes == grant.granted_scopes.as_str());
|
||||
preset
|
||||
.map(|p| p.label.to_string())
|
||||
.unwrap_or_else(|| "Custom".to_string())
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
|
||||
(
|
||||
Some(true),
|
||||
Some(ctrl_did.to_string()),
|
||||
ctrl_handle,
|
||||
Some(level),
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
Json(ConsentResponse {
|
||||
request_uri: query.request_uri.clone(),
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: client_metadata.as_ref().and_then(|m| m.client_name.clone()),
|
||||
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
show_consent,
|
||||
did: did.to_string(),
|
||||
handle: account_handle,
|
||||
is_delegation,
|
||||
controller_did: controller_did_resp,
|
||||
controller_handle,
|
||||
delegation_level,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn consent_post(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<ConsentSubmit>,
|
||||
) -> Response {
|
||||
tracing::info!(
|
||||
"consent_post: approved_scopes={:?}, remember={}",
|
||||
form.approved_scopes,
|
||||
form.remember
|
||||
);
|
||||
let consent_post_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&consent_post_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
|
||||
Ok(flow) => match flow.require_user() {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&consent_post_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_with_user.did().clone();
|
||||
let original_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
|
||||
let controller_did_parsed: Option<Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
let delegation_grant = match controller_did_parsed.as_ref() {
|
||||
Some(ctrl_did) => state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegation(&did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
original_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
};
|
||||
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let user_denied_some_granular = has_granular_scopes
|
||||
&& requested_scopes
|
||||
.iter()
|
||||
.filter(|s| is_granular_scope(s))
|
||||
.any(|s| !form.approved_scopes.contains(&s.to_string()));
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
if atproto_was_requested
|
||||
&& !has_granular_scopes
|
||||
&& !form.approved_scopes.contains(&"atproto".to_string())
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = if user_denied_some_granular {
|
||||
form.approved_scopes
|
||||
.iter()
|
||||
.filter(|s| *s != "atproto")
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
form.approved_scopes.clone()
|
||||
};
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"At least one scope must be approved",
|
||||
);
|
||||
}
|
||||
let approved_scope_str = final_approved.join(" ");
|
||||
let has_valid_scope = final_approved.iter().all(|s| is_valid_scope(s));
|
||||
if !has_valid_scope {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid scope format",
|
||||
);
|
||||
}
|
||||
if form.remember {
|
||||
let preferences: Vec<ScopePreference> = requested_scopes
|
||||
.iter()
|
||||
.map(|s| ScopePreference {
|
||||
scope: s.to_string(),
|
||||
granted: form.approved_scopes.contains(&s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
let consent_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.upsert_scope_preferences(&did, &consent_post_client_id, &preferences)
|
||||
.await;
|
||||
}
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.update_request_scope(&consent_post_request_id, &approved_scope_str)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update request scope: {:?}", e);
|
||||
}
|
||||
let code = Code::generate();
|
||||
let consent_post_device_id = request_data
|
||||
.device_id
|
||||
.as_ref()
|
||||
.map(|d| DeviceIdType::new(d.0.clone()));
|
||||
let consent_post_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.update_authorization_request(
|
||||
&consent_post_request_id,
|
||||
&did,
|
||||
consent_post_device_id.as_ref(),
|
||||
&consent_post_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Failed to complete authorization",
|
||||
);
|
||||
}
|
||||
let redirect_uri = &request_data.parameters.redirect_uri;
|
||||
let intermediate_url = build_intermediate_redirect_url(
|
||||
redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
tracing::info!(
|
||||
intermediate_url = %intermediate_url,
|
||||
client_redirect = %redirect_uri,
|
||||
"consent_post returning JSON with intermediate URL (for 303 redirect)"
|
||||
);
|
||||
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenewRequest {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
pub async fn authorize_renew(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
Json(form): Json<RenewRequest>,
|
||||
) -> Response {
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Unknown authorization request",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if request_data.did.is_none() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request not yet authenticated",
|
||||
);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
if request_data.expires_at >= now {
|
||||
return Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": false
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let staleness = now - request_data.expires_at;
|
||||
if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request expired too long ago to renew",
|
||||
);
|
||||
}
|
||||
|
||||
let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS);
|
||||
match state
|
||||
.repos
|
||||
.oauth
|
||||
.extend_authorization_request_expiry(&request_id, new_expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": true
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(false) => json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request could not be renewed",
|
||||
),
|
||||
Err(_) => json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
use super::*;
|
||||
|
||||
pub async fn authorize_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Response {
|
||||
let request_uri = match query.request_uri {
|
||||
Some(uri) => uri,
|
||||
None => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing request_uri parameter. Use PAR to initiate authorization."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Missing request_uri parameter. Use PAR to initiate authorization.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let request_id = RequestId::from(request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri. Please start a new authorization request."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error("server_error", "A database error occurred.");
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired. Please start a new request."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_name = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.client_name);
|
||||
if wants_json(&headers) {
|
||||
return Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: client_name.clone(),
|
||||
scope: request_data.parameters.scope.clone(),
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
let force_new_account = query.new_account.unwrap_or(false);
|
||||
|
||||
if let Some(ref login_hint) = request_data.parameters.login_hint {
|
||||
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
|
||||
tracing::info!(normalized = %normalized, "Normalized login_hint");
|
||||
|
||||
match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_check_by_handle_or_email(normalized.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
|
||||
let is_delegated = state
|
||||
.repos
|
||||
.delegation
|
||||
.is_delegated_account(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_password = user.password_hash.is_some();
|
||||
tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check");
|
||||
|
||||
if is_delegated {
|
||||
tracing::info!("Redirecting to delegation auth");
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.set_request_did(&request_id, &user.did)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "Failed to set delegated DID on authorization request");
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"Failed to initialize delegation flow",
|
||||
);
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&request_uri),
|
||||
url_encode(&user.did)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!(normalized = %normalized, "No user found for login_hint");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Error looking up user for login_hint");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No login_hint in request");
|
||||
}
|
||||
|
||||
if request_data.parameters.prompt == Some(Prompt::Create) {
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/register?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
));
|
||||
}
|
||||
|
||||
if !force_new_account
|
||||
&& let Some(device_id) = extract_device_cookie(&headers)
|
||||
&& let Ok(accounts) = state
|
||||
.repos
|
||||
.oauth
|
||||
.get_device_accounts(&device_id.clone())
|
||||
.await
|
||||
&& !accounts.is_empty()
|
||||
{
|
||||
let login_hint_param = request_data
|
||||
.parameters
|
||||
.login_hint
|
||||
.as_ref()
|
||||
.map(|h| format!("&login_hint={}", url_encode(h)))
|
||||
.unwrap_or_default();
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/accounts?request_uri={}{}",
|
||||
url_encode(&request_uri),
|
||||
login_hint_param
|
||||
));
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn authorize_get_json(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Result<Json<AuthorizeResponse>, OAuthError> {
|
||||
let request_uri = query
|
||||
.request_uri
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?;
|
||||
let request_id_json = RequestId::from(request_uri.clone());
|
||||
let request_data = state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&request_id_json)
|
||||
.await
|
||||
.map_err(tranquil_pds::oauth::db_err_to_oauth)?
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&request_id_json)
|
||||
.await;
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"request_uri has expired".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: None,
|
||||
scope: request_data.parameters.scope.clone(),
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountInfo {
|
||||
pub did: String,
|
||||
pub handle: Handle,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountsResponse {
|
||||
pub accounts: Vec<AccountInfo>,
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
fn mask_email(email: &str) -> String {
|
||||
if let Some(at_pos) = email.find('@') {
|
||||
let local = &email[..at_pos];
|
||||
let domain = &email[at_pos..];
|
||||
if local.len() <= 2 {
|
||||
format!("{}***{}", local.chars().next().unwrap_or('*'), domain)
|
||||
} else {
|
||||
let first = local.chars().next().unwrap_or('*');
|
||||
let last = local.chars().last().unwrap_or('*');
|
||||
format!("{}***{}{}", first, last, domain)
|
||||
}
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_accounts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Response {
|
||||
let request_uri = match query.request_uri {
|
||||
Some(uri) => uri,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing request_uri parameter"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let device_id = match extract_device_cookie(&headers) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Json(AccountsResponse {
|
||||
accounts: vec![],
|
||||
request_uri,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let accounts = match state.repos.oauth.get_device_accounts(&device_id).await {
|
||||
Ok(accts) => accts,
|
||||
Err(_) => {
|
||||
return Json(AccountsResponse {
|
||||
accounts: vec![],
|
||||
request_uri,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let account_infos: Vec<AccountInfo> = accounts
|
||||
.into_iter()
|
||||
.map(|row| AccountInfo {
|
||||
did: row.did.to_string(),
|
||||
handle: row.handle,
|
||||
email: row.email.map(|e| mask_email(&e)),
|
||||
})
|
||||
.collect();
|
||||
Json(AccountsResponse {
|
||||
accounts: account_infos,
|
||||
request_uri,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_post(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<AuthorizeSubmit>,
|
||||
) -> Response {
|
||||
let json_response = wants_json(&headers);
|
||||
let form_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&form_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error("server_error", &format!("Database error: {:?}", e));
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&form_request_id)
|
||||
.await;
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let show_login_error = |error_msg: &str, json: bool| -> Response {
|
||||
if json {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": error_msg
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(error_msg)
|
||||
))
|
||||
};
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
|
||||
tracing::debug!(
|
||||
original_username = %form.username,
|
||||
normalized_username = %normalized_username,
|
||||
pds_hostname = %tranquil_config::get().server.hostname,
|
||||
"Normalized username for lookup"
|
||||
);
|
||||
let user = match state
|
||||
.repos
|
||||
.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
let _ = bcrypt::verify(
|
||||
&form.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
}
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
};
|
||||
if user.deactivated_at.is_some() {
|
||||
return show_login_error("This account has been deactivated.", json_response);
|
||||
}
|
||||
if user.takedown_ref.is_some() {
|
||||
return show_login_error("This account has been taken down.", json_response);
|
||||
}
|
||||
if user.account_type.is_delegated() {
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(&user.did)
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "delegation",
|
||||
"delegated_did": user.did,
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
if !user.password_required {
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/app/oauth/passkey?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "passkey",
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
let password_valid = match &user.password_hash {
|
||||
Some(hash) => match bcrypt::verify(&form.password, hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => {
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
if !password_valid {
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
||||
let handle = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.handle.to_string())
|
||||
.unwrap_or_else(|| form.username.clone());
|
||||
let channel = resend_info
|
||||
.map(|r| r.channel.as_str().to_owned())
|
||||
.unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned());
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": user.did,
|
||||
"handle": handle,
|
||||
"channel": channel
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode("account_not_verified")
|
||||
));
|
||||
}
|
||||
let has_totp = tranquil_api::server::has_totp_enabled(&state, &user.did).await;
|
||||
if has_totp {
|
||||
let device_cookie = extract_device_cookie(&headers);
|
||||
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
|
||||
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), dev_id, &user.did)
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if device_is_trusted {
|
||||
if let Some(ref dev_id) = device_cookie {
|
||||
let _ = tranquil_api::server::extend_device_trust(
|
||||
state.repos.oauth.as_ref(),
|
||||
dev_id,
|
||||
&user.did,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
if json_response {
|
||||
return Json(serde_json::json!({
|
||||
"needs_totp": true
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/totp?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
));
|
||||
}
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_2fa_challenge_by_request_uri(&form_request_id)
|
||||
.await;
|
||||
match state
|
||||
.repos
|
||||
.oauth
|
||||
.create_2fa_challenge(&user.did, &form_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(challenge) => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user.id,
|
||||
&challenge.code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %user.did,
|
||||
error = %e,
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = user.preferred_comms_channel.display_name();
|
||||
if json_response {
|
||||
return Json(serde_json::json!({
|
||||
"needs_2fa": true,
|
||||
"channel": channel_name
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(channel_name)
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut device_id: Option<DeviceIdType> = extract_device_cookie(&headers);
|
||||
let mut new_cookie: Option<String> = None;
|
||||
if form.remember_device {
|
||||
let final_device_id = if let Some(existing_id) = &device_id {
|
||||
existing_id.clone()
|
||||
} else {
|
||||
let new_id = DeviceId::generate();
|
||||
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.create_device(&new_device_id_typed, &device_data)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
new_cookie = Some(make_device_cookie(&new_device_id_typed));
|
||||
device_id = Some(new_device_id_typed.clone());
|
||||
}
|
||||
new_device_id_typed
|
||||
};
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.upsert_account_device(&user.did, &final_device_id)
|
||||
.await;
|
||||
}
|
||||
let set_auth_device_id = device_id.clone();
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&user.did,
|
||||
&client_id_typed,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::SEE_OTHER,
|
||||
[(SET_COOKIE, cookie), (LOCATION, consent_url)],
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&consent_url);
|
||||
}
|
||||
let code = Code::generate();
|
||||
let auth_post_device_id = device_id.clone();
|
||||
let auth_post_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.update_authorization_request(
|
||||
&form_request_id,
|
||||
&user.did,
|
||||
auth_post_device_id.as_ref(),
|
||||
&auth_post_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
if json_response {
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
} else {
|
||||
let redirect_url = build_success_redirect(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
redirect_see_other(&redirect_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_select(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<AuthorizeSelectSubmit>,
|
||||
) -> Response {
|
||||
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
let select_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&select_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&select_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let device_id = match extract_device_cookie(&headers) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No device session found. Please sign in.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let did: Did = match form.did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid DID format.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let verify_device_id = device_id.clone();
|
||||
let account_valid = match state
|
||||
.repos
|
||||
.oauth
|
||||
.verify_account_on_device(&verify_device_id, &did)
|
||||
.await
|
||||
{
|
||||
Ok(valid) => valid,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if !account_valid {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"This account is not available on this device. Please sign in.",
|
||||
);
|
||||
}
|
||||
let user = match state.repos.user.get_2fa_status_by_did(&did).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Account not found. Please sign in.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let has_totp = tranquil_api::server::has_totp_enabled(&state, &did).await;
|
||||
let select_early_device_typed = device_id.clone();
|
||||
if has_totp {
|
||||
let device_is_trusted =
|
||||
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), &device_id, &did)
|
||||
.await;
|
||||
if !device_is_trusted {
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
return Json(serde_json::json!({
|
||||
"needs_totp": true
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let _ =
|
||||
tranquil_api::server::extend_device_trust(state.repos.oauth.as_ref(), &device_id, &did)
|
||||
.await;
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_2fa_challenge_by_request_uri(&select_request_id)
|
||||
.await;
|
||||
match state
|
||||
.repos
|
||||
.oauth
|
||||
.create_2fa_challenge(&did, &select_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(challenge) => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user.id,
|
||||
&challenge.code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %form.did,
|
||||
error = %e,
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = user.preferred_comms_channel.display_name();
|
||||
return Json(serde_json::json!({
|
||||
"needs_2fa": true,
|
||||
"channel": channel_name
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let select_device_typed = device_id.clone();
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.upsert_account_device(&did, &select_device_typed)
|
||||
.await;
|
||||
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})).into_response()
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{LOCATION, SET_COOKIE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
|
||||
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
|
||||
db::should_show_consent, scopes::expand_include_scopes,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{
|
||||
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::util::extract_client_ip;
|
||||
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
|
||||
use urlencoding::encode as url_encode;
|
||||
|
||||
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
|
||||
const RENEW_EXPIRY_SECONDS: i64 = 600;
|
||||
const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600;
|
||||
|
||||
fn redirect_see_other(uri: &str) -> Response {
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[
|
||||
(LOCATION, uri.to_string()),
|
||||
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
|
||||
(
|
||||
SET_COOKIE,
|
||||
"bfCacheBypass=foo; max-age=1; SameSite=Lax".to_string(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/error?error={}&error_description={}",
|
||||
url_encode(error),
|
||||
url_encode(description)
|
||||
))
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, error: &str, description: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn is_granular_scope(s: &str) -> bool {
|
||||
s.starts_with("repo:")
|
||||
|| s.starts_with("repo?")
|
||||
|| s == "repo"
|
||||
|| s.starts_with("blob:")
|
||||
|| s.starts_with("blob?")
|
||||
|| s == "blob"
|
||||
|| s.starts_with("rpc:")
|
||||
|| s.starts_with("rpc?")
|
||||
|| s.starts_with("account:")
|
||||
|| s.starts_with("identity:")
|
||||
}
|
||||
|
||||
fn is_valid_scope(s: &str) -> bool {
|
||||
s == "atproto"
|
||||
|| s == "transition:generic"
|
||||
|| s == "transition:chat.bsky"
|
||||
|| s == "transition:email"
|
||||
|| is_granular_scope(s)
|
||||
|| s.starts_with("include:")
|
||||
}
|
||||
|
||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
|
||||
headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookie_str| {
|
||||
cookie_str.split(';').map(|c| c.trim()).find_map(|cookie| {
|
||||
cookie
|
||||
.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME))
|
||||
.and_then(|value| {
|
||||
tranquil_pds::config::AuthConfig::get().verify_device_cookie(value)
|
||||
})
|
||||
.map(tranquil_types::DeviceId::new)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String {
|
||||
let signed_value =
|
||||
tranquil_pds::config::AuthConfig::get().sign_device_cookie(device_id.as_str());
|
||||
format!(
|
||||
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
|
||||
DEVICE_COOKIE_NAME, signed_value
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeQuery {
|
||||
pub request_uri: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub new_account: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthorizeResponse {
|
||||
pub client_id: String,
|
||||
pub client_name: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub redirect_uri: String,
|
||||
pub state: Option<String>,
|
||||
pub login_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSubmit {
|
||||
pub request_uri: String,
|
||||
pub username: String,
|
||||
pub password: PlainPassword,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSelectSubmit {
|
||||
pub request_uri: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
fn wants_json(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("accept")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|accept| accept.contains("application/json"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn build_success_redirect(
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: Option<&str>,
|
||||
response_mode: Option<&str>,
|
||||
) -> String {
|
||||
let mut redirect_url = redirect_uri.to_string();
|
||||
let use_fragment = response_mode == Some("fragment");
|
||||
let separator = if use_fragment {
|
||||
'#'
|
||||
} else if redirect_url.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
'?'
|
||||
};
|
||||
redirect_url.push(separator);
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
redirect_url.push_str(&format!(
|
||||
"iss={}",
|
||||
url_encode(&format!("https://{}", pds_host))
|
||||
));
|
||||
if let Some(req_state) = state {
|
||||
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
|
||||
}
|
||||
redirect_url.push_str(&format!("&code={}", url_encode(code)));
|
||||
redirect_url
|
||||
}
|
||||
|
||||
fn build_intermediate_redirect_url(
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: Option<&str>,
|
||||
response_mode: Option<&str>,
|
||||
) -> String {
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
let mut url = format!(
|
||||
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
|
||||
pds_host,
|
||||
url_encode(redirect_uri),
|
||||
url_encode(code)
|
||||
);
|
||||
if let Some(s) = state {
|
||||
url.push_str(&format!("&state={}", url_encode(s)));
|
||||
}
|
||||
if let Some(rm) = response_mode {
|
||||
url.push_str(&format!("&response_mode={}", url_encode(rm)));
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeRedirectParams {
|
||||
redirect_uri: String,
|
||||
code: String,
|
||||
state: Option<String>,
|
||||
response_mode: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn authorize_redirect(Query(params): Query<AuthorizeRedirectParams>) -> Response {
|
||||
let final_url = build_success_redirect(
|
||||
¶ms.redirect_uri,
|
||||
¶ms.code,
|
||||
params.state.as_deref(),
|
||||
params.response_mode.as_deref(),
|
||||
);
|
||||
tracing::info!(
|
||||
final_url = %final_url,
|
||||
client_redirect = %params.redirect_uri,
|
||||
"authorize_redirect performing 303 redirect"
|
||||
);
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[
|
||||
(axum::http::header::LOCATION, final_url),
|
||||
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_deny(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<AuthorizeDenyForm>,
|
||||
) -> Response {
|
||||
let deny_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&deny_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid request_uri"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&deny_request_id)
|
||||
.await;
|
||||
let redirect_uri = &request_data.parameters.redirect_uri;
|
||||
let mut redirect_url = redirect_uri.to_string();
|
||||
let separator = if redirect_url.contains('?') { '&' } else { '?' };
|
||||
redirect_url.push(separator);
|
||||
redirect_url.push_str("error=access_denied");
|
||||
redirect_url.push_str("&error_description=User%20denied%20the%20request");
|
||||
if let Some(state) = &request_data.parameters.state {
|
||||
redirect_url.push_str(&format!("&state={}", url_encode(state)));
|
||||
}
|
||||
Json(serde_json::json!({
|
||||
"redirect_uri": redirect_url
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeDenyForm {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
mod consent;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod registration;
|
||||
mod two_factor;
|
||||
|
||||
pub use consent::*;
|
||||
pub use login::*;
|
||||
pub use passkey::*;
|
||||
pub use registration::*;
|
||||
pub use two_factor::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterCompleteInput {
|
||||
pub request_uri: String,
|
||||
pub did: String,
|
||||
pub app_password: String,
|
||||
}
|
||||
|
||||
pub async fn register_complete(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
|
||||
Json(form): Json<RegisterCompleteInput>,
|
||||
) -> Response {
|
||||
let did = Did::from(form.did.clone());
|
||||
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch authorization request"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if request_data.parameters.prompt != Some(Prompt::Create) {
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
prompt = ?request_data.parameters.prompt,
|
||||
"register_complete called on non-registration OAuth flow"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "This endpoint is only for registration flows."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if request_data.code.is_some() {
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
"register_complete called on already-completed OAuth flow"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization has already been completed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(existing_did) = &request_data.did
|
||||
&& existing_did != &form.did
|
||||
{
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
existing_did = %existing_did,
|
||||
attempted_did = %form.did,
|
||||
"register_complete attempted with different DID than already bound"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request is already bound to a different account."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let password_hashes = match state
|
||||
.repos
|
||||
.session
|
||||
.get_app_password_hashes_by_did(&did)
|
||||
.await
|
||||
{
|
||||
Ok(hashes) => hashes,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch app password hashes"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut password_valid = password_hashes.iter().fold(false, |acc, hash| {
|
||||
acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false)
|
||||
});
|
||||
|
||||
if !password_valid
|
||||
&& let Ok(Some(account_hash)) = state.repos.user.get_password_hash_by_did(&did).await
|
||||
{
|
||||
password_valid = bcrypt::verify(&form.app_password, &account_hash).unwrap_or(false);
|
||||
}
|
||||
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Invalid credentials."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
|
||||
Ok(Some(info)) => info.channel_verification.has_any_verified(),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Account not found."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch session info"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before continuing.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.set_authorization_did(&request_id, &did, None)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to set authorization DID"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&client_id_typed,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_consent {
|
||||
tracing::info!(
|
||||
did = %did,
|
||||
client_id = %request_data.parameters.client_id,
|
||||
"OAuth registration complete, redirecting to consent"
|
||||
);
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
|
||||
let code = Code::generate();
|
||||
let auth_code = AuthorizationCode::from(code.0.clone());
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.update_authorization_request(&request_id, &did, None, &auth_code)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to update authorization request with code"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
did = %did,
|
||||
client_id = %request_data.parameters.client_id,
|
||||
"OAuth registration flow completed successfully"
|
||||
);
|
||||
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
|
||||
pub async fn establish_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
|
||||
) -> Response {
|
||||
let did = &auth.did;
|
||||
|
||||
let existing_device = extract_device_cookie(&headers);
|
||||
|
||||
let (device_id, new_cookie) = match existing_device {
|
||||
Some(id) => {
|
||||
let _ = state.repos.oauth.upsert_account_device(did, &id).await;
|
||||
(id, None)
|
||||
}
|
||||
None => {
|
||||
let new_id = DeviceId::generate();
|
||||
let device_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.create_device(&device_typed, &device_data)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = ?e, "Failed to create device");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to establish session"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = state
|
||||
.repos
|
||||
.oauth
|
||||
.upsert_account_device(did, &device_typed)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = ?e, "Failed to link device to account");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to establish session"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let cookie = make_device_cookie(&device_typed);
|
||||
(device_typed, Some(cookie))
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(did = %did, device_id = %device_id, "Device session established");
|
||||
|
||||
match new_cookie {
|
||||
Some(cookie) => (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"device_id": device_id
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
None => Json(serde_json::json!({
|
||||
"success": true,
|
||||
"device_id": device_id
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faQuery {
|
||||
pub request_uri: String,
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faSubmit {
|
||||
pub request_uri: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub trust_device: bool,
|
||||
}
|
||||
|
||||
const MAX_2FA_ATTEMPTS: i32 = 5;
|
||||
|
||||
pub async fn authorize_2fa_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<Authorize2faQuery>,
|
||||
) -> Response {
|
||||
let twofa_request_id = RequestId::from(query.request_uri.clone());
|
||||
let challenge = match state.repos.oauth.get_2fa_challenge(&twofa_request_id).await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => {
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if challenge.expires_at < Utc::now() {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"2FA code has expired. Please start over.",
|
||||
);
|
||||
}
|
||||
let _request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&twofa_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request not found. Please start over.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let channel = query.channel.as_deref().unwrap_or("email");
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&query.request_uri),
|
||||
url_encode(channel)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn authorize_2fa_post(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<Authorize2faSubmit>,
|
||||
) -> Response {
|
||||
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
let twofa_post_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos
|
||||
.oauth
|
||||
.get_authorization_request(&twofa_post_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request not found.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.delete_authorization_request(&twofa_post_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired.",
|
||||
);
|
||||
}
|
||||
let challenge = state
|
||||
.repos
|
||||
.oauth
|
||||
.get_2fa_challenge(&twofa_post_request_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(challenge) = challenge {
|
||||
if challenge.expires_at < Utc::now() {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"2FA code has expired. Please start over.",
|
||||
);
|
||||
}
|
||||
if challenge.attempts >= MAX_2FA_ATTEMPTS {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Too many failed attempts. Please start over.",
|
||||
);
|
||||
}
|
||||
let code_valid: bool = form
|
||||
.code
|
||||
.trim()
|
||||
.as_bytes()
|
||||
.ct_eq(challenge.code.as_bytes())
|
||||
.into();
|
||||
if !code_valid {
|
||||
let _ = state.repos.oauth.increment_2fa_attempts(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"invalid_code",
|
||||
"Invalid verification code. Please try again.",
|
||||
);
|
||||
}
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
let code = Code::generate();
|
||||
let device_id = extract_device_cookie(&headers);
|
||||
let twofa_totp_device_id = device_id.clone();
|
||||
let twofa_totp_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.update_authorization_request(
|
||||
&twofa_post_request_id,
|
||||
&challenge.did,
|
||||
twofa_totp_device_id.as_ref(),
|
||||
&twofa_totp_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
return Json(serde_json::json!({
|
||||
"redirect_uri": redirect_url
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let did_str = match &request_data.did {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let did: tranquil_types::Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid DID format.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if !tranquil_api::server::has_totp_enabled(&state, &did).await {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
let _rate_proof = match check_user_rate_limit::<TotpVerifyLimit>(&state, &did).await {
|
||||
Ok(proof) => proof,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RateLimitExceeded",
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let totp_valid =
|
||||
tranquil_api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
|
||||
if !totp_valid {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"invalid_code",
|
||||
"Invalid verification code. Please try again.",
|
||||
);
|
||||
}
|
||||
let mut device_id = extract_device_cookie(&headers);
|
||||
let mut new_cookie: Option<String> = None;
|
||||
if form.trust_device {
|
||||
let trust_device_id = match &device_id {
|
||||
Some(existing_id) => existing_id.clone(),
|
||||
None => {
|
||||
let new_id = DeviceId::generate();
|
||||
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.create_device(&new_device_id_typed, &device_data)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
new_cookie = Some(make_device_cookie(&new_device_id_typed));
|
||||
device_id = Some(new_device_id_typed.clone());
|
||||
}
|
||||
new_device_id_typed
|
||||
}
|
||||
};
|
||||
let _ = state
|
||||
.repos
|
||||
.oauth
|
||||
.upsert_account_device(&did, &trust_device_id)
|
||||
.await;
|
||||
let _ =
|
||||
tranquil_api::server::trust_device(state.repos.oauth.as_ref(), &trust_device_id, &did)
|
||||
.await;
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let twofa_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&twofa_post_client_id,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
let code = Code::generate();
|
||||
let twofa_final_device_id = device_id.clone();
|
||||
let twofa_final_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos
|
||||
.oauth
|
||||
.update_authorization_request(
|
||||
&twofa_post_request_id,
|
||||
&did,
|
||||
twofa_final_device_id.as_ref(),
|
||||
&twofa_final_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user