mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-25 10:46:11 +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 | ||
|
|
e6c8a44a42 | ||
|
|
aac6f2818b | ||
|
|
1a80a33e12 | ||
|
|
00d71fd3d6 | ||
|
|
c8bb4c9076 | ||
|
|
d123168787 | ||
|
|
b337d2b154 | ||
|
|
1e07d674dd | ||
|
|
a3f96b6367 | ||
|
|
7b7936d539 | ||
|
|
7e3dca4026 | ||
|
|
7bc90d5e23 | ||
|
|
4d86f026df | ||
|
|
5c8894d531 | ||
|
|
81fc03c705 | ||
|
|
f3f55e239f | ||
|
|
cf77354cde | ||
|
|
467be99da3 | ||
|
|
20212a413f | ||
|
|
6225d2bc1d | ||
|
|
b990c239e6 | ||
|
|
5b503c3f7f | ||
|
|
7f66419036 | ||
|
|
c2b8253abe |
@@ -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"
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kyber_pre_keys",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0295414146e4ddacad638486443fd1036609ab8443390820be8696f708be3812"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_sender_keys\n (address, device_id, identity, distribution_id, record)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (address, device_id, identity, distribution_id) DO UPDATE SET record = $5",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a3eaf432e99ef22aa6b3b43978740b085f6416a058ea2da6d639ea1cf276eda"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kv",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "166ee5a554e3ddc75496616d9fba75a15cc0c072f493f579b881a4f2300f38c8"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_signed_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1a061c261518573c29aa55c5d8e2181f1728034e723ed0bc836e928b4c69128f"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1aac5ab7dbc29f0d0937767ae174e8b47d236b3cdc973974cec413dc65198d64"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kyber_pre_keys (id, identity, record, is_last_resort)\n VALUES ($1, $2, $3, FALSE)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3, is_last_resort = FALSE",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d4459863eeddea9ccb5ce5c0eaffd39d56a763728ce5b1f76871265e704e241"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_sessions WHERE address = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1f464d947e93274cbc974f8a6f31d8a47eb676df857fa83f08ed1937cf8a6d9d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_base_keys_seen",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "222836500a964ee6fd238ddb128691215cd15d2ede9a1a19e7a580e4d6644ce8"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_identities",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "252d42bb50ef918fe414d036979daba055dbadd3cd7e1bdcce4526867be79930"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kv (key, value) VALUES ($1, $2)\n ON CONFLICT (key) DO UPDATE SET value = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "29c834b7870cdbcdcc2d2c9259a04326e20433237ebe25e33dbd39952ae9f976"
|
||||
}
|
||||
+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": "INSERT INTO signal_base_keys_seen\n (kyber_pre_key_id, signed_pre_key_id, identity, base_key)\n VALUES ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "31edfe22cdd8e5f62322dad01a65165ec9546eaa92c9e372a5a4fce99d938d22"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kv WHERE key = 'registration'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "374be0bd97ed927b15929e2f86d88913b2fec45ab67e4805fb9f1e39b422ee7f"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a3eee11be6a9c5fe012d2a634c7ef514fb695ca6a4ba7631d1c5aba87a854a5"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_sender_keys",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4011d789a6b619231f4e2b917753f0b4452f21243e1d95d47dbaa91cac019e64"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM signal_kv WHERE key = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4316de453ec1d5ba65a1dbee29599287d42bf87517936b3b61958c0675ce8115"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_sessions",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "43583f8246016d15d9875c4d33855317c41ae07af5676228c0533724590ec6ed"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_identities WHERE address = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4bcd896340fc9178e2d97ad572269e8f57561190ec5f94ccfa06ca93f4827e79"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_sessions (address, device_id, identity, record)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (address, device_id, identity) DO UPDATE SET record = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4d22ba21356bdc84ea549799c77cab8c34ca9a5f0815150254b590fd87509eb8"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_last_resort FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_last_resort",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "513a0d3b907df1004f2178ebf14fc182f20fc13c5691cbeb7656d428cb6437f9"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kv (key, value) VALUES ('master_key', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "53814b5db4f051c30573d146007ddc487f4c12c726bb970efe17e325536629e4"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kv (key, value) VALUES ('sender_certificate', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5cd556968671fc8ecf57dd7fd40f65e3a168f17bd389ce2cf7ddee27d810cf1d"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_identities (address, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (address, identity) DO UPDATE SET record = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "62dc57cf7ad007a6458df06619fe7e0bf5ffeba195e55793f6bf6d170e053226"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) AS \"count!\" FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "63ecf786e52a52d83232f6d4c8d93d5d7065f5dbfd3cd1e259d7990d02da1114"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = TRUE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6a91d97ed4f955ce0936eced6318c630f39ebebc52cee584f1b0dacefec984cd"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kv WHERE key = 'master_key'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6cb39ddd462ae93fe97d930ce64425ed16271adc4ca066c4d51bb742beffc22b"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6cbfcd552a7b4eeb6a1e525f2f11e8f432405e3606e3af7d31b9eca32bc28607"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kv (key, value) VALUES ('registration', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f14e2d74ed0c1e0946c62da2a3ddd63344892bdd4022073123604ee17114ce0"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_pre_keys (id, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "811af305fef56437151c1cfe27eaccf6312b8234f1217a81a4d36cd5758fd863"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_profile_keys (uuid, key) VALUES ($1, $2)\n ON CONFLICT (uuid) DO UPDATE SET key = $2\n RETURNING (xmax = 0) AS \"inserted!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "inserted!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "894169c5a40acade1000869bdde61d09984e17a686c1b487004d79897ecb4e4f"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT MAX(id) FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = TRUE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "max",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9da9a790efa51edd6abcb4b0991b307c3ba7f0a929e114a4e0cb2ffb88adfbbb"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT device_id FROM signal_sessions\n WHERE address = $1 AND device_id != $2 AND identity = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "device_id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9fdcff515573446f0b4340bf94421bb8a425cc65304fa8c3fd153945904136f0"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_profile_keys",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a403c2982978159b120d4bfa6d6fdca5808b667e54b0765b5ada047ce6dac9d6"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE signal_kyber_pre_keys\n SET stale_at = $1\n WHERE identity = $2 AND is_last_resort = FALSE AND stale_at IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "adabc98c853ae77678ad6cf1747384706154334cc507eb7542a8b5490f5a1e2a"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_kyber_pre_keys (id, identity, record, is_last_resort)\n VALUES ($1, $2, $3, TRUE)\n ON CONFLICT (id, identity) DO UPDATE SET is_last_resort = TRUE, record = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b157b94e003376d827b92eab1ff55caa773ed5691e2e555ab1d1f23fc5adcf8e"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT MAX(id) FROM signal_pre_keys WHERE identity = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "max",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b558892051ad793b4ae38a76d072e4ea440092663bd0d4ed9d48f5074788157a"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_sender_keys\n WHERE address = $1 AND device_id = $2 AND identity = $3 AND distribution_id = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b583ea011f17bd703a1dde860d1cc595a18c33dd0e37b575a71fa78f9fddc96f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_signed_pre_keys",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c08770c1ca1cb553e9067c26dec9a47d40f4cd3e16461cf0edbbb49020695f18"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM signal_kv WHERE key = 'registration'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c130944c8791acb05ebf5f7b5b0f82b8c548ea9d516c0e9310fb46ffd3907977"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH total AS (\n SELECT COUNT(*) AS cnt FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = FALSE\n )\n DELETE FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = FALSE\n AND stale_at IS NOT NULL AND stale_at < $2\n AND (SELECT cnt FROM total) > $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c30d8f2bb8a20154b565a93108d3871289a752fb8d0de35a2164619da2c03aa0"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) AS \"count!\" FROM signal_signed_pre_keys WHERE identity = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c5310fca925ec4188e761f218e98c11182e5757c40a70a2aa50af0f2896d98e7"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT key FROM signal_profile_keys WHERE uuid = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "key",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c67709e3fb975148fa86d9fd5ed5e1ef805b2f57a2ce5d955629d598e1ab2b80"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kyber_pre_keys\n WHERE id = $1 AND identity = $2 AND is_last_resort = FALSE",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c749d53e4e38dd53aa2ac59ba2b15b641ace056515c34ec977bf47d74dfd7072"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM signal_kv WHERE key = 'master_key' LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c86da12578b2e53e0bff374121b22244c4663820cbb6edfd8f0b8c9aa583cad2"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM signal_kv WHERE key = 'sender_certificate' LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dded08cb4fd0fc9bca55b3cf0c8d22f4e279bc8aedb72000f4c4bc093f61c750"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO signal_signed_pre_keys (id, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "def40c1cfac196a066d81fb0efa0fa7bdf5f242bd13c8b70e47a512c5e9df5b1"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_pre_keys",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2ed0b4bfe13a99be85311fcc6e3c2dbdb789b9a44c0122f736ead48f4713a5b"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eb837730e87ff990c4441d7bdab001d7542d1803702c634a6099365a9740e45a"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT MAX(id) FROM signal_kyber_pre_keys WHERE identity = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "max",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "eea566212ffce269028a8623a12b553c048ef1ea433aa5567e72b2b639f69b0d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM signal_sessions WHERE address = $1 AND device_id = $2 AND identity = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "efcf513bba65213ed4b71259a7016ffd7b1b8ff85206416a86beba19323a3be7"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT record FROM signal_sessions\n WHERE address = $1 AND device_id = $2 AND identity = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "record",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f332e618e59a298573c4b6b6c7a12252b461a6676f2d797c5dae5a0baee016a7"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT MAX(id) FROM signal_signed_pre_keys WHERE identity = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "max",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f41749b35c081fc9d0881c7e2fc988e7c4d164ab5996f18e9f3a29fe4d183db9"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
when:
|
||||
- event: []
|
||||
branch: []
|
||||
|
||||
engine: nixery
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- podman
|
||||
|
||||
steps:
|
||||
- name: Create podman config
|
||||
command: |
|
||||
mkdir -p ~/.config/containers
|
||||
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
|
||||
|
||||
- name: Build image
|
||||
command: |
|
||||
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
|
||||
|
||||
- name: Publish image
|
||||
command: |
|
||||
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
+2296
-585
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -21,10 +21,12 @@ members = [
|
||||
"crates/tranquil-oauth-server",
|
||||
"crates/tranquil-api",
|
||||
"crates/tranquil-lexicon",
|
||||
"crates/tranquil-signal",
|
||||
"crates/tranquil-store",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.4"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -49,6 +51,10 @@ 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", features = ["fjall-store"] }
|
||||
tranquil-store = { path = "crates/tranquil-store" }
|
||||
|
||||
presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844ae51c3a9fa49cf80a7816a31a425", default-features = false }
|
||||
|
||||
unicode-segmentation = "1"
|
||||
|
||||
@@ -81,6 +87,7 @@ hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
http = "1.4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
qrcodegen = "1.8"
|
||||
infer = "0.19"
|
||||
ipld-core = "0.4"
|
||||
iroh-car = "0.5"
|
||||
@@ -129,7 +136,15 @@ testcontainers = "0.26"
|
||||
testcontainers-modules = { version = "0.14", features = ["postgres"] }
|
||||
wiremock = "0.6"
|
||||
|
||||
[patch.crates-io]
|
||||
curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag = "signal-curve25519-4.1.3" }
|
||||
|
||||
[profile.release]
|
||||
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()))
|
||||
@@ -69,7 +69,7 @@ pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissiv
|
||||
if let Some(age) = personal_details_pref
|
||||
.as_ref()
|
||||
.and_then(|pref| pref.get("birthDate"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(Value::as_str)
|
||||
.and_then(get_age_from_datestring)
|
||||
{
|
||||
let declared_age_pref = serde_json::json!({
|
||||
@@ -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();
|
||||
@@ -122,7 +122,7 @@ pub async fn put_preferences(
|
||||
if pref_str.len() > MAX_PREFERENCE_SIZE {
|
||||
return PrefValidation::TooLarge(pref_str.len());
|
||||
}
|
||||
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
|
||||
let pref_type = match pref.get("$type").and_then(Value::as_str) {
|
||||
Some(t) => t,
|
||||
None => return PrefValidation::MissingType,
|
||||
};
|
||||
@@ -179,7 +179,7 @@ pub async fn put_preferences(
|
||||
.preferences
|
||||
.into_iter()
|
||||
.filter_map(|pref| {
|
||||
let pref_type = pref.get("$type").and_then(|t| t.as_str())?;
|
||||
let pref_type = pref.get("$type").and_then(Value::as_str)?;
|
||||
if pref_type == DECLARED_AGE_PREF {
|
||||
return None;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
use tranquil_pds::api::EmptyResponse;
|
||||
@@ -20,10 +16,11 @@ pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<DeleteAccountInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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")?
|
||||
@@ -31,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")?;
|
||||
@@ -52,5 +50,5 @@ pub async fn delete_account(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
|
||||
.await;
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
@@ -30,13 +25,14 @@ pub async fn send_email(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<SendEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<SendEmailOutput>, ApiError> {
|
||||
let content = input.content.trim();
|
||||
if content.is_empty() {
|
||||
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")?
|
||||
@@ -50,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,
|
||||
@@ -68,11 +65,11 @@ pub async fn send_email(
|
||||
handle,
|
||||
input.recipient_did
|
||||
);
|
||||
Ok((StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response())
|
||||
Ok(Json(SendEmailOutput { sent: true }))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to enqueue admin email: {:?}", e);
|
||||
Ok((StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response())
|
||||
Ok(Json(SendEmailOutput { sent: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::common;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, RawQuery, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -68,9 +67,10 @@ pub async fn get_account_info(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Query(params): Query<GetAccountInfoParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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")?
|
||||
@@ -79,31 +79,28 @@ pub async fn get_account_info(
|
||||
let invited_by = get_invited_by(&state, account.id).await;
|
||||
let invites = get_invites_for_user(&state, account.id).await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(AccountInfo {
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
email: account.email,
|
||||
indexed_at: account.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: account.invites_disabled,
|
||||
email_confirmed_at: if account.email_verified {
|
||||
Some(account.created_at.to_rfc3339())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
deactivated_at: account.deactivated_at.map(|dt| dt.to_rfc3339()),
|
||||
invited_by,
|
||||
invites,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(AccountInfo {
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
email: account.email,
|
||||
indexed_at: account.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: account.invites_disabled,
|
||||
email_confirmed_at: if account.email_verified {
|
||||
Some(account.created_at.to_rfc3339())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
deactivated_at: account.deactivated_at.map(|dt| dt.to_rfc3339()),
|
||||
invited_by,
|
||||
invites,
|
||||
}))
|
||||
}
|
||||
|
||||
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()??;
|
||||
@@ -116,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()?;
|
||||
@@ -128,21 +126,16 @@ 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()?;
|
||||
|
||||
let uses_by_code: HashMap<String, Vec<InviteCodeUseInfo>> =
|
||||
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
|
||||
acc.entry(u.code.clone())
|
||||
.or_default()
|
||||
.push(InviteCodeUseInfo {
|
||||
used_by: u.used_by_did,
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
acc
|
||||
});
|
||||
let uses_by_code = common::group_invite_uses_by_code(uses, |u| InviteCodeUseInfo {
|
||||
used_by: u.used_by_did,
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
|
||||
let invites: Vec<InviteCodeInfo> = invite_codes
|
||||
.into_iter()
|
||||
@@ -165,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()
|
||||
@@ -195,7 +189,7 @@ pub async fn get_account_infos(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<GetAccountInfosOutput>, ApiError> {
|
||||
let dids: Vec<String> =
|
||||
tranquil_pds::util::parse_repeated_query_param(raw_query.as_deref(), "dids")
|
||||
.into_iter()
|
||||
@@ -208,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")?;
|
||||
@@ -216,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();
|
||||
@@ -228,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()
|
||||
@@ -237,25 +234,18 @@ 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()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let uses_by_code: HashMap<String, Vec<InviteCodeUseInfo>> =
|
||||
all_invite_uses
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut acc, u| {
|
||||
acc.entry(u.code.clone())
|
||||
.or_default()
|
||||
.push(InviteCodeUseInfo {
|
||||
used_by: u.used_by_did,
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
acc
|
||||
});
|
||||
let uses_by_code = common::group_invite_uses_by_code(all_invite_uses, |u| InviteCodeUseInfo {
|
||||
used_by: u.used_by_did,
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
|
||||
let (codes_by_user, code_info_map): (
|
||||
HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
|
||||
@@ -304,5 +294,5 @@ pub async fn get_account_infos(
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response())
|
||||
Ok(Json(GetAccountInfosOutput { infos }))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tranquil_pds::api::error::{ApiError, DbResultExt};
|
||||
@@ -51,17 +49,16 @@ pub async fn search_accounts(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Query(params): Query<SearchAccountsParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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
|
||||
@@ -91,12 +88,8 @@ pub async fn search_accounts(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(SearchAccountsOutput {
|
||||
cursor: next_cursor,
|
||||
accounts,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(SearchAccountsOutput {
|
||||
cursor: next_cursor,
|
||||
accounts,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, warn};
|
||||
use tranquil_pds::api::EmptyResponse;
|
||||
@@ -21,7 +17,7 @@ pub async fn update_account_email(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<UpdateAccountEmailInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let account = input.account.trim();
|
||||
let email = input.email.trim();
|
||||
if account.is_empty() || email.is_empty() {
|
||||
@@ -34,12 +30,13 @@ 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
|
||||
{
|
||||
Ok(0) => Err(ApiError::AccountNotFound),
|
||||
Ok(_) => Ok(EmptyResponse::ok().into_response()),
|
||||
Ok(_) => Ok(Json(EmptyResponse {})),
|
||||
Err(e) => {
|
||||
error!("DB error updating email: {:?}", e);
|
||||
Err(ApiError::InternalError(None))
|
||||
@@ -57,7 +54,7 @@ pub async fn update_account_handle(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<UpdateAccountHandleInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let did = &input.did;
|
||||
let input_handle = input.handle.trim();
|
||||
if input_handle.is_empty() {
|
||||
@@ -75,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()
|
||||
@@ -85,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
|
||||
{
|
||||
@@ -125,7 +125,7 @@ pub async fn update_account_handle(
|
||||
{
|
||||
warn!("Failed to update PLC handle for admin handle update: {}", e);
|
||||
}
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating handle: {:?}", e);
|
||||
@@ -144,24 +144,22 @@ pub async fn update_account_password(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<UpdateAccountPasswordInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let did = &input.did;
|
||||
let password = input.password.trim();
|
||||
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
|
||||
{
|
||||
Ok(0) => Err(ApiError::AccountNotFound),
|
||||
Ok(_) => Ok(EmptyResponse::ok().into_response()),
|
||||
Ok(_) => Ok(Json(EmptyResponse {})),
|
||||
Err(e) => {
|
||||
error!("DB error updating password: {:?}", e);
|
||||
Err(ApiError::InternalError(None))
|
||||
|
||||
@@ -8,7 +8,7 @@ use tranquil_types::CidLink;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerConfigResponse {
|
||||
pub struct ServerConfigOutput {
|
||||
pub server_name: String,
|
||||
pub primary_color: Option<String>,
|
||||
pub primary_color_dark: Option<String>,
|
||||
@@ -29,7 +29,7 @@ pub struct UpdateServerConfigRequest {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateServerConfigResponse {
|
||||
pub struct UpdateServerConfigOutput {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fn is_valid_hex_color(s: &str) -> bool {
|
||||
|
||||
pub async fn get_server_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ServerConfigResponse>, ApiError> {
|
||||
) -> Result<Json<ServerConfigOutput>, ApiError> {
|
||||
let keys = &[
|
||||
"server_name",
|
||||
"primary_color",
|
||||
@@ -53,14 +53,15 @@ pub async fn get_server_config(
|
||||
];
|
||||
|
||||
let rows = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_server_configs(keys)
|
||||
.await
|
||||
.log_db_err("fetching server config")?;
|
||||
|
||||
let config_map: std::collections::HashMap<String, String> = rows.into_iter().collect();
|
||||
|
||||
Ok(Json(ServerConfigResponse {
|
||||
Ok(Json(ServerConfigOutput {
|
||||
server_name: config_map
|
||||
.get("server_name")
|
||||
.cloned()
|
||||
@@ -77,7 +78,7 @@ pub async fn update_server_config(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(req): Json<UpdateServerConfigRequest>,
|
||||
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
|
||||
) -> Result<Json<UpdateServerConfigOutput>, ApiError> {
|
||||
if let Some(server_name) = req.server_name {
|
||||
let trimmed = server_name.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 100 {
|
||||
@@ -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,18 +225,20 @@ 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")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(UpdateServerConfigResponse { success: true }))
|
||||
Ok(Json(UpdateServerConfigOutput { success: true }))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::common;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
@@ -23,9 +22,9 @@ pub async fn disable_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<DisableInviteCodesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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);
|
||||
}
|
||||
@@ -33,14 +32,15 @@ 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
|
||||
{
|
||||
error!("DB error disabling invite codes by account: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -80,7 +80,7 @@ pub async fn get_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Query(params): Query<GetInviteCodesParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<GetInviteCodesOutput>, ApiError> {
|
||||
let limit = params.limit.unwrap_or(100).clamp(1, 500);
|
||||
let sort_order = match params.sort.as_deref() {
|
||||
Some("usage") => InviteCodeSortOrder::Usage,
|
||||
@@ -88,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")?;
|
||||
@@ -97,33 +98,30 @@ 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()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
|
||||
if code_strings.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
let uses_by_code = if code_strings.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
common::group_invite_uses_by_code(
|
||||
state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.get_invite_code_uses_batch(&code_strings)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(std::collections::HashMap::new(), |mut acc, u| {
|
||||
acc.entry(u.code.clone())
|
||||
.or_default()
|
||||
.push(InviteCodeUseInfo {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
acc
|
||||
})
|
||||
};
|
||||
.unwrap_or_default(),
|
||||
|u| InviteCodeUseInfo {
|
||||
used_by: u.used_by_did.to_string(),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let codes: Vec<InviteCodeInfo> = codes_rows
|
||||
.iter()
|
||||
@@ -149,14 +147,10 @@ pub async fn get_invite_codes(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(GetInviteCodesOutput {
|
||||
cursor: next_cursor,
|
||||
codes,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(GetInviteCodesOutput {
|
||||
cursor: next_cursor,
|
||||
codes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -168,7 +162,7 @@ pub async fn disable_account_invites(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<DisableAccountInvitesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let account = input.account.trim();
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
@@ -178,11 +172,12 @@ 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
|
||||
{
|
||||
Ok(true) => Ok(EmptyResponse::ok().into_response()),
|
||||
Ok(true) => Ok(Json(EmptyResponse {})),
|
||||
Ok(false) => Err(ApiError::AccountNotFound),
|
||||
Err(e) => {
|
||||
error!("DB error disabling account invites: {:?}", e);
|
||||
@@ -200,7 +195,7 @@ pub async fn enable_account_invites(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<EnableAccountInvitesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
let account = input.account.trim();
|
||||
if account.is_empty() {
|
||||
return Err(ApiError::InvalidRequest("account is required".into()));
|
||||
@@ -210,11 +205,12 @@ 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
|
||||
{
|
||||
Ok(true) => Ok(EmptyResponse::ok().into_response()),
|
||||
Ok(true) => Ok(Json(EmptyResponse {})),
|
||||
Ok(false) => Err(ApiError::AccountNotFound),
|
||||
Err(e) => {
|
||||
error!("DB error enabling account invites: {:?}", e);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use serde::Serialize;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
@@ -10,7 +6,7 @@ use tranquil_pds::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatsResponse {
|
||||
pub struct ServerStatsOutput {
|
||||
pub user_count: i64,
|
||||
pub repo_count: i64,
|
||||
pub record_count: i64,
|
||||
@@ -20,17 +16,16 @@ pub struct ServerStatsResponse {
|
||||
pub async fn get_server_stats(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Response, 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);
|
||||
) -> Result<Json<ServerStatsOutput>, ApiError> {
|
||||
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(ServerStatsResponse {
|
||||
Ok(Json(ServerStatsOutput {
|
||||
user_count,
|
||||
repo_count,
|
||||
record_count,
|
||||
blob_storage_bytes,
|
||||
})
|
||||
.into_response())
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{error, warn};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{Admin, Auth};
|
||||
@@ -37,7 +35,7 @@ pub async fn get_subject_status(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Query(params): Query<GetSubjectStatusParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<SubjectStatus>, ApiError> {
|
||||
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Must provide did, uri, or blob".into(),
|
||||
@@ -47,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,
|
||||
@@ -57,18 +55,14 @@ pub async fn get_subject_status(
|
||||
applied: true,
|
||||
r#ref: Some(r.clone()),
|
||||
});
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.admin.defs#repoRef",
|
||||
"did": did_str
|
||||
}),
|
||||
takedown,
|
||||
deactivated,
|
||||
return Ok(Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.admin.defs#repoRef",
|
||||
"did": did_str
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
takedown,
|
||||
deactivated,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(ApiError::SubjectNotFound);
|
||||
@@ -83,25 +77,21 @@ 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,
|
||||
r#ref: Some(r.clone()),
|
||||
});
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.repo.strongRef",
|
||||
"uri": uri_str,
|
||||
"cid": uri_str
|
||||
}),
|
||||
takedown,
|
||||
deactivated: None,
|
||||
return Ok(Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.repo.strongRef",
|
||||
"uri": uri_str,
|
||||
"cid": uri_str
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
takedown,
|
||||
deactivated: None,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(ApiError::RecordNotFound);
|
||||
@@ -119,25 +109,21 @@ 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,
|
||||
r#ref: Some(r.clone()),
|
||||
});
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.admin.defs#repoBlobRef",
|
||||
"did": did,
|
||||
"cid": blob.cid
|
||||
}),
|
||||
takedown,
|
||||
deactivated: None,
|
||||
return Ok(Json(SubjectStatus {
|
||||
subject: json!({
|
||||
"$type": "com.atproto.admin.defs#repoBlobRef",
|
||||
"did": did,
|
||||
"cid": blob.cid
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
takedown,
|
||||
deactivated: None,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(ApiError::BlobNotFound(None));
|
||||
@@ -169,11 +155,11 @@ pub async fn update_subject_status(
|
||||
State(state): State<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
Json(input): Json<UpdateSubjectStatusInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let subject_type = input.subject.get("$type").and_then(Value::as_str);
|
||||
match subject_type {
|
||||
Some("com.atproto.admin.defs#repoRef") => {
|
||||
let did_str = input.subject.get("did").and_then(|d| d.as_str());
|
||||
let did_str = input.subject.get("did").and_then(Value::as_str);
|
||||
if let Some(did_str) = did_str {
|
||||
let did: Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
@@ -186,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| {
|
||||
@@ -196,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!(
|
||||
@@ -208,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
|
||||
@@ -221,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
|
||||
@@ -232,30 +229,26 @@ 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))
|
||||
.await;
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
})),
|
||||
"deactivated": input.deactivated.as_ref().map(|d| json!({
|
||||
"applied": d.applied
|
||||
}))
|
||||
return Ok(Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
"deactivated": input.deactivated.as_ref().map(|d| json!({
|
||||
"applied": d.applied
|
||||
}))
|
||||
})));
|
||||
}
|
||||
}
|
||||
Some("com.atproto.repo.strongRef") => {
|
||||
let uri_str = input.subject.get("uri").and_then(|u| u.as_str());
|
||||
let uri_str = input.subject.get("uri").and_then(Value::as_str);
|
||||
if let Some(uri_str) = uri_str {
|
||||
let cid: CidLink = uri_str
|
||||
.parse()
|
||||
@@ -267,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| {
|
||||
@@ -278,21 +272,17 @@ pub async fn update_subject_status(
|
||||
ApiError::InternalError(Some("Failed to update takedown status".into()))
|
||||
})?;
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
}))
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
return Ok(Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
}))
|
||||
})));
|
||||
}
|
||||
}
|
||||
Some("com.atproto.admin.defs#repoBlobRef") => {
|
||||
let cid_str = input.subject.get("cid").and_then(|c| c.as_str());
|
||||
let cid_str = input.subject.get("cid").and_then(Value::as_str);
|
||||
if let Some(cid_str) = cid_str {
|
||||
let cid: CidLink = cid_str
|
||||
.parse()
|
||||
@@ -304,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| {
|
||||
@@ -315,17 +306,13 @@ pub async fn update_subject_status(
|
||||
ApiError::InternalError(Some("Failed to update takedown status".into()))
|
||||
})?;
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
}))
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
return Ok(Json(json!({
|
||||
"subject": input.subject,
|
||||
"takedown": input.takedown.as_ref().map(|t| json!({
|
||||
"applied": t.applied,
|
||||
"ref": t.r#ref
|
||||
}))
|
||||
})));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1,37 +1,60 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
http::{HeaderMap, Method},
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde::Serialize;
|
||||
use tranquil_pds::auth::{
|
||||
AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
pub async fn get_state(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgeAssuranceState {
|
||||
pub status: &'static str,
|
||||
pub access: &'static str,
|
||||
pub last_initiated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgeAssuranceMetadata {
|
||||
pub account_created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetAgeAssuranceOutput {
|
||||
pub state: AgeAssuranceState,
|
||||
pub metadata: AgeAssuranceMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AgeAssuranceStatusOutput {
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
pub async fn get_state(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<GetAgeAssuranceOutput> {
|
||||
let created_at = get_account_created_at(&state, &headers).await;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"state": {
|
||||
"status": "assured",
|
||||
"access": "full",
|
||||
"lastInitiatedAt": now
|
||||
},
|
||||
"metadata": {
|
||||
"accountCreatedAt": created_at
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
Json(GetAgeAssuranceOutput {
|
||||
state: AgeAssuranceState {
|
||||
status: "assured",
|
||||
access: "full",
|
||||
last_initiated_at: now,
|
||||
},
|
||||
metadata: AgeAssuranceMetadata {
|
||||
account_created_at: created_at,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_age_assurance_state() -> Response {
|
||||
(StatusCode::OK, Json(json!({"status": "assured"}))).into_response()
|
||||
pub async fn get_age_assurance_state() -> Json<AgeAssuranceStatusOutput> {
|
||||
Json(AgeAssuranceStatusOutput { status: "assured" })
|
||||
}
|
||||
|
||||
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||
@@ -45,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,
|
||||
@@ -66,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())
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use tracing::error;
|
||||
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle};
|
||||
|
||||
pub struct ResolvedRepo {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> {
|
||||
let raw = handle.as_str();
|
||||
let qualified = match raw.contains('.') {
|
||||
true => return Ok(handle.clone()),
|
||||
false => format!(
|
||||
"{}.{}",
|
||||
raw,
|
||||
tranquil_config::get().server.hostname_without_port()
|
||||
),
|
||||
};
|
||||
qualified
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid handle format".into()))
|
||||
}
|
||||
|
||||
pub async fn resolve_repo(
|
||||
user_repo: &dyn UserRepository,
|
||||
repo: &AtIdentifier,
|
||||
) -> Result<ResolvedRepo, ApiError> {
|
||||
let row = match repo {
|
||||
AtIdentifier::Did(did) => user_repo
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.log_db_err("resolving repo by DID")?,
|
||||
AtIdentifier::Handle(handle) => {
|
||||
let qualified = qualify_handle(handle)?;
|
||||
user_repo
|
||||
.get_by_handle(&qualified)
|
||||
.await
|
||||
.log_db_err("resolving repo by handle")?
|
||||
}
|
||||
};
|
||||
row.map(|r| ResolvedRepo {
|
||||
user_id: r.id,
|
||||
did: r.did,
|
||||
handle: r.handle,
|
||||
})
|
||||
.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
|
||||
}
|
||||
|
||||
pub async fn resolve_repo_user_id(
|
||||
user_repo: &dyn UserRepository,
|
||||
repo: &AtIdentifier,
|
||||
) -> Result<uuid::Uuid, ApiError> {
|
||||
let id = match repo {
|
||||
AtIdentifier::Did(did) => user_repo
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.log_db_err("resolving repo user ID by DID")?,
|
||||
AtIdentifier::Handle(handle) => {
|
||||
let qualified = qualify_handle(handle)?;
|
||||
user_repo
|
||||
.get_id_by_handle(&qualified)
|
||||
.await
|
||||
.log_db_err("resolving repo user ID by handle")?
|
||||
}
|
||||
};
|
||||
id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
|
||||
}
|
||||
|
||||
pub fn group_invite_uses_by_code<U, F>(
|
||||
uses: Vec<tranquil_db_traits::InviteCodeUse>,
|
||||
map_use: F,
|
||||
) -> HashMap<String, Vec<U>>
|
||||
where
|
||||
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
|
||||
{
|
||||
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
|
||||
let code = u.code.clone();
|
||||
acc.entry(code).or_default().push(map_use(u));
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_also_known_as(
|
||||
overrides: Option<&DidWebOverrides>,
|
||||
current_handle: &str,
|
||||
) -> Vec<String> {
|
||||
overrides
|
||||
.filter(|ovr| !ovr.also_known_as.is_empty())
|
||||
.map(|ovr| ovr.also_known_as.clone())
|
||||
.unwrap_or_else(|| vec![format!("at://{}", current_handle)])
|
||||
}
|
||||
|
||||
pub fn build_did_document(
|
||||
did: &str,
|
||||
also_known_as: Vec<String>,
|
||||
verification_methods: Vec<serde_json::Value>,
|
||||
service_endpoint: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": verification_methods,
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_channel_verified_flag(
|
||||
user_repo: &dyn UserRepository,
|
||||
user_id: uuid::Uuid,
|
||||
channel: CommsChannel,
|
||||
) -> Result<(), ApiError> {
|
||||
match channel {
|
||||
CommsChannel::Email => user_repo
|
||||
.set_email_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating email verified status")?,
|
||||
CommsChannel::Discord => user_repo
|
||||
.set_discord_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating discord verified status")?,
|
||||
CommsChannel::Telegram => user_repo
|
||||
.set_telegram_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating telegram verified status")?,
|
||||
CommsChannel::Signal => user_repo
|
||||
.set_signal_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating signal verified status")?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ChannelInput<'a> {
|
||||
pub email: Option<&'a str>,
|
||||
pub discord_username: Option<&'a str>,
|
||||
pub telegram_username: Option<&'a str>,
|
||||
pub signal_username: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn extract_verification_recipient(
|
||||
channel: CommsChannel,
|
||||
input: &ChannelInput<'_>,
|
||||
) -> Result<String, ApiError> {
|
||||
match channel {
|
||||
CommsChannel::Email => match input.email {
|
||||
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
|
||||
_ => Err(ApiError::MissingEmail),
|
||||
},
|
||||
CommsChannel::Discord => match input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
));
|
||||
}
|
||||
Ok(clean)
|
||||
}
|
||||
_ => Err(ApiError::MissingDiscordId),
|
||||
},
|
||||
CommsChannel::Telegram => match input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
));
|
||||
}
|
||||
Ok(clean.to_string())
|
||||
}
|
||||
_ => Err(ApiError::MissingTelegramUsername),
|
||||
},
|
||||
CommsChannel::Signal => match input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
Ok(username.trim().trim_start_matches('@').to_lowercase())
|
||||
}
|
||||
_ => Err(ApiError::MissingSignalNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
|
||||
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(format!("did:web:{}", encoded_handle))
|
||||
}
|
||||
|
||||
pub enum CredentialMatch {
|
||||
MainPassword,
|
||||
AppPassword {
|
||||
name: String,
|
||||
scopes: Option<String>,
|
||||
controller_did: Option<Did>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn verify_credential(
|
||||
session_repo: &dyn SessionRepository,
|
||||
user_id: uuid::Uuid,
|
||||
password: &str,
|
||||
password_hash: Option<&str>,
|
||||
) -> Option<CredentialMatch> {
|
||||
let main_valid = password_hash
|
||||
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if main_valid {
|
||||
return Some(CredentialMatch::MainPassword);
|
||||
}
|
||||
let app_passwords = session_repo
|
||||
.get_app_passwords_for_login(user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
app_passwords
|
||||
.into_iter()
|
||||
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
|
||||
.map(|app| CredentialMatch::AppPassword {
|
||||
name: app.name,
|
||||
scopes: app.scopes,
|
||||
controller_did: app.created_by_controller_did,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
input_token: &str,
|
||||
expired_err: ApiError,
|
||||
invalid_err: ApiError,
|
||||
) -> Result<(), ApiError> {
|
||||
match expires_at {
|
||||
Some(exp) if exp < Utc::now() => Err(expired_err),
|
||||
_ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) {
|
||||
true => Ok(()),
|
||||
false => Err(invalid_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ use crate::identity::provision::{create_plc_did, init_genesis_repo};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::{
|
||||
AccountsOutput, AuditLogOutput, ControllersOutput, PresetsOutput, SuccessResponse,
|
||||
};
|
||||
use tranquil_pds::auth::{Active, Auth};
|
||||
use tranquil_pds::delegation::{
|
||||
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
|
||||
@@ -21,30 +22,27 @@ use tranquil_pds::types::{Did, Handle};
|
||||
pub async fn list_controllers(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let controllers = match state
|
||||
.delegation_repo
|
||||
) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> {
|
||||
let controllers = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_delegations_for_account(&auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to list controllers: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to list controllers".into())).into_response(),
|
||||
);
|
||||
}
|
||||
};
|
||||
ApiError::InternalError(Some("Failed to list controllers".into()))
|
||||
})?;
|
||||
|
||||
let resolve_futures = controllers.into_iter().map(|mut c| {
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
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(|h| h.into());
|
||||
.map(Into::into);
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -52,7 +50,7 @@ pub async fn list_controllers(
|
||||
|
||||
let controllers = futures::future::join_all(resolve_futures).await;
|
||||
|
||||
Ok(Json(serde_json::json!({ "controllers": controllers })).into_response())
|
||||
Ok(Json(ControllersOutput { controllers }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -65,18 +63,18 @@ pub async fn add_controller(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<AddControllerInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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
|
||||
{
|
||||
if !pds_url.starts_with("https://") {
|
||||
return Ok(
|
||||
ApiError::InvalidDelegation("Controller PDS must use HTTPS".into()).into_response(),
|
||||
);
|
||||
return Err(ApiError::InvalidDelegation(
|
||||
"Controller PDS must use HTTPS".into(),
|
||||
));
|
||||
}
|
||||
match state
|
||||
.cross_pds_oauth
|
||||
@@ -84,10 +82,9 @@ pub async fn add_controller(
|
||||
.await
|
||||
{
|
||||
Some(true) => {
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
return Err(ApiError::InvalidDelegation(
|
||||
"Cannot add a delegated account from another PDS as a controller".into(),
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
Some(false) => {}
|
||||
None => {
|
||||
@@ -100,26 +97,24 @@ pub async fn add_controller(
|
||||
}
|
||||
}
|
||||
|
||||
let can_add = match verify_can_add_controllers(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
let can_add = verify_can_add_controllers(&state, &auth).await?;
|
||||
|
||||
if resolved.is_local
|
||||
&& state
|
||||
.delegation_repo
|
||||
.repos
|
||||
.delegation
|
||||
.is_delegated_account(&input.controller_did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(ApiError::InvalidDelegation(
|
||||
return Err(ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
|
||||
match state
|
||||
.delegation_repo
|
||||
.repos
|
||||
.delegation
|
||||
.create_delegation(
|
||||
can_add.did(),
|
||||
&input.controller_did,
|
||||
@@ -130,13 +125,14 @@ pub async fn add_controller(
|
||||
{
|
||||
Ok(_) => {
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.repos
|
||||
.delegation
|
||||
.log_delegation_action(
|
||||
can_add.did(),
|
||||
can_add.did(),
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::GrantCreated,
|
||||
Some(serde_json::json!({
|
||||
Some(json!({
|
||||
"granted_scopes": input.granted_scopes.as_str(),
|
||||
"is_local": resolved.is_local
|
||||
})),
|
||||
@@ -145,17 +141,13 @@ pub async fn add_controller(
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to add controller: {:?}", e);
|
||||
Ok(ApiError::InternalError(Some("Failed to add controller".into())).into_response())
|
||||
Err(ApiError::InternalError(Some(
|
||||
"Failed to add controller".into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,15 +161,17 @@ pub async fn remove_controller(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<RemoveControllerInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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)
|
||||
@@ -185,19 +179,21 @@ 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,
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::GrantRevoked,
|
||||
Some(serde_json::json!({
|
||||
Some(json!({
|
||||
"revoked_app_passwords": revoked_app_passwords,
|
||||
"revoked_oauth_tokens": revoked_oauth_tokens
|
||||
})),
|
||||
@@ -206,18 +202,14 @@ pub async fn remove_controller(
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
|
||||
Ok(false) => Err(ApiError::DelegationNotFound),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove controller: {:?}", e);
|
||||
Ok(ApiError::InternalError(Some("Failed to remove controller".into())).into_response())
|
||||
Err(ApiError::InternalError(Some(
|
||||
"Failed to remove controller".into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,21 +224,23 @@ pub async fn update_controller_scopes(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<UpdateControllerScopesInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> 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,
|
||||
Some(&input.controller_did),
|
||||
DelegationActionType::ScopesModified,
|
||||
Some(serde_json::json!({
|
||||
Some(json!({
|
||||
"new_scopes": input.granted_scopes.as_str()
|
||||
})),
|
||||
None,
|
||||
@@ -254,21 +248,14 @@ pub async fn update_controller_scopes(
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(SuccessResponse { success: true }))
|
||||
}
|
||||
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
|
||||
Ok(false) => Err(ApiError::DelegationNotFound),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update controller scopes: {:?}", e);
|
||||
Ok(
|
||||
ApiError::InternalError(Some("Failed to update controller scopes".into()))
|
||||
.into_response(),
|
||||
)
|
||||
Err(ApiError::InternalError(Some(
|
||||
"Failed to update controller scopes".into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,23 +263,18 @@ pub async fn update_controller_scopes(
|
||||
pub async fn list_controlled_accounts(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let accounts = match state
|
||||
.delegation_repo
|
||||
) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> {
|
||||
let accounts = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_accounts_controlled_by(&auth.did)
|
||||
.await
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to list controlled accounts: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
};
|
||||
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "accounts": accounts })).into_response())
|
||||
Ok(Json(AccountsOutput { accounts }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -311,35 +293,35 @@ pub async fn get_audit_log(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Active>,
|
||||
Query(params): Query<AuditLogParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<AuditLogOutput<Vec<tranquil_db_traits::AuditLogEntry>>>, ApiError> {
|
||||
let limit = params.limit.clamp(1, 100);
|
||||
let offset = params.offset.max(0);
|
||||
|
||||
let entries = match state
|
||||
.delegation_repo
|
||||
let entries = state
|
||||
.repos
|
||||
.delegation
|
||||
.get_audit_log_for_account(&auth.did, limit, offset)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to get audit log: {:?}", e);
|
||||
return Ok(
|
||||
ApiError::InternalError(Some("Failed to get audit log".into())).into_response(),
|
||||
);
|
||||
}
|
||||
};
|
||||
ApiError::InternalError(Some("Failed to get audit log".into()))
|
||||
})?;
|
||||
|
||||
let total = state
|
||||
.delegation_repo
|
||||
.repos
|
||||
.delegation
|
||||
.count_audit_log_entries(&auth.did)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response())
|
||||
Ok(Json(AuditLogOutput { entries, total }))
|
||||
}
|
||||
|
||||
pub async fn get_scope_presets() -> Response {
|
||||
Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response()
|
||||
pub async fn get_scope_presets()
|
||||
-> Json<PresetsOutput<&'static [tranquil_pds::delegation::ScopePreset]>> {
|
||||
Json(PresetsOutput {
|
||||
presets: SCOPE_PRESETS,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -353,7 +335,7 @@ pub struct CreateDelegatedAccountInput {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDelegatedAccountResponse {
|
||||
pub struct CreateDelegatedAccountOutput {
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
}
|
||||
@@ -363,18 +345,11 @@ pub async fn create_delegated_account(
|
||||
_rate_limit: RateLimited<AccountCreationLimit>,
|
||||
auth: Auth<Active>,
|
||||
Json(input): Json<CreateDelegatedAccountInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let can_control = match verify_can_control_accounts(&state, &auth).await {
|
||||
Ok(proof) => proof,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
) -> Result<Json<CreateDelegatedAccountOutput>, ApiError> {
|
||||
let can_control = verify_can_control_accounts(&state, &auth).await?;
|
||||
|
||||
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
|
||||
}
|
||||
};
|
||||
let handle = tranquil_pds::api::validation::resolve_handle_input(&input.handle)
|
||||
.map_err(|e| ApiError::InvalidRequest(e.to_string()))?;
|
||||
|
||||
let email = input
|
||||
.email
|
||||
@@ -384,18 +359,18 @@ pub async fn create_delegated_account(
|
||||
if let Some(ref email) = email
|
||||
&& !tranquil_pds::api::validation::is_valid_email(email)
|
||||
{
|
||||
return Ok(ApiError::InvalidEmail.into_response());
|
||||
return Err(ApiError::InvalidEmail);
|
||||
}
|
||||
|
||||
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 Ok(ApiError::InvalidInviteCode.into_response()),
|
||||
Err(_) => return Err(ApiError::InvalidInviteCode),
|
||||
}
|
||||
} else {
|
||||
let invite_required = tranquil_config::get().server.invite_code_required;
|
||||
if invite_required {
|
||||
return Ok(ApiError::InviteCodeRequired.into_response());
|
||||
return Err(ApiError::InviteCodeRequired);
|
||||
}
|
||||
None
|
||||
};
|
||||
@@ -409,6 +384,7 @@ pub async fn create_delegated_account(
|
||||
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
|
||||
|
||||
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
|
||||
let repo_for_seq = repo.clone();
|
||||
|
||||
let create_input = tranquil_db_traits::CreateDelegatedAccountInput {
|
||||
handle: handle.clone(),
|
||||
@@ -425,65 +401,46 @@ pub async fn create_delegated_account(
|
||||
};
|
||||
|
||||
let user_id = match state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.create_delegated_account(&create_input)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
|
||||
return Ok(ApiError::HandleNotAvailable(None).into_response());
|
||||
return Err(ApiError::HandleNotAvailable(None));
|
||||
}
|
||||
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
|
||||
return Ok(ApiError::EmailTaken.into_response());
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error creating delegated account: {:?}", e);
|
||||
return Ok(ApiError::InternalError(None).into_response());
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(validated) = validated_invite_code
|
||||
&& let Err(e) = state
|
||||
.infra_repo
|
||||
.repos
|
||||
.infra
|
||||
.record_invite_code_use(&validated, user_id)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to record invite code use for {}: {:?}", did, e);
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||
crate::identity::provision::sequence_new_account(
|
||||
&state,
|
||||
&did,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
&handle,
|
||||
&repo_for_seq,
|
||||
handle.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = state
|
||||
.delegation_repo
|
||||
.repos
|
||||
.delegation
|
||||
.log_delegation_action(
|
||||
&did,
|
||||
&auth.did,
|
||||
@@ -500,7 +457,7 @@ pub async fn create_delegated_account(
|
||||
|
||||
info!(did = %did, handle = %handle, controller = %&auth.did, "Delegated account created");
|
||||
|
||||
Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response())
|
||||
Ok(Json(CreateDelegatedAccountOutput { did, handle }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -511,7 +468,7 @@ pub struct ResolveControllerParams {
|
||||
pub async fn resolve_controller(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ResolveControllerParams>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<tranquil_pds::delegation::ResolvedIdentity>, ApiError> {
|
||||
let identifier = params.identifier.trim().trim_start_matches('@');
|
||||
|
||||
let did: Did = if identifier.starts_with("did:") {
|
||||
@@ -521,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 {
|
||||
@@ -536,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).into_response())
|
||||
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,
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
use super::did::verify_did_web;
|
||||
use crate::common;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use k256::{SecretKey, ecdsa::SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, info};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
|
||||
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 {
|
||||
@@ -47,6 +44,145 @@ pub struct CreateAccountOutput {
|
||||
pub verification_channel: tranquil_db_traits::CommsChannel,
|
||||
}
|
||||
|
||||
async fn try_reactivate_migration(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
email: &Option<String>,
|
||||
verification_channel: tranquil_db_traits::CommsChannel,
|
||||
verification_recipient: Option<&str>,
|
||||
) -> Option<Response> {
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()),
|
||||
};
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Some(ApiError::InvalidHandle(None).into_response()),
|
||||
};
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: did_typed.clone(),
|
||||
new_handle: handle_typed.clone(),
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
.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
|
||||
.repos
|
||||
.user
|
||||
.get_user_key_by_id(reactivated.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(key_info)) => {
|
||||
match tranquil_pds::config::decrypt_key(
|
||||
&key_info.key_bytes,
|
||||
key_info.encryption_version,
|
||||
) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error decrypting key for reactivated account: {:?}", e);
|
||||
return Some(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("No signing key found for reactivated account");
|
||||
return Some(
|
||||
ApiError::InternalError(Some("Account signing key not found".into()))
|
||||
.into_response(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let access_meta =
|
||||
match tranquil_pds::auth::create_access_token_with_metadata(did, &secret_key_bytes)
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating access token: {:?}", e);
|
||||
return Some(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
};
|
||||
let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata(
|
||||
did,
|
||||
&secret_key_bytes,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating refresh token: {:?}", e);
|
||||
return Some(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
if let Err(e) = state.repos.session.create_session(&session_data).await {
|
||||
error!("Error creating session: {:?}", e);
|
||||
return Some(ApiError::InternalError(None).into_response());
|
||||
}
|
||||
let verification_required = match verification_recipient {
|
||||
Some(recipient) => {
|
||||
super::provision::enqueue_migration_verification(
|
||||
state,
|
||||
reactivated.user_id,
|
||||
&did_typed,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
.await;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
Some(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.to_string().into(),
|
||||
did: did_typed.clone(),
|
||||
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,
|
||||
verification_channel,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
Err(tranquil_db_traits::MigrationReactivationError::NotFound) => None,
|
||||
Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => {
|
||||
Some(ApiError::AccountAlreadyExists.into_response())
|
||||
}
|
||||
Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => {
|
||||
Some(ApiError::HandleTaken.into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reactivating migration account: {:?}", e);
|
||||
Some(ApiError::InternalError(None).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: RateLimited<AccountCreationLimit>,
|
||||
@@ -153,82 +289,38 @@ pub async fn create_account(
|
||||
let verification_channel = input
|
||||
.verification_channel
|
||||
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
|
||||
let verification_recipient = if is_migration {
|
||||
None
|
||||
} else {
|
||||
Some(match verification_channel {
|
||||
tranquil_db_traits::CommsChannel::Email => match &input.email {
|
||||
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
let verification_recipient = {
|
||||
Some(
|
||||
match common::extract_verification_recipient(
|
||||
verification_channel,
|
||||
&common::ChannelInput {
|
||||
email: input.email.as_deref(),
|
||||
discord_username: input.discord_username.as_deref(),
|
||||
telegram_username: input.telegram_username.as_deref(),
|
||||
signal_username: input.signal_username.as_deref(),
|
||||
},
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e.into_response(),
|
||||
},
|
||||
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean
|
||||
}
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
).into_response();
|
||||
}
|
||||
clean.to_string()
|
||||
}
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
username.trim().trim_start_matches('@').to_lowercase()
|
||||
}
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
})
|
||||
)
|
||||
};
|
||||
let hostname = &cfg.server.hostname;
|
||||
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
|
||||
if let Some(signing_key_did) = &input.signing_key {
|
||||
match state
|
||||
.infra_repo
|
||||
.get_reserved_signing_key(signing_key_did)
|
||||
.await
|
||||
{
|
||||
Ok(Some(key)) => (key.private_key_bytes, Some(key.id)),
|
||||
Ok(None) => {
|
||||
return ApiError::InvalidSigningKey.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error looking up reserved signing key: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let secret_key = SecretKey::random(&mut OsRng);
|
||||
(secret_key.to_bytes().to_vec(), None)
|
||||
let key_result =
|
||||
match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await {
|
||||
Ok(k) => k,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
let signing_key = match SigningKey::from_slice(&secret_key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error creating signing key: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let secret_key_bytes = key_result.secret_key_bytes;
|
||||
let signing_key = key_result.signing_key;
|
||||
let reserved_key_id = key_result.reserved_key_id;
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
|
||||
return ApiError::SelfHostedDidWebDisabled.into_response();
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_handle);
|
||||
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
|
||||
self_hosted_did
|
||||
}
|
||||
@@ -289,137 +381,18 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
};
|
||||
if is_migration {
|
||||
let did_typed: Did = match did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
|
||||
};
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => return ApiError::InvalidHandle(None).into_response(),
|
||||
};
|
||||
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
|
||||
did: did_typed.clone(),
|
||||
new_handle: handle_typed.clone(),
|
||||
new_email: email.clone(),
|
||||
};
|
||||
match state
|
||||
.user_repo
|
||||
.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
|
||||
.get_user_key_by_id(reactivated.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(key_info)) => {
|
||||
match tranquil_pds::config::decrypt_key(
|
||||
&key_info.key_bytes,
|
||||
key_info.encryption_version,
|
||||
) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error decrypting key for reactivated account: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
error!("No signing key found for reactivated account");
|
||||
return ApiError::InternalError(Some(
|
||||
"Account signing key not found".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let access_meta = match tranquil_pds::auth::create_access_token_with_metadata(
|
||||
&did,
|
||||
&secret_key_bytes,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating access token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata(
|
||||
&did,
|
||||
&secret_key_bytes,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating refresh token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_typed.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
};
|
||||
if let Err(e) = state.session_repo.create_session(&session_data).await {
|
||||
error!("Error creating session: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
let verification_required = if let Some(ref user_email) = email {
|
||||
let token = tranquil_pds::auth::verification_token::generate_migration_token(
|
||||
&did_typed, user_email,
|
||||
);
|
||||
let formatted_token =
|
||||
tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
reactivated.user_id,
|
||||
user_email,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue migration verification email: {:?}", e);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
return (
|
||||
axum::http::StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: did_typed.clone(),
|
||||
did_doc: state.did_resolver.resolve_did_document(&did).await,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required,
|
||||
verification_channel: tranquil_db_traits::CommsChannel::Email,
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(tranquil_db_traits::MigrationReactivationError::NotFound) => {}
|
||||
Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => {
|
||||
return ApiError::AccountAlreadyExists.into_response();
|
||||
}
|
||||
Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => {
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reactivating migration account: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
if is_migration
|
||||
&& let Some(response) = try_reactivate_migration(
|
||||
&state,
|
||||
&did,
|
||||
&handle,
|
||||
&email,
|
||||
verification_channel,
|
||||
verification_recipient.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
let handle_typed: Handle = match handle.parse() {
|
||||
@@ -427,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
|
||||
{
|
||||
@@ -442,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() {
|
||||
@@ -463,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);
|
||||
@@ -480,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 +492,13 @@ pub async fn create_account(
|
||||
None
|
||||
};
|
||||
|
||||
let comms = super::provision::normalize_comms_usernames(
|
||||
input.discord_username.as_deref(),
|
||||
input.telegram_username.as_deref(),
|
||||
input.signal_username.as_deref(),
|
||||
);
|
||||
let preferred_comms_channel = verification_channel;
|
||||
let repo_for_seq = repo.clone();
|
||||
|
||||
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
|
||||
handle: handle_typed.clone(),
|
||||
@@ -535,23 +506,9 @@ pub async fn create_account(
|
||||
did: did_for_commit.clone(),
|
||||
password_hash,
|
||||
preferred_comms_channel,
|
||||
discord_username: input
|
||||
.discord_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
telegram_username: input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal_username: input
|
||||
.signal_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_lowercase()),
|
||||
discord_username: comms.discord,
|
||||
telegram_username: comms.telegram,
|
||||
signal_username: comms.signal,
|
||||
deactivated_at,
|
||||
encrypted_key_bytes: repo.encrypted_key_bytes,
|
||||
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
|
||||
@@ -567,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();
|
||||
@@ -585,144 +547,52 @@ pub async fn create_account(
|
||||
};
|
||||
let user_id = create_result.user_id;
|
||||
if !is_migration && !is_did_web_byod {
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(
|
||||
super::provision::sequence_new_account(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
Some(&handle_typed),
|
||||
&handle_typed,
|
||||
&repo_for_seq,
|
||||
&input.handle,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&repo.commit_cid,
|
||||
&repo.mst_root_cid,
|
||||
&rev_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence commit event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&commit_cid_str,
|
||||
Some(&rev_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence sync event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": input.handle
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
&state,
|
||||
&did_for_commit,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
.await;
|
||||
}
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient {
|
||||
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
|
||||
super::provision::enqueue_signup_verification(
|
||||
&state,
|
||||
user_id,
|
||||
&did_for_commit,
|
||||
verification_channel,
|
||||
recipient,
|
||||
);
|
||||
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(
|
||||
&verification_token,
|
||||
);
|
||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
user_id,
|
||||
verification_channel,
|
||||
recipient,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to enqueue signup verification notification: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
.await;
|
||||
}
|
||||
} else if let Some(ref user_email) = email {
|
||||
let token = tranquil_pds::auth::verification_token::generate_migration_token(
|
||||
&did_for_commit,
|
||||
user_email,
|
||||
);
|
||||
let formatted_token =
|
||||
tranquil_pds::auth::verification_token::format_token_for_display(&token);
|
||||
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
|
||||
state.user_repo.as_ref(),
|
||||
state.infra_repo.as_ref(),
|
||||
} else if let Some(ref recipient) = verification_recipient {
|
||||
super::provision::enqueue_migration_verification(
|
||||
&state,
|
||||
user_id,
|
||||
user_email,
|
||||
&formatted_token,
|
||||
hostname,
|
||||
&did_for_commit,
|
||||
verification_channel,
|
||||
recipient,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue migration verification email: {:?}", e);
|
||||
}
|
||||
.await;
|
||||
}
|
||||
|
||||
let access_meta =
|
||||
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("createAccount: Error creating access token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta =
|
||||
match tranquil_pds::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("createAccount: Error creating refresh token: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did_for_commit.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: Some("transition:generic transition:chat.bsky".to_string()),
|
||||
controller_did: None,
|
||||
app_password_name: None,
|
||||
let session = match super::provision::create_and_store_session(
|
||||
&state,
|
||||
&did,
|
||||
&did_for_commit,
|
||||
&secret_key_bytes,
|
||||
"transition:generic transition:chat.bsky",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
if let Err(e) = state.session_repo.create_session(&session_data).await {
|
||||
error!("createAccount: Error creating session: {:?}", e);
|
||||
return ApiError::InternalError(None).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!(
|
||||
@@ -736,9 +606,9 @@ pub async fn create_account(
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: did_for_commit,
|
||||
did_doc,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
did_doc: did_doc.map(|f| (*f).clone()),
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
verification_required: !is_migration,
|
||||
verification_channel,
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::common;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
@@ -10,6 +11,7 @@ use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::api::{ApiError, DidResponse, EmptyResponse};
|
||||
use tranquil_pds::auth::{Auth, NotTakendown};
|
||||
use tranquil_pds::plc::signing_key_to_did_key;
|
||||
@@ -52,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
|
||||
@@ -163,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
|
||||
{
|
||||
@@ -180,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()
|
||||
@@ -188,91 +192,20 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
|
||||
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
|
||||
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| (ovr, p))
|
||||
}) {
|
||||
let also_known_as = if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
};
|
||||
|
||||
return Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": parsed.iter().map(|m| json!({
|
||||
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
|
||||
"type": m.method_type,
|
||||
"controller": did,
|
||||
"publicKeyMultibase": m.public_key_multibase
|
||||
})).collect::<Vec<_>>(),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let key_info = match state.user_repo.get_user_key_by_id(user_id).await {
|
||||
Ok(Some(k)) => k,
|
||||
Ok(None) => return ApiError::InternalError(None).into_response(),
|
||||
Err(_) => return ApiError::InternalError(None).into_response(),
|
||||
};
|
||||
let key_bytes: Vec<u8> =
|
||||
match tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let verification_methods =
|
||||
build_override_or_key_verification_methods(state, user_id, &did, overrides.as_ref()).await;
|
||||
let verification_methods = match verification_methods {
|
||||
Ok(vm) => vm,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let also_known_as = if let Some(ref ovr) = overrides {
|
||||
if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
}
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
let also_known_as = common::resolve_also_known_as(overrides.as_ref(), ¤t_handle);
|
||||
Json(common::build_did_document(
|
||||
&did,
|
||||
also_known_as,
|
||||
verification_methods,
|
||||
&service_endpoint,
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -287,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
|
||||
{
|
||||
@@ -315,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()
|
||||
@@ -323,92 +258,65 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
|
||||
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
|
||||
let verification_methods =
|
||||
build_override_or_key_verification_methods(&state, user_id, &did, overrides.as_ref()).await;
|
||||
let verification_methods = match verification_methods {
|
||||
Ok(vm) => vm,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
let also_known_as = common::resolve_also_known_as(overrides.as_ref(), ¤t_handle);
|
||||
Json(common::build_did_document(
|
||||
&did,
|
||||
also_known_as,
|
||||
verification_methods,
|
||||
&service_endpoint,
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn build_override_or_key_verification_methods(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &str,
|
||||
overrides: Option<&tranquil_db_traits::DidWebOverrides>,
|
||||
) -> Result<Vec<serde_json::Value>, Response> {
|
||||
if let Some(parsed) = overrides.and_then(|ovr| {
|
||||
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| (ovr, p))
|
||||
}) {
|
||||
let also_known_as = if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
};
|
||||
|
||||
return Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": parsed.iter().map(|m| json!({
|
||||
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
|
||||
"type": m.method_type,
|
||||
"controller": did,
|
||||
"publicKeyMultibase": m.public_key_multibase
|
||||
})).collect::<Vec<_>>(),
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
.into_response();
|
||||
return Ok(parsed
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
|
||||
"type": m.method_type,
|
||||
"controller": did,
|
||||
"publicKeyMultibase": m.public_key_multibase
|
||||
})
|
||||
})
|
||||
.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,
|
||||
Ok(None) => return ApiError::InternalError(None).into_response(),
|
||||
Err(_) => return ApiError::InternalError(None).into_response(),
|
||||
};
|
||||
let key_bytes: Vec<u8> =
|
||||
match tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
_ => return Err(ApiError::InternalError(None).into_response()),
|
||||
};
|
||||
let key_bytes =
|
||||
tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version)
|
||||
.map_err(|_| ApiError::InternalError(None).into_response())?;
|
||||
let public_key_multibase = get_public_key_multibase(&key_bytes).map_err(|e| {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
ApiError::InternalError(None).into_response()
|
||||
})?;
|
||||
|
||||
let also_known_as = if let Some(ref ovr) = overrides {
|
||||
if !ovr.also_known_as.is_empty() {
|
||||
ovr.also_known_as.clone()
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
}
|
||||
} else {
|
||||
vec![format!("at://{}", current_handle)]
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
.into_response()
|
||||
Ok(vec![json!({
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
})])
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -562,12 +470,13 @@ pub struct AtprotoPds {
|
||||
pub async fn get_recommended_did_credentials(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
) -> Result<Response, ApiError> {
|
||||
) -> Result<Json<GetRecommendedDidCredentialsOutput>, ApiError> {
|
||||
let handle = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_handle_by_did(&auth.did)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(None))?
|
||||
.log_db_err("fetching handle for DID credentials")?
|
||||
.ok_or(ApiError::InternalError(None))?;
|
||||
|
||||
let key_bytes = auth.key_bytes.clone().ok_or_else(|| {
|
||||
@@ -593,21 +502,17 @@ pub async fn get_recommended_did_credentials(
|
||||
};
|
||||
vec![server_rotation_key]
|
||||
};
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(GetRecommendedDidCredentialsOutput {
|
||||
rotation_keys,
|
||||
also_known_as: vec![format!("at://{}", handle)],
|
||||
verification_methods: VerificationMethods { atproto: did_key },
|
||||
services: Services {
|
||||
atproto_pds: AtprotoPds {
|
||||
service_type: tranquil_pds::plc::ServiceType::Pds.as_str().to_string(),
|
||||
endpoint: pds_endpoint,
|
||||
},
|
||||
Ok(Json(GetRecommendedDidCredentialsOutput {
|
||||
rotation_keys,
|
||||
also_known_as: vec![format!("at://{}", handle)],
|
||||
verification_methods: VerificationMethods { atproto: did_key },
|
||||
services: Services {
|
||||
atproto_pds: AtprotoPds {
|
||||
service_type: tranquil_pds::plc::ServiceType::Pds.as_str().to_string(),
|
||||
endpoint: pds_endpoint,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -619,14 +524,12 @@ pub async fn update_handle(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<NotTakendown>,
|
||||
Json(input): Json<UpdateHandleInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::IdentityAttr::Handle,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
)?;
|
||||
let did = auth.did.clone();
|
||||
let _rate_limit = check_user_rate_limit_with_message::<HandleUpdateLimit>(
|
||||
&state,
|
||||
@@ -641,10 +544,11 @@ pub async fn update_handle(
|
||||
)
|
||||
.await?;
|
||||
let user_row = state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.get_id_and_handle_by_did(&did)
|
||||
.await
|
||||
.map_err(|_| ApiError::InternalError(None))?
|
||||
.log_db_err("fetching user for handle update")?
|
||||
.ok_or(ApiError::InternalError(None))?;
|
||||
let user_id = user_row.id;
|
||||
let current_handle = user_row.handle;
|
||||
@@ -710,7 +614,7 @@ pub async fn update_handle(
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return Ok(EmptyResponse::ok().into_response());
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
if short_part.contains('.') {
|
||||
return Err(ApiError::InvalidHandle(Some(
|
||||
@@ -736,7 +640,7 @@ pub async fn update_handle(
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return Ok(EmptyResponse::ok().into_response());
|
||||
return Ok(Json(EmptyResponse {}));
|
||||
}
|
||||
match tranquil_pds::handle::verify_handle_ownership(&new_handle, &did).await {
|
||||
Ok(()) => {}
|
||||
@@ -763,15 +667,17 @@ 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
|
||||
.map_err(|_| ApiError::InternalError(None))?;
|
||||
.log_db_err("checking handle existence")?;
|
||||
if handle_exists {
|
||||
return Err(ApiError::HandleTaken);
|
||||
}
|
||||
state
|
||||
.user_repo
|
||||
.repos
|
||||
.user
|
||||
.update_handle(user_id, &handle_typed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -797,7 +703,7 @@ pub async fn update_handle(
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
|
||||
warn!("Failed to update PLC handle: {}", e);
|
||||
}
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
pub async fn update_plc_handle(
|
||||
@@ -808,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(()),
|
||||
};
|
||||
@@ -835,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(),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::{Duration, Utc};
|
||||
use tracing::{info, warn};
|
||||
use tranquil_pds::api::EmptyResponse;
|
||||
@@ -16,34 +13,34 @@ fn generate_plc_token() -> String {
|
||||
pub async fn request_plc_operation_signature(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
)?;
|
||||
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,
|
||||
@@ -53,5 +50,5 @@ pub async fn request_plc_operation_signature(
|
||||
warn!("Failed to enqueue PLC operation notification: {:?}", e);
|
||||
}
|
||||
info!("PLC operation signature requested for user {}", auth.did);
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::Utc;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -43,14 +38,12 @@ pub async fn sign_plc_operation(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
Json(input): Json<SignPlcOperationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
) -> Result<Json<SignPlcOperationOutput>, ApiError> {
|
||||
tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
)?;
|
||||
let did = &auth.did;
|
||||
if did.starts_with("did:web:") {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
@@ -62,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")?
|
||||
@@ -143,13 +139,9 @@ 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((
|
||||
StatusCode::OK,
|
||||
Json(SignPlcOperationOutput {
|
||||
operation: signed_op,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
Ok(Json(SignPlcOperationOutput {
|
||||
operation: signed_op,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -23,14 +19,12 @@ pub async fn submit_plc_operation(
|
||||
State(state): State<AppState>,
|
||||
auth: Auth<Permissive>,
|
||||
Json(input): Json<SubmitPlcOperationInput>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
) -> Result<Json<EmptyResponse>, ApiError> {
|
||||
tranquil_pds::auth::scope_check::check_identity_scope(
|
||||
&auth.auth_source,
|
||||
auth.scope.as_deref(),
|
||||
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
|
||||
) {
|
||||
return Ok(e);
|
||||
}
|
||||
)?;
|
||||
let did = &auth.did;
|
||||
if did.starts_with("did:web:") {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
@@ -44,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")?
|
||||
@@ -76,7 +72,7 @@ pub async fn submit_plc_operation(
|
||||
.plc_rotation_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| user_did_key.clone());
|
||||
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
|
||||
if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) {
|
||||
let has_server_key = rotation_keys
|
||||
.iter()
|
||||
.any(|k| k.as_str() == Some(&server_rotation_key));
|
||||
@@ -86,11 +82,11 @@ pub async fn submit_plc_operation(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(services) = op.get("services").and_then(|v| v.as_object())
|
||||
&& let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object())
|
||||
if let Some(services) = op.get("services").and_then(Value::as_object)
|
||||
&& let Some(pds) = services.get("atproto_pds").and_then(Value::as_object)
|
||||
{
|
||||
let service_type = pds.get("type").and_then(|v| v.as_str());
|
||||
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
|
||||
let service_type = pds.get("type").and_then(Value::as_str);
|
||||
let endpoint = pds.get("endpoint").and_then(Value::as_str);
|
||||
if service_type != Some(tranquil_pds::plc::ServiceType::Pds.as_str()) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Incorrect type on atproto_pds service".into(),
|
||||
@@ -102,8 +98,8 @@ pub async fn submit_plc_operation(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
|
||||
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
|
||||
if let Some(verification_methods) = op.get("verificationMethods").and_then(Value::as_object)
|
||||
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(Value::as_str)
|
||||
&& atproto_key != user_did_key
|
||||
{
|
||||
return Err(ApiError::InvalidRequest(
|
||||
@@ -111,11 +107,11 @@ pub async fn submit_plc_operation(
|
||||
));
|
||||
}
|
||||
if let Some(also_known_as) = (!user.handle.is_empty())
|
||||
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
|
||||
.then(|| op.get("alsoKnownAs").and_then(Value::as_array))
|
||||
.flatten()
|
||||
{
|
||||
let expected_handle = format!("at://{}", user.handle);
|
||||
let first_aka = also_known_as.first().and_then(|v| v.as_str());
|
||||
let first_aka = also_known_as.first().and_then(Value::as_str);
|
||||
if first_aka != Some(&expected_handle) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Incorrect handle in alsoKnownAs".into(),
|
||||
@@ -134,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);
|
||||
}
|
||||
}
|
||||
@@ -159,9 +156,9 @@ 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");
|
||||
Ok(EmptyResponse::ok().into_response())
|
||||
Ok(Json(EmptyResponse {}))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ use jacquard_common::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use std::sync::Arc;
|
||||
use tranquil_db_traits::CommsChannel;
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::repo_ops::create_signed_commit;
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::Did;
|
||||
use tranquil_pds::types::{Did, Handle};
|
||||
|
||||
pub struct PlcDidResult {
|
||||
pub did: Did,
|
||||
@@ -74,6 +75,7 @@ pub async fn submit_plc_genesis(
|
||||
Ok(genesis_result.did)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GenesisRepo {
|
||||
pub encrypted_key_bytes: Vec<u8>,
|
||||
pub commit_cid: cid::Cid,
|
||||
@@ -120,3 +122,242 @@ pub async fn init_genesis_repo(
|
||||
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
|
||||
})
|
||||
}
|
||||
|
||||
pub struct SigningKeyResult {
|
||||
pub secret_key_bytes: Vec<u8>,
|
||||
pub signing_key: SigningKey,
|
||||
pub reserved_key_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
pub async fn resolve_signing_key(
|
||||
state: &AppState,
|
||||
signing_key_did: Option<&str>,
|
||||
) -> Result<SigningKeyResult, ApiError> {
|
||||
match signing_key_did {
|
||||
Some(key_did) => {
|
||||
let key = state
|
||||
.repos
|
||||
.infra
|
||||
.get_reserved_signing_key(key_did)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error looking up reserved signing key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?
|
||||
.ok_or(ApiError::InvalidSigningKey)?;
|
||||
let signing_key = SigningKey::from_slice(&key.private_key_bytes).map_err(|e| {
|
||||
tracing::error!("Error creating signing key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
Ok(SigningKeyResult {
|
||||
secret_key_bytes: key.private_key_bytes,
|
||||
signing_key,
|
||||
reserved_key_id: Some(key.id),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
use k256::SecretKey;
|
||||
use rand::rngs::OsRng;
|
||||
let secret_key = SecretKey::random(&mut OsRng);
|
||||
let secret_key_bytes = secret_key.to_bytes().to_vec();
|
||||
let signing_key = SigningKey::from_slice(&secret_key_bytes).map_err(|e| {
|
||||
tracing::error!("Error creating signing key: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
Ok(SigningKeyResult {
|
||||
secret_key_bytes,
|
||||
signing_key,
|
||||
reserved_key_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sequence_new_account(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
handle: &Handle,
|
||||
repo: &GenesisRepo,
|
||||
display_name: &str,
|
||||
) {
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(state, did, Some(handle)).await
|
||||
{
|
||||
tracing::warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
|
||||
state,
|
||||
did,
|
||||
tranquil_db_traits::AccountStatus::Active,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit(
|
||||
state,
|
||||
did,
|
||||
&repo.commit_cid,
|
||||
&repo.mst_root_cid,
|
||||
&repo.repo_rev,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to sequence commit event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
|
||||
state,
|
||||
did,
|
||||
&repo.commit_cid.to_string(),
|
||||
Some(&repo.repo_rev),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": display_name
|
||||
});
|
||||
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
|
||||
state,
|
||||
did,
|
||||
&tranquil_pds::types::PROFILE_COLLECTION,
|
||||
&tranquil_pds::types::PROFILE_RKEY,
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommsUsernames {
|
||||
pub discord: Option<String>,
|
||||
pub telegram: Option<String>,
|
||||
pub signal: Option<String>,
|
||||
}
|
||||
|
||||
pub fn normalize_comms_usernames(
|
||||
discord: Option<&str>,
|
||||
telegram: Option<&str>,
|
||||
signal: Option<&str>,
|
||||
) -> CommsUsernames {
|
||||
CommsUsernames {
|
||||
discord: discord
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty()),
|
||||
telegram: telegram
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
signal: signal
|
||||
.map(|s| s.trim().trim_start_matches('@'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionResult {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
pub async fn create_and_store_session(
|
||||
state: &AppState,
|
||||
did_str: &str,
|
||||
did: &Did,
|
||||
signing_key_bytes: &[u8],
|
||||
scope: &str,
|
||||
controller_did: Option<&Did>,
|
||||
) -> Result<SessionResult, ApiError> {
|
||||
let access_meta =
|
||||
tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err(
|
||||
|e| {
|
||||
tracing::error!("Error creating access token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
},
|
||||
)?;
|
||||
let refresh_meta =
|
||||
tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating refresh token: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let session_data = tranquil_db_traits::SessionTokenCreate {
|
||||
did: did.clone(),
|
||||
access_jti: access_meta.jti.clone(),
|
||||
refresh_jti: refresh_meta.jti.clone(),
|
||||
access_expires_at: access_meta.expires_at,
|
||||
refresh_expires_at: refresh_meta.expires_at,
|
||||
login_type: tranquil_db_traits::LoginType::Modern,
|
||||
mfa_verified: false,
|
||||
scope: Some(scope.to_string()),
|
||||
controller_did: controller_did.cloned(),
|
||||
app_password_name: None,
|
||||
};
|
||||
state
|
||||
.repos
|
||||
.session
|
||||
.create_session(&session_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating session: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
Ok(SessionResult {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn enqueue_signup_verification(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
recipient: &str,
|
||||
) {
|
||||
let token =
|
||||
tranquil_pds::auth::verification_token::generate_signup_token(did, channel, recipient);
|
||||
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.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
channel,
|
||||
recipient,
|
||||
&formatted,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to enqueue signup verification: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_migration_verification(
|
||||
state: &AppState,
|
||||
user_id: uuid::Uuid,
|
||||
did: &Did,
|
||||
channel: CommsChannel,
|
||||
recipient: &str,
|
||||
) {
|
||||
let token =
|
||||
tranquil_pds::auth::verification_token::generate_migration_token(did, channel, recipient);
|
||||
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.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user_id,
|
||||
channel,
|
||||
recipient,
|
||||
&formatted,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to enqueue migration verification: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod actor;
|
||||
pub mod admin;
|
||||
pub mod age_assurance;
|
||||
pub mod common;
|
||||
pub mod delegation;
|
||||
pub mod discord_webhook;
|
||||
pub mod identity;
|
||||
@@ -10,7 +11,6 @@ pub mod repo;
|
||||
pub mod server;
|
||||
pub mod telegram_webhook;
|
||||
pub mod temp;
|
||||
pub mod verification;
|
||||
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
@@ -215,10 +215,6 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
"/_account.checkEmailInUse",
|
||||
post(server::check_email_in_use),
|
||||
)
|
||||
.route(
|
||||
"/_account.checkCommsChannelInUse",
|
||||
post(server::check_comms_channel_in_use),
|
||||
)
|
||||
.route(
|
||||
"/com.atproto.server.reserveSigningKey",
|
||||
post(server::reserve_signing_key),
|
||||
@@ -334,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",
|
||||
@@ -390,7 +392,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/_account.confirmChannelVerification",
|
||||
post(verification::confirm_channel_verification),
|
||||
post(server::confirm_channel_verification),
|
||||
)
|
||||
.route("/_account.verifyToken", post(server::verify_token))
|
||||
.route(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user