[Fix] Connection Management
This commit is contained in:
parent
9e9e3597da
commit
3f2ac18333
122 changed files with 19970 additions and 5263 deletions
|
|
@ -1,2 +1,5 @@
|
||||||
[env]
|
[env]
|
||||||
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
||||||
|
|
||||||
|
[net]
|
||||||
|
git-fetch-with-cli = true
|
||||||
|
|
|
||||||
234
Cargo.lock
generated
234
Cargo.lock
generated
|
|
@ -4,11 +4,11 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-codec"
|
name = "actix-codec"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e"
|
checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
|
@ -30,7 +30,7 @@ dependencies = [
|
||||||
"actix-tls",
|
"actix-tls",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"brotli",
|
"brotli",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytestring",
|
"bytestring",
|
||||||
|
|
@ -85,9 +85,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-rt"
|
name = "actix-rt"
|
||||||
version = "2.13.0"
|
version = "2.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1"
|
checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -95,13 +95,12 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-server"
|
name = "actix-server"
|
||||||
version = "2.9.1"
|
version = "2.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9"
|
checksum = "aec21d555b23ee78a1c0e4ed61667935de7d61664caba878226a081b1ac20a74"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"mio",
|
"mio",
|
||||||
|
|
@ -122,15 +121,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-tls"
|
name = "actix-tls"
|
||||||
version = "3.5.0"
|
version = "3.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f"
|
checksum = "21d0a5de50893252dfa9d7c4bf83761bcc1920651916cd9da24c974cf8ee1b92"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"impl-more 0.1.9",
|
"impl-more",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -174,7 +173,7 @@ dependencies = [
|
||||||
"foldhash",
|
"foldhash",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"impl-more 0.3.7",
|
"impl-more",
|
||||||
"itoa",
|
"itoa",
|
||||||
"language-tags",
|
"language-tags",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -511,9 +510,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blake2"
|
name = "blake2"
|
||||||
|
|
@ -622,9 +621,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.5"
|
version = "1.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
|
|
@ -747,14 +746,21 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"iota-auth",
|
||||||
"iota-connection",
|
"iota-connection",
|
||||||
|
"iota-identity",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"mtp",
|
"mtp",
|
||||||
|
"other-iota",
|
||||||
|
"rcgen",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"web-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -907,9 +913,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.1"
|
version = "1.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
@ -932,10 +938,11 @@ version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"document-features",
|
"document-features",
|
||||||
|
"futures-core",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
|
|
@ -1284,9 +1291,9 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.40"
|
version = "0.8.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e"
|
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"core_detect",
|
"core_detect",
|
||||||
|
|
@ -1828,9 +1835,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"typenum",
|
"typenum",
|
||||||
|
|
@ -2032,12 +2039,6 @@ dependencies = [
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "impl-more"
|
|
||||||
version = "0.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impl-more"
|
name = "impl-more"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
|
|
@ -2101,13 +2102,20 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iota-auth"
|
name = "iota-auth"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"json",
|
"async-trait",
|
||||||
|
"dashmap",
|
||||||
|
"iota-identity",
|
||||||
|
"mtp",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2122,6 +2130,7 @@ dependencies = [
|
||||||
"iota-terms",
|
"iota-terms",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
|
"ratatool",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|
@ -2135,9 +2144,16 @@ dependencies = [
|
||||||
name = "iota-connection"
|
name = "iota-connection"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"iota-auth",
|
||||||
|
"iota-identity",
|
||||||
|
"iota-logger",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"mtp",
|
"mtp",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2162,7 +2178,10 @@ dependencies = [
|
||||||
name = "iota-daemon"
|
name = "iota-daemon"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"client",
|
||||||
|
"iota-connection",
|
||||||
"iota-daemon-lib",
|
"iota-daemon-lib",
|
||||||
|
"iota-identity",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
|
|
@ -2170,6 +2189,7 @@ dependencies = [
|
||||||
"iota-terms",
|
"iota-terms",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
|
"other-iota",
|
||||||
"tokio",
|
"tokio",
|
||||||
"web-server",
|
"web-server",
|
||||||
]
|
]
|
||||||
|
|
@ -2179,6 +2199,9 @@ name = "iota-daemon-lib"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"iota-auth",
|
||||||
|
"iota-connection",
|
||||||
|
"iota-identity",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
|
|
@ -2188,6 +2211,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"mtp",
|
"mtp",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
|
"other-iota",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
|
@ -2197,6 +2221,17 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iota-identity"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"mtp",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iota-installer"
|
name = "iota-installer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -2261,17 +2296,22 @@ name = "iota-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"iota-identity",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"json",
|
"json",
|
||||||
|
"mtp",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
@ -2311,6 +2351,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"hex",
|
"hex",
|
||||||
|
"iota-identity",
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
"mtp",
|
"mtp",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -2535,7 +2576,7 @@ version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2734,7 +2775,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp"
|
name = "mtp"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-client",
|
"mtp-client",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
|
|
@ -2751,7 +2792,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-client"
|
name = "mtp-client"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
|
|
@ -2764,7 +2805,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-codec"
|
name = "mtp-codec"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
@ -2778,7 +2819,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-common"
|
name = "mtp-common"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|
@ -2787,7 +2828,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-core"
|
name = "mtp-core"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
|
|
@ -2799,7 +2840,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-crypto"
|
name = "mtp-crypto"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2822,7 +2863,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-files"
|
name = "mtp-files"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
|
@ -2833,7 +2874,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-h3"
|
name = "mtp-h3"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2850,7 +2891,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-host"
|
name = "mtp-host"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
|
|
@ -2867,7 +2908,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-native"
|
name = "mtp-native"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
|
|
@ -2882,13 +2923,14 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wtransport",
|
"wtransport",
|
||||||
|
"x509-parser",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-type-map"
|
name = "mtp-type-map"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|
@ -2897,7 +2939,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-webserver"
|
name = "mtp-webserver"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"h3",
|
"h3",
|
||||||
|
|
@ -2925,24 +2967,23 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multiversion"
|
name = "multiversion"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
|
checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"multiversion-macros",
|
"multiversion-macros",
|
||||||
"target-features",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multiversion-macros"
|
name = "multiversion-macros"
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
|
checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"rustversion",
|
||||||
"target-features",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2957,7 +2998,7 @@ version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -3049,7 +3090,7 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3084,7 +3125,9 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"iota-auth",
|
||||||
"iota-connection",
|
"iota-connection",
|
||||||
|
"iota-identity",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
|
|
@ -3118,9 +3161,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.4.3"
|
version = "5.4.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
|
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -3144,6 +3187,20 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "other-iota"
|
name = "other-iota"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"iota-connection",
|
||||||
|
"iota-identity",
|
||||||
|
"iota-storage",
|
||||||
|
"iota-util",
|
||||||
|
"mtp",
|
||||||
|
"rcgen",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"web-server",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "palette"
|
name = "palette"
|
||||||
|
|
@ -3678,6 +3735,19 @@ dependencies = [
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatool"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/ratatool.git#f94b8a25ffc5c196db2852ff576593df5ad96938"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"futures-util",
|
||||||
|
"ratatui",
|
||||||
|
"tokio",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.30.2"
|
version = "0.30.2"
|
||||||
|
|
@ -3700,7 +3770,7 @@ version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
"critical-section",
|
"critical-section",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
|
|
@ -3765,7 +3835,7 @@ version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"indoc",
|
"indoc",
|
||||||
"instability",
|
"instability",
|
||||||
|
|
@ -3800,7 +3870,7 @@ version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3840,11 +3910,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|
@ -3864,6 +3934,8 @@ dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
|
@ -3906,7 +3978,7 @@ version = "0.40.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
|
|
@ -3945,7 +4017,7 @@ version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
|
|
@ -4089,7 +4161,7 @@ version = "3.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -4337,9 +4409,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.16.0"
|
version = "1.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
|
|
@ -4507,7 +4579,7 @@ version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"system-configuration-sys",
|
"system-configuration-sys",
|
||||||
]
|
]
|
||||||
|
|
@ -4522,12 +4594,6 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "target-features"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
|
|
@ -4547,7 +4613,7 @@ version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
|
|
@ -4583,7 +4649,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"fancy-regex",
|
"fancy-regex",
|
||||||
"filedescriptor",
|
"filedescriptor",
|
||||||
"finl_unicode",
|
"finl_unicode",
|
||||||
|
|
@ -4799,7 +4865,7 @@ version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.2",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
|
|
@ -4962,9 +5028,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.26.0"
|
version = "1.26.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic",
|
"atomic",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
|
|
@ -5086,10 +5152,14 @@ dependencies = [
|
||||||
name = "web-server"
|
name = "web-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
|
"iota-identity",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"mtp",
|
"mtp",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
@ -5561,18 +5631,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
|
"iota-identity",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"iota-connection",
|
"iota-connection",
|
||||||
"client",
|
"client",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,20 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = ["client", "crypto"] }
|
async-trait = "0.1.89"
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["client", "crypto", "pipes", "web-server"] }
|
||||||
|
iota-auth = { path = "../iota-auth" }
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
iota-connection = { path = "../iota-connection" }
|
iota-connection = { path = "../iota-connection" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
|
other-iota = { path = "../other-iota" }
|
||||||
|
web-server = { path = "../web-server" }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
uuid = { version = "*", features = ["v4"] }
|
uuid = { version = "*", features = ["v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rcgen = "0.14"
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use iota_auth::{
|
||||||
|
AuthenticatedSession, HostedSessionRegistrar, SessionCapability, SessionIdentity,
|
||||||
|
SessionManager,
|
||||||
|
};
|
||||||
|
use iota_connection::message_common::CommunicationResponseExt;
|
||||||
|
use iota_connection::message_handlers;
|
||||||
|
use iota_connection::relay_service::{IngressSource, RelayService};
|
||||||
|
use iota_identity::{
|
||||||
|
LocalUserId, LocalUserStore, PrincipalHandle, PrincipalStore, PublicKeyBundle,
|
||||||
|
};
|
||||||
|
use iota_util::crypto_helper::public_key_bundle_from_base64;
|
||||||
|
use iota_util::mtp_compat::OptionalDataValueExt;
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
|
use mtp::codec::{DataType, DataValue};
|
||||||
|
use other_iota::LocalDeliverySink;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct HostedAndPeerKeyResolver {
|
||||||
|
local_users: Arc<dyn LocalUserStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostedAndPeerKeyResolver {
|
||||||
|
pub fn new(local_users: Arc<dyn LocalUserStore>) -> Self {
|
||||||
|
Self { local_users }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl web_server::ClientPublicKeyResolver for HostedAndPeerKeyResolver {
|
||||||
|
async fn public_key(&self, client_id: u64) -> Option<PublicKeyBundle> {
|
||||||
|
if client_id & (1_u64 << 63) != 0 {
|
||||||
|
return iota_storage::node_directory::SqliteNodeDirectory
|
||||||
|
.key_for_authentication_hint(client_id)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
}
|
||||||
|
let local_user = i64::try_from(client_id).ok().map(LocalUserId)?;
|
||||||
|
self.local_users
|
||||||
|
.get_local_user(local_user)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|user| public_key_bundle_from_base64(&user.public_key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClientConnectionManager {
|
||||||
|
local_users: Arc<dyn LocalUserStore>,
|
||||||
|
principals: Arc<dyn PrincipalStore>,
|
||||||
|
registrar: Arc<HostedSessionRegistrar>,
|
||||||
|
sessions: Arc<SessionManager>,
|
||||||
|
relay: Arc<RelayService>,
|
||||||
|
active: DashMap<PrincipalHandle, HashMap<Uuid, mtp::webserver::WebMtpSender>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConnectionManager {
|
||||||
|
pub fn new(
|
||||||
|
local_users: Arc<dyn LocalUserStore>,
|
||||||
|
principals: Arc<dyn PrincipalStore>,
|
||||||
|
registrar: Arc<HostedSessionRegistrar>,
|
||||||
|
sessions: Arc<SessionManager>,
|
||||||
|
relay: Arc<RelayService>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
local_users,
|
||||||
|
principals,
|
||||||
|
registrar,
|
||||||
|
sessions,
|
||||||
|
relay,
|
||||||
|
active: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_connected(&self, principal: PrincipalHandle) -> bool {
|
||||||
|
self.active.contains_key(&principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
session: &AuthenticatedSession,
|
||||||
|
frame: CommunicationValue,
|
||||||
|
) -> Vec<CommunicationValue> {
|
||||||
|
if let Some(relay) = client_relay_frame(&frame) {
|
||||||
|
let request = frame.clone();
|
||||||
|
return match self
|
||||||
|
.relay
|
||||||
|
.accept_relay(
|
||||||
|
IngressSource::HostedClient {
|
||||||
|
session: session.clone(),
|
||||||
|
},
|
||||||
|
relay,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(outcome) => {
|
||||||
|
let (response, deliveries) = outcome.into_parts();
|
||||||
|
for delivery in deliveries {
|
||||||
|
self.deliver(delivery.recipient, delivery.frame).await;
|
||||||
|
}
|
||||||
|
response.into_iter().collect()
|
||||||
|
}
|
||||||
|
Err(_) => vec![
|
||||||
|
CommunicationValue::new(CommunicationType::ErrorInvalidData)
|
||||||
|
.with_request_id(&request),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let request = frame.clone();
|
||||||
|
let Some(frame) = authorized_hosted_frame(session, frame) else {
|
||||||
|
return vec![
|
||||||
|
CommunicationValue::new(CommunicationType::ErrorNotAuthenticated)
|
||||||
|
.with_request_id(&request),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
if frame.is_type(CommunicationType::AccountStateRequest) {
|
||||||
|
return vec![message_handlers::handle_account_state_request(&frame)];
|
||||||
|
}
|
||||||
|
if frame.is_type(CommunicationType::AccountStateApplied) {
|
||||||
|
return vec![message_handlers::handle_account_state_applied(&frame)];
|
||||||
|
}
|
||||||
|
if frame.is_type(CommunicationType::MessagesGet) {
|
||||||
|
return vec![message_handlers::handle_messages_get(&frame)];
|
||||||
|
}
|
||||||
|
if frame.is_type(CommunicationType::MessageGet) {
|
||||||
|
return vec![message_handlers::handle_message_get(&frame)];
|
||||||
|
}
|
||||||
|
vec![CommunicationValue::new(CommunicationType::ErrorInvalidData).with_request_id(&frame)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorized_hosted_frame(
|
||||||
|
session: &AuthenticatedSession,
|
||||||
|
frame: CommunicationValue,
|
||||||
|
) -> Option<CommunicationValue> {
|
||||||
|
let SessionIdentity::Hosted { local_user, .. } = &session.identity else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let capability = if frame.is_type(CommunicationType::AccountStateRequest)
|
||||||
|
|| frame.is_type(CommunicationType::AccountStateApplied)
|
||||||
|
{
|
||||||
|
SessionCapability::AccountData
|
||||||
|
} else if frame.is_type(CommunicationType::MessagesGet)
|
||||||
|
|| frame.is_type(CommunicationType::MessageGet)
|
||||||
|
{
|
||||||
|
SessionCapability::Messaging
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if !session.allows(&capability) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sender = u64::try_from(local_user.0).ok()?;
|
||||||
|
Some(frame.with_sender(sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_relay_frame(frame: &CommunicationValue) -> Option<CommunicationValue> {
|
||||||
|
if frame.is_type(CommunicationType::Relay) {
|
||||||
|
return Some(frame.clone());
|
||||||
|
}
|
||||||
|
if !frame.is_type(CommunicationType::MessageSend)
|
||||||
|
|| frame.get_data(DataType::VersionNumber).as_number() != Some(2)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let frame_id = frame.id()?;
|
||||||
|
let payload = match frame.get_data(DataType::SecurePayload)? {
|
||||||
|
DataValue::Bytes(payload) => payload.clone(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
CommunicationValue::new(CommunicationType::Relay)
|
||||||
|
.with_id(frame_id)
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2))
|
||||||
|
.add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl other_iota::LocalDeliverySink for ClientConnectionManager {
|
||||||
|
async fn deliver(&self, recipient: PrincipalHandle, frame: CommunicationValue) {
|
||||||
|
let senders = self
|
||||||
|
.active
|
||||||
|
.get(&recipient)
|
||||||
|
.map(|connections| connections.values().cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for sender in senders {
|
||||||
|
let _ = sender.send(&frame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl web_server::MtpConnectionHandler for ClientConnectionManager {
|
||||||
|
async fn accept(&self, connection: mtp::webserver::WebMTPConnection) {
|
||||||
|
if connection.auth_state != mtp::host::AuthState::Authenticated
|
||||||
|
|| connection.client_id & (1_u64 << 63) != 0
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(local_user_id) = i64::try_from(connection.client_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let local_user = LocalUserId(local_user_id);
|
||||||
|
if !self.local_users.is_hosted_here(local_user).unwrap_or(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(Some(principal)) = self.local_users.principal_for_local_user(local_user) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self
|
||||||
|
.principals
|
||||||
|
.get_principal(principal)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
let session = self
|
||||||
|
.registrar
|
||||||
|
.authenticate(connection_id, local_user, principal);
|
||||||
|
self.active
|
||||||
|
.entry(principal)
|
||||||
|
.or_default()
|
||||||
|
.insert(connection_id, connection.sender.clone());
|
||||||
|
while let Ok(frame) = connection.receive().await {
|
||||||
|
for response in self.handle(&session, frame).await {
|
||||||
|
if connection.sender.send(&response).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let dashmap::mapref::entry::Entry::Occupied(mut connections) =
|
||||||
|
self.active.entry(principal)
|
||||||
|
{
|
||||||
|
connections.get_mut().remove(&connection_id);
|
||||||
|
if connections.get().is_empty() {
|
||||||
|
connections.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.sessions.remove(connection_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConnectionGateway {
|
||||||
|
clients: Arc<ClientConnectionManager>,
|
||||||
|
peers: Arc<other_iota::PeerManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionGateway {
|
||||||
|
pub fn new(clients: Arc<ClientConnectionManager>, peers: Arc<other_iota::PeerManager>) -> Self {
|
||||||
|
Self { clients, peers }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl web_server::MtpConnectionHandler for ConnectionGateway {
|
||||||
|
async fn accept(&self, connection: mtp::webserver::WebMTPConnection) {
|
||||||
|
if connection.client_id & (1_u64 << 63) != 0 {
|
||||||
|
self.peers.accept(connection).await;
|
||||||
|
} else {
|
||||||
|
self.clients.accept(connection).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use iota_auth::SessionCapabilities;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_message_send_enters_relay_v2_path() {
|
||||||
|
let message = CommunicationValue::new(CommunicationType::MessageSend)
|
||||||
|
.with_id(7)
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2))
|
||||||
|
.add_typed_default(DataType::SecurePayload, DataValue::Bytes(vec![1, 2, 3]));
|
||||||
|
|
||||||
|
let relay = client_relay_frame(&message).unwrap();
|
||||||
|
assert!(relay.is_type(CommunicationType::Relay));
|
||||||
|
assert_eq!(relay.id(), Some(7));
|
||||||
|
assert_eq!(
|
||||||
|
relay.get_data(DataType::SecurePayload),
|
||||||
|
Some(&DataValue::Bytes(vec![1, 2, 3]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_message_send_does_not_enter_relay_path() {
|
||||||
|
assert!(
|
||||||
|
client_relay_frame(&CommunicationValue::new(CommunicationType::MessageSend)).is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_session_replaces_untrusted_request_sender() {
|
||||||
|
let session = AuthenticatedSession {
|
||||||
|
connection_id: Uuid::new_v4(),
|
||||||
|
identity: SessionIdentity::Hosted {
|
||||||
|
local_user: LocalUserId(7),
|
||||||
|
principal: PrincipalHandle(9),
|
||||||
|
},
|
||||||
|
capabilities: SessionCapabilities::hosted(),
|
||||||
|
};
|
||||||
|
let frame = CommunicationValue::new(CommunicationType::AccountStateRequest).with_sender(99);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
authorized_hosted_frame(&session, frame).unwrap().sender(),
|
||||||
|
Some(7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,6 @@
|
||||||
mod client_connection;
|
mod client_connection;
|
||||||
|
mod client_connection_manager;
|
||||||
pub use client_connection::ClientConnection;
|
pub use client_connection::ClientConnection;
|
||||||
|
pub use client_connection_manager::{
|
||||||
|
ClientConnectionManager, ConnectionGateway, HostedAndPeerKeyResolver,
|
||||||
|
};
|
||||||
|
|
|
||||||
296
client/tests/direct_gateway.rs
Normal file
296
client/tests/direct_gateway.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use client::ClientConnectionManager;
|
||||||
|
use iota_auth::{HostedSessionRegistrar, SessionManager};
|
||||||
|
use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome};
|
||||||
|
use iota_connection::federated_relay::FederatedRelayV2;
|
||||||
|
use iota_connection::relay_service::{RelayNodeIdentity, RelayService};
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityKind, IdentityError, IdentityResolver, IotaNodeId, LocalNodeIdentity,
|
||||||
|
LocalUserDescriptor, LocalUserId, LocalUserStore, PrincipalDescriptor, PrincipalHome,
|
||||||
|
PrincipalId, PrincipalStore, PublicKeyBundle, ResolutionContext, ResolvedPrincipal,
|
||||||
|
SignedPrincipalDescriptor, UserAddress,
|
||||||
|
};
|
||||||
|
use iota_storage::identity::{SqliteLocalUserStore, SqlitePrincipalStore};
|
||||||
|
use iota_storage::util::e2ee_storage;
|
||||||
|
use iota_util::mtp_compat::OptionalDataValueExt;
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
|
use mtp::crypto::Keyring;
|
||||||
|
use mtp::host::{AuthenticationPolicy, HostConfig};
|
||||||
|
use mtp::webserver::{MTPWebServer, WebServerConfig};
|
||||||
|
use other_iota::LocalDeliverySink;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct StoreResolver;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityResolver for StoreResolver {
|
||||||
|
async fn resolve_address(
|
||||||
|
&self,
|
||||||
|
_: &UserAddress,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
Err(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
SqlitePrincipalStore
|
||||||
|
.get_by_canonical_id(principal)?
|
||||||
|
.ok_or(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_keys(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
SqlitePrincipalStore.signing_keys(principal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestNodeIdentity(LocalNodeIdentity);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RelayNodeIdentity for TestNodeIdentity {
|
||||||
|
async fn keyring(&self) -> Option<Arc<Keyring>> {
|
||||||
|
Some(self.0.keyring())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_id(&self) -> Option<IotaNodeId> {
|
||||||
|
Some(self.0.node_id().clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AcceptingRouter(Mutex<Vec<CommunicationValue>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PeerRouter for AcceptingRouter {
|
||||||
|
async fn route(
|
||||||
|
&self,
|
||||||
|
_: &RouteDestination,
|
||||||
|
frame: CommunicationValue,
|
||||||
|
) -> Result<RouteOutcome, RouteError> {
|
||||||
|
self.0.lock().unwrap().push(frame);
|
||||||
|
Ok(RouteOutcome::Accepted {
|
||||||
|
relay_message_id: "client-relay".into(),
|
||||||
|
destination_accepted_at: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn certificate() -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let key_pair = rcgen::KeyPair::generate().unwrap();
|
||||||
|
let params =
|
||||||
|
rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap();
|
||||||
|
let certificate = params.self_signed(&key_pair).unwrap();
|
||||||
|
(
|
||||||
|
certificate.pem().into_bytes(),
|
||||||
|
key_pair.serialize_pem().into_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn hosted_client_loads_state_originates_relay_and_receives_delivery() {
|
||||||
|
let storage = tempfile::tempdir().unwrap();
|
||||||
|
iota_util::file_util::configure_storage_directory(storage.path().to_owned());
|
||||||
|
iota_storage::util::db::initialize_database().unwrap();
|
||||||
|
|
||||||
|
let local_node = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let remote_node = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let local_key = Keyring::generate();
|
||||||
|
let remote_key = Keyring::generate();
|
||||||
|
let local_user = LocalUserDescriptor {
|
||||||
|
id: LocalUserId(1),
|
||||||
|
username: "alice".into(),
|
||||||
|
display_name: None,
|
||||||
|
public_key: local_key.public_key_bundle().try_to_base64().unwrap(),
|
||||||
|
};
|
||||||
|
iota_storage::util::db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users (user_id, username, public_key, created_at) VALUES (1, 'alice', ?1, 1)",
|
||||||
|
[&local_user.public_key],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let now = iota_storage::util::sync::now_millis();
|
||||||
|
let local_handle = SqlitePrincipalStore
|
||||||
|
.ensure_local_principal(
|
||||||
|
local_node.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&local_user,
|
||||||
|
PrincipalHome::Iota(local_node.node_id().clone()),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let remote_descriptor = SignedPrincipalDescriptor::sign(
|
||||||
|
PrincipalDescriptor {
|
||||||
|
principal: PrincipalId {
|
||||||
|
authority: remote_node.authority_id().clone(),
|
||||||
|
user_id: 1,
|
||||||
|
},
|
||||||
|
authority_kind: AuthorityKind::Iota,
|
||||||
|
username: Some("bob".into()),
|
||||||
|
display_name: None,
|
||||||
|
public_keys: vec![remote_key.public_key_bundle()],
|
||||||
|
home: PrincipalHome::Iota(remote_node.node_id().clone()),
|
||||||
|
revision: 1,
|
||||||
|
valid_until: Some(now + 60_000),
|
||||||
|
issued_at: now,
|
||||||
|
},
|
||||||
|
&remote_node.keyring(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.verify(
|
||||||
|
remote_node.authority_id(),
|
||||||
|
&remote_node.public_keys(),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let remote_handle = SqlitePrincipalStore
|
||||||
|
.upsert_remote_descriptor(&remote_descriptor)
|
||||||
|
.unwrap();
|
||||||
|
let chat_id = e2ee_storage::principal_chat_id(local_handle, remote_handle).unwrap();
|
||||||
|
e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret {
|
||||||
|
user_id: "1".into(),
|
||||||
|
secret_id: e2ee_storage::principal_secret_id(&chat_id),
|
||||||
|
chat_id,
|
||||||
|
version: 1,
|
||||||
|
encrypted_secret: vec![1],
|
||||||
|
kem_ciphertext: vec![2],
|
||||||
|
wrapping_scheme: "test".into(),
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let router = Arc::new(AcceptingRouter::default());
|
||||||
|
let local_users: Arc<dyn LocalUserStore> = Arc::new(SqliteLocalUserStore);
|
||||||
|
let relay = Arc::new(
|
||||||
|
RelayService::new(local_users.clone(), router.clone(), None).with_federation(
|
||||||
|
Arc::new(StoreResolver),
|
||||||
|
Arc::new(TestNodeIdentity(local_node.clone())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let sessions = Arc::new(SessionManager::default());
|
||||||
|
let manager = Arc::new(ClientConnectionManager::new(
|
||||||
|
local_users,
|
||||||
|
Arc::new(SqlitePrincipalStore),
|
||||||
|
Arc::new(HostedSessionRegistrar::new(sessions.clone())),
|
||||||
|
sessions,
|
||||||
|
relay,
|
||||||
|
));
|
||||||
|
|
||||||
|
let (certificate, private_key) = certificate();
|
||||||
|
let client_public_key = local_key.public_key_bundle();
|
||||||
|
let host_keyring = Keyring::from_bytes(&local_node.keyring().try_to_bytes().unwrap()).unwrap();
|
||||||
|
let host = HostConfig::new(
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
0,
|
||||||
|
certificate.clone(),
|
||||||
|
private_key,
|
||||||
|
)
|
||||||
|
.with_authentication(
|
||||||
|
host_keyring,
|
||||||
|
Box::new(move |client_id, _| {
|
||||||
|
let key = (client_id == 1).then(|| client_public_key.clone());
|
||||||
|
Box::pin(async move { key })
|
||||||
|
}),
|
||||||
|
Box::new(|_, _| Box::pin(async { 0 })),
|
||||||
|
)
|
||||||
|
.with_authentication_policy(AuthenticationPolicy::ForceAuthentication);
|
||||||
|
let mut server = MTPWebServer::new(host, WebServerConfig::new())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let address = server.local_addr();
|
||||||
|
let server_manager = manager.clone();
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
if let Some(connection) = server.accept().await.unwrap() {
|
||||||
|
web_server::MtpConnectionHandler::accept(server_manager.as_ref(), connection).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let client = mtp::client::MTPClient::auth_connect(
|
||||||
|
mtp::client::ClientConfig::new(format!("https://{address}/"))
|
||||||
|
.with_client_id(1)
|
||||||
|
.with_pinned_pem(certificate),
|
||||||
|
&local_key,
|
||||||
|
&local_node.public_keys(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let snapshot = client
|
||||||
|
.request(
|
||||||
|
&CommunicationValue::new(CommunicationType::AccountStateRequest)
|
||||||
|
.with_id(3)
|
||||||
|
.with_sender(99),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(snapshot.is_type(CommunicationType::AccountStateSnapshot));
|
||||||
|
assert_eq!(snapshot.receiver(), Some(1));
|
||||||
|
|
||||||
|
let content = CommunicationValue::new(CommunicationType::MessageSend)
|
||||||
|
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()))
|
||||||
|
.add_typed_default(DataType::SendTime, DataValue::SignedNumber(10))
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1));
|
||||||
|
let relay = FederatedRelayV2::sign(
|
||||||
|
PrincipalId {
|
||||||
|
authority: local_node.authority_id().clone(),
|
||||||
|
user_id: 1,
|
||||||
|
},
|
||||||
|
remote_descriptor.descriptor().principal.clone(),
|
||||||
|
remote_node.node_id().clone(),
|
||||||
|
"client-relay".into(),
|
||||||
|
10,
|
||||||
|
content,
|
||||||
|
&local_key,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.into_frame(4)
|
||||||
|
.unwrap();
|
||||||
|
let payload = match relay.get_data(DataType::SecurePayload).unwrap() {
|
||||||
|
DataValue::Bytes(payload) => payload.clone(),
|
||||||
|
_ => panic!("Relay V2 payload has wrong type"),
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.request(
|
||||||
|
&CommunicationValue::new(CommunicationType::MessageSend)
|
||||||
|
.with_id(4)
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2))
|
||||||
|
.add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
response.get_comm_type_enum(),
|
||||||
|
Some(CommunicationType::Success),
|
||||||
|
"unexpected relay response: {response:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.get_data(DataType::RelayMessageId).as_str(),
|
||||||
|
Some("client-relay")
|
||||||
|
);
|
||||||
|
assert_eq!(router.0.lock().unwrap().len(), 1);
|
||||||
|
|
||||||
|
LocalDeliverySink::deliver(
|
||||||
|
manager.as_ref(),
|
||||||
|
local_handle,
|
||||||
|
CommunicationValue::new(CommunicationType::MessageLive).with_id(8),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let delivery = tokio::time::timeout(std::time::Duration::from_secs(5), client.receive())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(delivery.is_type(CommunicationType::MessageLive));
|
||||||
|
|
||||||
|
server_task.abort();
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,12 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
json = "*"
|
async-trait = "0.1.89"
|
||||||
|
dashmap = "6.2.1"
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom", "std"] }
|
||||||
|
uuid = { version = "*", features = ["v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.50.0", features = ["macros", "rt"] }
|
||||||
|
|
|
||||||
|
|
@ -1 +1,11 @@
|
||||||
|
mod principal_auth;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use principal_auth::{
|
||||||
|
AuthChallenge, AuthError, ForeignPrincipalAuthenticator, HostedSessionRegistrar,
|
||||||
|
IotaPeerAuthenticator,
|
||||||
|
};
|
||||||
|
pub use session::{
|
||||||
|
AuthenticatedSession, CommunityId, SessionCapabilities, SessionCapability, SessionIdentity,
|
||||||
|
SessionManager,
|
||||||
|
};
|
||||||
|
|
|
||||||
465
iota-auth/src/principal_auth.rs
Normal file
465
iota-auth/src/principal_auth.rs
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use iota_identity::{
|
||||||
|
IdentityError, IdentityResolver, IotaNodeId, LocalUserId, NodeIdentityResolver,
|
||||||
|
PrincipalHandle, PrincipalId,
|
||||||
|
};
|
||||||
|
use mtp::crypto::{PublicKeyBundle, SigAlgorithm, verify_ed25519, verify_ml_dsa};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AuthenticatedSession, SessionCapabilities, SessionIdentity, SessionManager};
|
||||||
|
|
||||||
|
const CHALLENGE_LIFETIME: Duration = Duration::from_secs(60);
|
||||||
|
const OFFLINE_IDENTITY_TRUST: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
const CHALLENGE_DOMAIN: &[u8] = b"tensamin.foreign-principal-auth.v1\0";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuthChallenge {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub signed_payload: Vec<u8>,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AuthError {
|
||||||
|
Identity(IdentityError),
|
||||||
|
ChallengeNotFound,
|
||||||
|
ChallengeExpired,
|
||||||
|
ChallengeConnectionMismatch,
|
||||||
|
InvalidProof,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IdentityError> for AuthError {
|
||||||
|
fn from(error: IdentityError) -> Self {
|
||||||
|
Self::Identity(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingChallenge {
|
||||||
|
principal: PrincipalHandle,
|
||||||
|
keys: Vec<PublicKeyBundle>,
|
||||||
|
connection_id: Uuid,
|
||||||
|
signed_payload: Vec<u8>,
|
||||||
|
expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ForeignPrincipalAuthenticator {
|
||||||
|
identities: Arc<dyn IdentityResolver>,
|
||||||
|
sessions: Arc<SessionManager>,
|
||||||
|
pending: DashMap<Uuid, PendingChallenge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForeignPrincipalAuthenticator {
|
||||||
|
pub fn new(identities: Arc<dyn IdentityResolver>, sessions: Arc<SessionManager>) -> Self {
|
||||||
|
Self {
|
||||||
|
identities,
|
||||||
|
sessions,
|
||||||
|
pending: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn issue_challenge(
|
||||||
|
&self,
|
||||||
|
connection_id: Uuid,
|
||||||
|
server: &IotaNodeId,
|
||||||
|
principal: PrincipalId,
|
||||||
|
) -> Result<AuthChallenge, AuthError> {
|
||||||
|
let resolution_context = iota_identity::ResolutionContext {
|
||||||
|
allow_network: true,
|
||||||
|
offline_policy: iota_identity::OfflineResolutionPolicy::AllowUnexpired {
|
||||||
|
max_staleness: OFFLINE_IDENTITY_TRUST,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolved = self
|
||||||
|
.identities
|
||||||
|
.resolve_principal_with_context(&principal, &resolution_context)
|
||||||
|
.await?;
|
||||||
|
if resolved.public_keys.is_empty() {
|
||||||
|
return Err(AuthError::Identity(IdentityError::InvalidDescriptor(
|
||||||
|
"principal has no current signing keys".into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let now = now_seconds();
|
||||||
|
self.pending
|
||||||
|
.retain(|_, challenge| challenge.expires_at > now);
|
||||||
|
let mut nonce = vec![0_u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut nonce);
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let expires_at = now.saturating_add(CHALLENGE_LIFETIME.as_secs());
|
||||||
|
let signed_payload =
|
||||||
|
challenge_payload(id, connection_id, server, &principal, &nonce, expires_at);
|
||||||
|
let challenge = AuthChallenge {
|
||||||
|
id,
|
||||||
|
signed_payload: signed_payload.clone(),
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
self.pending.insert(
|
||||||
|
challenge.id,
|
||||||
|
PendingChallenge {
|
||||||
|
principal: resolved.handle,
|
||||||
|
keys: resolved.public_keys,
|
||||||
|
connection_id,
|
||||||
|
signed_payload,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(
|
||||||
|
&self,
|
||||||
|
connection_id: Uuid,
|
||||||
|
challenge_id: Uuid,
|
||||||
|
signature: &[u8],
|
||||||
|
) -> Result<AuthenticatedSession, AuthError> {
|
||||||
|
let (_, pending) = self
|
||||||
|
.pending
|
||||||
|
.remove(&challenge_id)
|
||||||
|
.ok_or(AuthError::ChallengeNotFound)?;
|
||||||
|
if pending.connection_id != connection_id {
|
||||||
|
return Err(AuthError::ChallengeConnectionMismatch);
|
||||||
|
}
|
||||||
|
if pending.expires_at <= now_seconds() {
|
||||||
|
return Err(AuthError::ChallengeExpired);
|
||||||
|
}
|
||||||
|
if !pending
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.any(|key| verify_dual(key, &pending.signed_payload, signature))
|
||||||
|
{
|
||||||
|
return Err(AuthError::InvalidProof);
|
||||||
|
}
|
||||||
|
let session = AuthenticatedSession {
|
||||||
|
connection_id,
|
||||||
|
identity: SessionIdentity::Foreign {
|
||||||
|
principal: pending.principal,
|
||||||
|
},
|
||||||
|
capabilities: SessionCapabilities::foreign_authenticated(),
|
||||||
|
};
|
||||||
|
self.sessions.insert(session.clone());
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HostedSessionRegistrar {
|
||||||
|
sessions: Arc<SessionManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostedSessionRegistrar {
|
||||||
|
pub fn new(sessions: Arc<SessionManager>) -> Self {
|
||||||
|
Self { sessions }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate(
|
||||||
|
&self,
|
||||||
|
connection_id: Uuid,
|
||||||
|
local_user: LocalUserId,
|
||||||
|
principal: PrincipalHandle,
|
||||||
|
) -> AuthenticatedSession {
|
||||||
|
let session = AuthenticatedSession {
|
||||||
|
connection_id,
|
||||||
|
identity: SessionIdentity::Hosted {
|
||||||
|
local_user,
|
||||||
|
principal,
|
||||||
|
},
|
||||||
|
capabilities: SessionCapabilities::hosted(),
|
||||||
|
};
|
||||||
|
self.sessions.insert(session.clone());
|
||||||
|
session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IotaPeerAuthenticator {
|
||||||
|
identities: Arc<dyn NodeIdentityResolver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IotaPeerAuthenticator {
|
||||||
|
pub fn new(identities: Arc<dyn NodeIdentityResolver>) -> Self {
|
||||||
|
Self { identities }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn trusted_keys(
|
||||||
|
&self,
|
||||||
|
identity: &IotaNodeId,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, AuthError> {
|
||||||
|
self.identities
|
||||||
|
.resolve_node(identity)
|
||||||
|
.await
|
||||||
|
.map(|resolved| resolved.public_keys)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_seconds() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn challenge_payload(
|
||||||
|
challenge_id: Uuid,
|
||||||
|
connection_id: Uuid,
|
||||||
|
server: &IotaNodeId,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
nonce: &[u8],
|
||||||
|
expires_at: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::with_capacity(
|
||||||
|
CHALLENGE_DOMAIN.len()
|
||||||
|
+ 16
|
||||||
|
+ 16
|
||||||
|
+ server.as_str().len()
|
||||||
|
+ principal.authority.as_str().len()
|
||||||
|
+ nonce.len()
|
||||||
|
+ 32,
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(CHALLENGE_DOMAIN);
|
||||||
|
payload.extend_from_slice(challenge_id.as_bytes());
|
||||||
|
payload.extend_from_slice(connection_id.as_bytes());
|
||||||
|
append_field(&mut payload, server.as_str().as_bytes());
|
||||||
|
append_field(&mut payload, principal.authority.as_str().as_bytes());
|
||||||
|
payload.extend_from_slice(&principal.user_id.to_be_bytes());
|
||||||
|
append_field(&mut payload, nonce);
|
||||||
|
payload.extend_from_slice(&expires_at.to_be_bytes());
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_field(target: &mut Vec<u8>, field: &[u8]) {
|
||||||
|
target.extend_from_slice(&(field.len() as u64).to_be_bytes());
|
||||||
|
target.extend_from_slice(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_dual(key: &PublicKeyBundle, message: &[u8], signature: &[u8]) -> bool {
|
||||||
|
let Some(classical_length) = SigAlgorithm::length(SigAlgorithm::ED25519) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(post_quantum_length) = SigAlgorithm::length(SigAlgorithm::ML_DSA_65) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if signature.len() != classical_length + post_quantum_length {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
verify_ed25519(
|
||||||
|
&key.sig_cl_public_key,
|
||||||
|
message,
|
||||||
|
&signature[..classical_length],
|
||||||
|
)
|
||||||
|
.and_then(|_| {
|
||||||
|
verify_ml_dsa(
|
||||||
|
&key.sig_pq_public_key,
|
||||||
|
message,
|
||||||
|
&signature[classical_length..],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use iota_identity::LocalNodeIdentity;
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityId, IdentityResolver, PrincipalHome, ResolutionContext, ResolvedPrincipal,
|
||||||
|
UserAddress,
|
||||||
|
};
|
||||||
|
use mtp::crypto::{DualSigner, Keyring, SignatureScheme};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
struct CachedIdentity {
|
||||||
|
principal: PrincipalId,
|
||||||
|
key: PublicKeyBundle,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OneShotIdentity {
|
||||||
|
resolved: ResolvedPrincipal,
|
||||||
|
calls: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityResolver for OneShotIdentity {
|
||||||
|
async fn resolve_address(
|
||||||
|
&self,
|
||||||
|
_: &UserAddress,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
Err(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal(
|
||||||
|
&self,
|
||||||
|
_: &PrincipalId,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
Err(IdentityError::Unavailable("authority is offline".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal_with_context(
|
||||||
|
&self,
|
||||||
|
_: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
Ok(self.resolved.clone())
|
||||||
|
} else {
|
||||||
|
Err(IdentityError::Unavailable("authority is offline".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_keys(
|
||||||
|
&self,
|
||||||
|
_: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
Err(IdentityError::Unavailable("authority is offline".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityResolver for CachedIdentity {
|
||||||
|
async fn resolve_address(
|
||||||
|
&self,
|
||||||
|
_: &UserAddress,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
self.resolve_principal(&self.principal).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
if principal != &self.principal {
|
||||||
|
return Err(IdentityError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(ResolvedPrincipal {
|
||||||
|
principal: principal.clone(),
|
||||||
|
handle: PrincipalHandle(44),
|
||||||
|
username: Some("foreign-user".into()),
|
||||||
|
public_keys: vec![self.key.clone()],
|
||||||
|
home: PrincipalHome::Unknown,
|
||||||
|
descriptor_revision: 1,
|
||||||
|
valid_until: None,
|
||||||
|
resolved_at: i64::MAX,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_keys(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
self.resolve_principal(principal)
|
||||||
|
.await
|
||||||
|
.map(|resolved| resolved.public_keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cached_foreign_principal_authenticates_without_a_hosted_account() {
|
||||||
|
let keyring = Keyring::generate();
|
||||||
|
let principal = PrincipalId {
|
||||||
|
authority: AuthorityId::new("omega:remote").unwrap(),
|
||||||
|
user_id: 17,
|
||||||
|
};
|
||||||
|
let identities = Arc::new(CachedIdentity {
|
||||||
|
principal: principal.clone(),
|
||||||
|
key: keyring.public_key_bundle(),
|
||||||
|
});
|
||||||
|
let sessions = Arc::new(SessionManager::default());
|
||||||
|
let authenticator = ForeignPrincipalAuthenticator::new(identities, sessions);
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
let server = LocalNodeIdentity::from_keyring(Keyring::generate())
|
||||||
|
.unwrap()
|
||||||
|
.node_id()
|
||||||
|
.clone();
|
||||||
|
let challenge = authenticator
|
||||||
|
.issue_challenge(connection_id, &server, principal.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let signer = DualSigner::new(
|
||||||
|
&keyring.sig_cl_secret_key,
|
||||||
|
&keyring.sig_pq_secret_key,
|
||||||
|
&keyring.sig_pq_public_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let signature = signer.sign(&challenge.signed_payload).unwrap();
|
||||||
|
let session = authenticator
|
||||||
|
.authenticate(connection_id, challenge.id, &signature)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.identity,
|
||||||
|
SessionIdentity::Foreign {
|
||||||
|
principal: PrincipalHandle(44)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!session.allows(&crate::SessionCapability::LocalStorage));
|
||||||
|
assert!(
|
||||||
|
!session.allows(&crate::SessionCapability::Community(crate::CommunityId(
|
||||||
|
"remote-community".into()
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let other_challenge = authenticator
|
||||||
|
.issue_challenge(connection_id, &server, principal)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
authenticator
|
||||||
|
.authenticate(Uuid::new_v4(), other_challenge.id, &[])
|
||||||
|
.await,
|
||||||
|
Err(AuthError::ChallengeConnectionMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn challenge_uses_the_key_set_resolved_when_it_was_issued() {
|
||||||
|
let keyring = Keyring::generate();
|
||||||
|
let principal = PrincipalId {
|
||||||
|
authority: AuthorityId::new("omega:remote").unwrap(),
|
||||||
|
user_id: 18,
|
||||||
|
};
|
||||||
|
let identities = Arc::new(OneShotIdentity {
|
||||||
|
resolved: ResolvedPrincipal {
|
||||||
|
principal: principal.clone(),
|
||||||
|
handle: PrincipalHandle(45),
|
||||||
|
username: None,
|
||||||
|
public_keys: vec![keyring.public_key_bundle()],
|
||||||
|
home: PrincipalHome::Unknown,
|
||||||
|
descriptor_revision: 1,
|
||||||
|
valid_until: None,
|
||||||
|
resolved_at: i64::MAX,
|
||||||
|
},
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
});
|
||||||
|
let sessions = Arc::new(SessionManager::default());
|
||||||
|
let authenticator = ForeignPrincipalAuthenticator::new(identities.clone(), sessions);
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
let server = LocalNodeIdentity::from_keyring(Keyring::generate())
|
||||||
|
.unwrap()
|
||||||
|
.node_id()
|
||||||
|
.clone();
|
||||||
|
let challenge = authenticator
|
||||||
|
.issue_challenge(connection_id, &server, principal)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let signer = DualSigner::new(
|
||||||
|
&keyring.sig_cl_secret_key,
|
||||||
|
&keyring.sig_pq_secret_key,
|
||||||
|
&keyring.sig_pq_public_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let signature = signer.sign(&challenge.signed_payload).unwrap();
|
||||||
|
|
||||||
|
authenticator
|
||||||
|
.authenticate(connection_id, challenge.id, &signature)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(identities.calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
iota-auth/src/session.rs
Normal file
189
iota-auth/src/session.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use iota_identity::{LocalUserId, PrincipalHandle};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CommunityId(pub String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SessionCapability {
|
||||||
|
AccountData,
|
||||||
|
Messaging,
|
||||||
|
Settings,
|
||||||
|
LocalStorage,
|
||||||
|
Communities,
|
||||||
|
Community(CommunityId),
|
||||||
|
RelayOrigination,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct SessionCapabilities(HashSet<SessionCapability>);
|
||||||
|
|
||||||
|
impl SessionCapabilities {
|
||||||
|
pub fn hosted() -> Self {
|
||||||
|
Self(HashSet::from([
|
||||||
|
SessionCapability::AccountData,
|
||||||
|
SessionCapability::Messaging,
|
||||||
|
SessionCapability::Settings,
|
||||||
|
SessionCapability::LocalStorage,
|
||||||
|
SessionCapability::Communities,
|
||||||
|
SessionCapability::RelayOrigination,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_community(community: CommunityId) -> Self {
|
||||||
|
Self(HashSet::from([SessionCapability::Community(community)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn foreign_authenticated() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant(&mut self, capability: SessionCapability) {
|
||||||
|
self.0.insert(capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows(&self, capability: &SessionCapability) -> bool {
|
||||||
|
self.0.contains(capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SessionIdentity {
|
||||||
|
Hosted {
|
||||||
|
local_user: LocalUserId,
|
||||||
|
principal: PrincipalHandle,
|
||||||
|
},
|
||||||
|
Foreign {
|
||||||
|
principal: PrincipalHandle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionIdentity {
|
||||||
|
pub fn principal(&self) -> PrincipalHandle {
|
||||||
|
match self {
|
||||||
|
Self::Hosted { principal, .. } | Self::Foreign { principal } => *principal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuthenticatedSession {
|
||||||
|
pub connection_id: Uuid,
|
||||||
|
pub identity: SessionIdentity,
|
||||||
|
pub capabilities: SessionCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticatedSession {
|
||||||
|
pub fn principal(&self) -> PrincipalHandle {
|
||||||
|
self.identity.principal()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows(&self, capability: &SessionCapability) -> bool {
|
||||||
|
self.capabilities.allows(capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SessionManager {
|
||||||
|
sessions: DashMap<Uuid, AuthenticatedSession>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
pub fn insert(&self, session: AuthenticatedSession) {
|
||||||
|
self.sessions.insert(session.connection_id, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, connection_id: Uuid) -> Option<AuthenticatedSession> {
|
||||||
|
self.sessions
|
||||||
|
.get(&connection_id)
|
||||||
|
.map(|session| session.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, connection_id: Uuid) -> Option<AuthenticatedSession> {
|
||||||
|
self.sessions
|
||||||
|
.remove(&connection_id)
|
||||||
|
.map(|(_, session)| session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant_community(
|
||||||
|
&self,
|
||||||
|
connection_id: Uuid,
|
||||||
|
community: CommunityId,
|
||||||
|
) -> Result<AuthenticatedSession, SessionAuthorizationError> {
|
||||||
|
let mut session = self
|
||||||
|
.sessions
|
||||||
|
.get_mut(&connection_id)
|
||||||
|
.ok_or(SessionAuthorizationError::UnknownSession)?;
|
||||||
|
session
|
||||||
|
.capabilities
|
||||||
|
.grant(SessionCapability::Community(community));
|
||||||
|
Ok(session.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorize(
|
||||||
|
&self,
|
||||||
|
connection_id: Uuid,
|
||||||
|
capability: &SessionCapability,
|
||||||
|
) -> Result<AuthenticatedSession, SessionAuthorizationError> {
|
||||||
|
let session = self
|
||||||
|
.get(connection_id)
|
||||||
|
.ok_or(SessionAuthorizationError::UnknownSession)?;
|
||||||
|
if !session.allows(capability) {
|
||||||
|
return Err(SessionAuthorizationError::CapabilityDenied);
|
||||||
|
}
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SessionAuthorizationError {
|
||||||
|
UnknownSession,
|
||||||
|
CapabilityDenied,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_community_session_cannot_access_hosted_data() {
|
||||||
|
let manager = SessionManager::default();
|
||||||
|
let connection_id = Uuid::new_v4();
|
||||||
|
manager.insert(AuthenticatedSession {
|
||||||
|
connection_id,
|
||||||
|
identity: SessionIdentity::Foreign {
|
||||||
|
principal: PrincipalHandle(12),
|
||||||
|
},
|
||||||
|
capabilities: SessionCapabilities::for_community(CommunityId("community-a".into())),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
manager
|
||||||
|
.authorize(connection_id, &SessionCapability::AccountData)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manager
|
||||||
|
.authorize(
|
||||||
|
connection_id,
|
||||||
|
&SessionCapability::Community(CommunityId("community-a".into()))
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_session_exposes_its_principal() {
|
||||||
|
let session = AuthenticatedSession {
|
||||||
|
connection_id: Uuid::new_v4(),
|
||||||
|
identity: SessionIdentity::Hosted {
|
||||||
|
local_user: LocalUserId(4),
|
||||||
|
principal: PrincipalHandle(9),
|
||||||
|
},
|
||||||
|
capabilities: SessionCapabilities::hosted(),
|
||||||
|
};
|
||||||
|
assert_eq!(session.principal(), PrincipalHandle(9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,11 @@ iota-paths = { path = "../iota-paths" }
|
||||||
|
|
||||||
|
|
||||||
chrono = "0.4.43"
|
chrono = "0.4.43"
|
||||||
crossterm = "*"
|
crossterm = "0.29.0"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
open = "5.3.3"
|
open = "5.3.3"
|
||||||
ratatui = "0.30.0"
|
ratatool = { git = "https://git.methanium.net/methanium/ratatool.git", features = ["tokio"] }
|
||||||
|
ratatui = "0.30.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ControlAction {
|
|
||||||
FocusNext,
|
|
||||||
FocusPrevious,
|
|
||||||
Select,
|
|
||||||
Activate,
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
|
||||||
use std::{collections::HashSet, hash::Hash};
|
|
||||||
|
|
||||||
pub struct CheckboxItem<T> {
|
|
||||||
pub value: T,
|
|
||||||
pub label: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub disabled_reason: Option<String>,
|
|
||||||
}
|
|
||||||
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
|
|
||||||
items: Vec<CheckboxItem<T>>,
|
|
||||||
selected: HashSet<T>,
|
|
||||||
focused_index: usize,
|
|
||||||
focus_policy: DisabledFocusPolicy,
|
|
||||||
wrap_navigation: bool,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CheckboxGroupError {
|
|
||||||
Empty,
|
|
||||||
DuplicateValue,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CheckboxChange<T> {
|
|
||||||
Selected(T),
|
|
||||||
Deselected(T),
|
|
||||||
IgnoredDisabled(T),
|
|
||||||
NoItem,
|
|
||||||
}
|
|
||||||
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
|
|
||||||
pub fn new(
|
|
||||||
items: Vec<CheckboxItem<T>>,
|
|
||||||
selected: impl IntoIterator<Item = T>,
|
|
||||||
) -> Result<Self, CheckboxGroupError> {
|
|
||||||
let mut values = HashSet::new();
|
|
||||||
if items.iter().any(|item| !values.insert(item.value.clone())) {
|
|
||||||
return Err(CheckboxGroupError::DuplicateValue);
|
|
||||||
}
|
|
||||||
let selected = selected
|
|
||||||
.into_iter()
|
|
||||||
.filter(|value| values.contains(value))
|
|
||||||
.collect();
|
|
||||||
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
|
|
||||||
Ok(Self {
|
|
||||||
items,
|
|
||||||
selected,
|
|
||||||
focused_index,
|
|
||||||
focus_policy: DisabledFocusPolicy::Skip,
|
|
||||||
wrap_navigation: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn items(&self) -> &[CheckboxItem<T>] {
|
|
||||||
&self.items
|
|
||||||
}
|
|
||||||
pub fn selected(&self) -> &HashSet<T> {
|
|
||||||
&self.selected
|
|
||||||
}
|
|
||||||
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
|
|
||||||
self.items.get(self.focused_index)
|
|
||||||
}
|
|
||||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
|
||||||
self.focus_policy = policy;
|
|
||||||
}
|
|
||||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
|
||||||
self.wrap_navigation = wrap;
|
|
||||||
}
|
|
||||||
pub fn focus_next(&mut self) {
|
|
||||||
self.move_focus(true);
|
|
||||||
}
|
|
||||||
pub fn focus_previous(&mut self) {
|
|
||||||
self.move_focus(false);
|
|
||||||
}
|
|
||||||
fn move_focus(&mut self, forward: bool) {
|
|
||||||
if self.items.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for step in 1..=self.items.len() {
|
|
||||||
let current = self.focused_index as isize;
|
|
||||||
let delta = if forward {
|
|
||||||
step as isize
|
|
||||||
} else {
|
|
||||||
-(step as isize)
|
|
||||||
};
|
|
||||||
let raw = current + delta;
|
|
||||||
let next = if self.wrap_navigation {
|
|
||||||
raw.rem_euclid(self.items.len() as isize) as usize
|
|
||||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
raw as usize
|
|
||||||
};
|
|
||||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
|
||||||
self.focused_index = next;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
|
|
||||||
let Some(item) = self.items.get(self.focused_index) else {
|
|
||||||
return CheckboxChange::NoItem;
|
|
||||||
};
|
|
||||||
let value = item.value.clone();
|
|
||||||
if !item.enabled {
|
|
||||||
return CheckboxChange::IgnoredDisabled(value);
|
|
||||||
}
|
|
||||||
if self.selected.remove(&value) {
|
|
||||||
CheckboxChange::Deselected(value)
|
|
||||||
} else {
|
|
||||||
self.selected.insert(value.clone());
|
|
||||||
CheckboxChange::Selected(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
|
|
||||||
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
|
|
||||||
item.enabled = enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_selected(&mut self, value: T, selected: bool) {
|
|
||||||
if selected {
|
|
||||||
self.selected.insert(value);
|
|
||||||
} else {
|
|
||||||
self.selected.remove(&value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
|
||||||
let item = self.items.iter().position(|item| &item.value == value);
|
|
||||||
ChoiceVisualState {
|
|
||||||
selected: self.selected.contains(value),
|
|
||||||
focused: item == Some(self.focused_index),
|
|
||||||
enabled: item
|
|
||||||
.and_then(|index| self.items.get(index))
|
|
||||||
.is_some_and(|item| item.enabled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::{Constraint, Layout, Rect},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, KeyHint, Screen, UiEvent},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DialogButton {
|
|
||||||
Cancel,
|
|
||||||
Confirm,
|
|
||||||
Custom(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConfirmDialog {
|
|
||||||
title: String,
|
|
||||||
message: Vec<String>,
|
|
||||||
buttons: Vec<DialogButtonConfig>,
|
|
||||||
focused_button: usize,
|
|
||||||
on_confirm: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
|
||||||
on_cancel: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DialogButtonConfig {
|
|
||||||
label: String,
|
|
||||||
intent: ButtonIntent,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfirmDialog {
|
|
||||||
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
message: vec![message.into()],
|
|
||||||
buttons: vec![
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Cancel".to_owned(),
|
|
||||||
intent: ButtonIntent::Cancel,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Confirm".to_owned(),
|
|
||||||
intent: ButtonIntent::Primary,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
focused_button: 0,
|
|
||||||
on_confirm: None,
|
|
||||||
on_cancel: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
message: vec![message.into()],
|
|
||||||
buttons: vec![
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Cancel".to_owned(),
|
|
||||||
intent: ButtonIntent::Cancel,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Delete".to_owned(),
|
|
||||||
intent: ButtonIntent::Destructive,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
focused_button: 0,
|
|
||||||
on_confirm: None,
|
|
||||||
on_cancel: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_message_line(mut self, line: impl Into<String>) -> Self {
|
|
||||||
self.message.push(line.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_button(mut self, label: impl Into<String>, intent: ButtonIntent) -> Self {
|
|
||||||
self.buttons.push(DialogButtonConfig {
|
|
||||||
label: label.into(),
|
|
||||||
intent,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_confirm_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
|
||||||
mut self,
|
|
||||||
action: F,
|
|
||||||
) -> Self {
|
|
||||||
self.on_confirm = Some(Box::new(action));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_cancel_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
|
||||||
mut self,
|
|
||||||
action: F,
|
|
||||||
) -> Self {
|
|
||||||
self.on_cancel = Some(Box::new(action));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(&self) -> InteractionResult {
|
|
||||||
match self.focused_button {
|
|
||||||
0 => {
|
|
||||||
if let Some(action) = &self.on_cancel {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
if let Some(action) = &self.on_confirm {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => InteractionResult::CloseScreen,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_button(&mut self) {
|
|
||||||
self.focused_button = (self.focused_button + 1) % self.buttons.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prev_button(&mut self) {
|
|
||||||
if self.focused_button == 0 {
|
|
||||||
self.focused_button = self.buttons.len() - 1;
|
|
||||||
} else {
|
|
||||||
self.focused_button -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Screen for ConfirmDialog {
|
|
||||||
fn as_any(&self) -> &dyn std::any::Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let area = crate::layout::fit::centered_rect(
|
|
||||||
rect,
|
|
||||||
crate::layout::fit::RequiredSize {
|
|
||||||
width: 50,
|
|
||||||
height: (self.message.len() + 8) as u16,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default()
|
|
||||||
.title(format!(" {} ", self.title))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(context.theme.borders.focused)
|
|
||||||
.style(context.theme.surfaces.overlay);
|
|
||||||
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let rows = Layout::vertical([
|
|
||||||
Constraint::Min(self.message.len() as u16),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
|
|
||||||
let lines: Vec<Line> = self
|
|
||||||
.message
|
|
||||||
.iter()
|
|
||||||
.map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal)))
|
|
||||||
.collect();
|
|
||||||
f.render_widget(Paragraph::new(lines), rows[0]);
|
|
||||||
|
|
||||||
let buttons_area = rows[2];
|
|
||||||
let button_widths: Vec<u16> = self
|
|
||||||
.buttons
|
|
||||||
.iter()
|
|
||||||
.map(|b| crate::controls::button::button_minimum_width(&b.label))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total_width: u16 = button_widths.iter().sum();
|
|
||||||
let spacing = self.buttons.len().saturating_sub(1) as u16;
|
|
||||||
let available = buttons_area.width;
|
|
||||||
let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2;
|
|
||||||
|
|
||||||
let mut x = start_x;
|
|
||||||
for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() {
|
|
||||||
let button_area = Rect {
|
|
||||||
x,
|
|
||||||
y: buttons_area.y,
|
|
||||||
width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
x = x.saturating_add(width + 1);
|
|
||||||
|
|
||||||
render_button(
|
|
||||||
f,
|
|
||||||
button_area,
|
|
||||||
ActionButton {
|
|
||||||
label: &button_config.label,
|
|
||||||
intent: button_config.intent,
|
|
||||||
focused: self.focused_button == i,
|
|
||||||
enabled: button_config.enabled,
|
|
||||||
},
|
|
||||||
context.theme,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
|
||||||
let UiEvent::Key(key) = event else {
|
|
||||||
return InteractionResult::Unhandled;
|
|
||||||
};
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Esc => InteractionResult::CloseScreen,
|
|
||||||
KeyCode::Tab => {
|
|
||||||
self.next_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::BackTab => {
|
|
||||||
self.prev_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
self.prev_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
self.next_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
|
||||||
_ => InteractionResult::Unhandled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
|
||||||
vec![
|
|
||||||
KeyHint {
|
|
||||||
keys: "Tab",
|
|
||||||
action: "Switch button",
|
|
||||||
},
|
|
||||||
KeyHint {
|
|
||||||
keys: "Enter",
|
|
||||||
action: "Confirm",
|
|
||||||
},
|
|
||||||
KeyHint {
|
|
||||||
keys: "Esc",
|
|
||||||
action: "Cancel",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ use crate::ipc_client::{DaemonStatus, IpcConnectionState};
|
||||||
use crate::theme::ResolvedTheme;
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::button::ButtonIntent,
|
controls::button::ButtonIntent,
|
||||||
screens::screens::{AppAction, HitMap},
|
screens::screens::{AppAction, HitMap, IotaMessage},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -119,7 +119,7 @@ pub fn render_header(
|
||||||
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
|
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
|
||||||
brand_area,
|
brand_area,
|
||||||
);
|
);
|
||||||
hits.register(brand_area, AppAction::OpenMain);
|
hits.register(brand_area, IotaMessage::Action(AppAction::OpenMain));
|
||||||
|
|
||||||
for (index, item) in HEADER_ITEMS.iter().enumerate() {
|
for (index, item) in HEADER_ITEMS.iter().enumerate() {
|
||||||
let top = cells[index + 1];
|
let top = cells[index + 1];
|
||||||
|
|
@ -150,6 +150,6 @@ pub fn render_header(
|
||||||
]),
|
]),
|
||||||
button_area,
|
button_area,
|
||||||
);
|
);
|
||||||
hits.register(button_area, item.action);
|
hits.register(button_area, IotaMessage::Action(item.action));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct MenuItem<T> {
|
|
||||||
pub label: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub value: T,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub disabled_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct MenuState<T> {
|
|
||||||
items: Vec<MenuItem<T>>,
|
|
||||||
selected: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> MenuState<T> {
|
|
||||||
pub fn new(items: Vec<MenuItem<T>>) -> Self {
|
|
||||||
let selected = items.iter().position(|item| item.enabled);
|
|
||||||
Self { items, selected }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn items(&self) -> &[MenuItem<T>] {
|
|
||||||
&self.items
|
|
||||||
}
|
|
||||||
pub fn selected_index(&self) -> Option<usize> {
|
|
||||||
self.selected
|
|
||||||
}
|
|
||||||
pub fn selected_item(&self) -> Option<&MenuItem<T>> {
|
|
||||||
self.selected.and_then(|index| self.items.get(index))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_next(&mut self) {
|
|
||||||
self.move_by(1);
|
|
||||||
}
|
|
||||||
pub fn move_previous(&mut self) {
|
|
||||||
self.move_by(-1);
|
|
||||||
}
|
|
||||||
pub fn first(&mut self) {
|
|
||||||
self.selected = self.items.iter().position(|item| item.enabled);
|
|
||||||
}
|
|
||||||
pub fn last(&mut self) {
|
|
||||||
self.selected = self.items.iter().rposition(|item| item.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyCode) -> bool {
|
|
||||||
match key {
|
|
||||||
KeyCode::Up => self.move_previous(),
|
|
||||||
KeyCode::Down => self.move_next(),
|
|
||||||
KeyCode::Home => self.first(),
|
|
||||||
KeyCode::End => self.last(),
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_by(&mut self, delta: isize) {
|
|
||||||
let Some(current) = self.selected else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for offset in 1..=self.items.len() {
|
|
||||||
let next = (current as isize + delta * offset as isize)
|
|
||||||
.rem_euclid(self.items.len() as isize) as usize;
|
|
||||||
if self.items[next].enabled {
|
|
||||||
self.selected = Some(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn navigation_skips_disabled_items() {
|
|
||||||
let mut menu = MenuState::new(vec![
|
|
||||||
MenuItem {
|
|
||||||
label: "A".into(),
|
|
||||||
description: None,
|
|
||||||
value: 1,
|
|
||||||
enabled: true,
|
|
||||||
disabled_reason: None,
|
|
||||||
},
|
|
||||||
MenuItem {
|
|
||||||
label: "B".into(),
|
|
||||||
description: None,
|
|
||||||
value: 2,
|
|
||||||
enabled: false,
|
|
||||||
disabled_reason: Some("Unavailable".into()),
|
|
||||||
},
|
|
||||||
MenuItem {
|
|
||||||
label: "C".into(),
|
|
||||||
description: None,
|
|
||||||
value: 3,
|
|
||||||
enabled: true,
|
|
||||||
disabled_reason: None,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
menu.move_next();
|
|
||||||
assert_eq!(menu.selected_index(), Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_enabled_set_is_safe() {
|
|
||||||
let mut menu = MenuState::new(vec![MenuItem {
|
|
||||||
label: "A".into(),
|
|
||||||
description: None,
|
|
||||||
value: (),
|
|
||||||
enabled: false,
|
|
||||||
disabled_reason: None,
|
|
||||||
}]);
|
|
||||||
menu.move_next();
|
|
||||||
menu.last();
|
|
||||||
assert_eq!(menu.selected_index(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
pub mod action;
|
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod checkbox_group;
|
|
||||||
pub mod choice;
|
pub mod choice;
|
||||||
pub mod dialog;
|
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod menu;
|
|
||||||
pub mod navigation;
|
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
pub mod radio_group;
|
|
||||||
pub mod scroll;
|
|
||||||
pub mod text_input;
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum DisabledFocusPolicy {
|
|
||||||
Include,
|
|
||||||
#[default]
|
|
||||||
Skip,
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
|
||||||
|
|
||||||
pub struct RadioItem<T> {
|
|
||||||
pub value: T,
|
|
||||||
pub label: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub disabled_reason: Option<String>,
|
|
||||||
}
|
|
||||||
pub struct RadioGroup<T: Clone + Eq> {
|
|
||||||
items: Vec<RadioItem<T>>,
|
|
||||||
selected: T,
|
|
||||||
default: T,
|
|
||||||
focused_index: usize,
|
|
||||||
focus_policy: DisabledFocusPolicy,
|
|
||||||
wrap_navigation: bool,
|
|
||||||
disabled_selection_policy: DisabledSelectionPolicy,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum RadioGroupError {
|
|
||||||
Empty,
|
|
||||||
DefaultMissing,
|
|
||||||
DefaultDisabled,
|
|
||||||
NoEnabledItems,
|
|
||||||
SelectedItemDisabled,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum RadioChange<T> {
|
|
||||||
Changed { previous: T, selected: T },
|
|
||||||
Unchanged(T),
|
|
||||||
IgnoredDisabled(T),
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DisabledSelectionPolicy {
|
|
||||||
UseConfiguredDefault,
|
|
||||||
UseFirstEnabled,
|
|
||||||
ReturnError,
|
|
||||||
}
|
|
||||||
impl<T: Clone + Eq> RadioGroup<T> {
|
|
||||||
pub fn new(
|
|
||||||
items: Vec<RadioItem<T>>,
|
|
||||||
observed: Option<T>,
|
|
||||||
default: T,
|
|
||||||
) -> Result<Self, RadioGroupError> {
|
|
||||||
if items.is_empty() {
|
|
||||||
return Err(RadioGroupError::Empty);
|
|
||||||
}
|
|
||||||
let default_item = items
|
|
||||||
.iter()
|
|
||||||
.find(|item| item.value == default)
|
|
||||||
.ok_or(RadioGroupError::DefaultMissing)?;
|
|
||||||
if !default_item.enabled {
|
|
||||||
return Err(RadioGroupError::DefaultDisabled);
|
|
||||||
}
|
|
||||||
let focused_index = items
|
|
||||||
.iter()
|
|
||||||
.position(|item| item.enabled)
|
|
||||||
.ok_or(RadioGroupError::NoEnabledItems)?;
|
|
||||||
let selected = observed
|
|
||||||
.filter(|value| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.any(|item| item.enabled && item.value == *value)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| default.clone());
|
|
||||||
Ok(Self {
|
|
||||||
items,
|
|
||||||
selected,
|
|
||||||
default,
|
|
||||||
focused_index,
|
|
||||||
focus_policy: DisabledFocusPolicy::Skip,
|
|
||||||
wrap_navigation: true,
|
|
||||||
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn items(&self) -> &[RadioItem<T>] {
|
|
||||||
&self.items
|
|
||||||
}
|
|
||||||
pub fn selected(&self) -> &T {
|
|
||||||
&self.selected
|
|
||||||
}
|
|
||||||
pub fn focused_item(&self) -> &RadioItem<T> {
|
|
||||||
&self.items[self.focused_index]
|
|
||||||
}
|
|
||||||
pub fn focus_next(&mut self) {
|
|
||||||
self.move_focus(true);
|
|
||||||
}
|
|
||||||
pub fn focus_previous(&mut self) {
|
|
||||||
self.move_focus(false);
|
|
||||||
}
|
|
||||||
fn move_focus(&mut self, forward: bool) {
|
|
||||||
for step in 1..=self.items.len() {
|
|
||||||
let raw = self.focused_index as isize
|
|
||||||
+ if forward {
|
|
||||||
step as isize
|
|
||||||
} else {
|
|
||||||
-(step as isize)
|
|
||||||
};
|
|
||||||
let next = if self.wrap_navigation {
|
|
||||||
raw.rem_euclid(self.items.len() as isize) as usize
|
|
||||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
raw as usize
|
|
||||||
};
|
|
||||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
|
||||||
self.focused_index = next;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn select_focused(&mut self) -> RadioChange<T> {
|
|
||||||
let item = self.focused_item();
|
|
||||||
let enabled = item.enabled;
|
|
||||||
let value = item.value.clone();
|
|
||||||
if !enabled {
|
|
||||||
return RadioChange::IgnoredDisabled(value);
|
|
||||||
}
|
|
||||||
if value == self.selected {
|
|
||||||
RadioChange::Unchanged(self.selected.clone())
|
|
||||||
} else {
|
|
||||||
let previous = std::mem::replace(&mut self.selected, value);
|
|
||||||
RadioChange::Changed {
|
|
||||||
previous,
|
|
||||||
selected: self.selected.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
|
||||||
let item = self.items.iter().position(|item| &item.value == value);
|
|
||||||
ChoiceVisualState {
|
|
||||||
selected: &self.selected == value,
|
|
||||||
focused: item == Some(self.focused_index),
|
|
||||||
enabled: item
|
|
||||||
.and_then(|index| self.items.get(index))
|
|
||||||
.is_some_and(|item| item.enabled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
|
|
||||||
self.disabled_selection_policy = policy;
|
|
||||||
}
|
|
||||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
|
||||||
self.focus_policy = policy;
|
|
||||||
}
|
|
||||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
|
||||||
self.wrap_navigation = wrap;
|
|
||||||
}
|
|
||||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
|
|
||||||
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if self.items[index].enabled == enabled {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if !enabled
|
|
||||||
&& self
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.all(|(other, item)| other == index || !item.enabled)
|
|
||||||
{
|
|
||||||
return Err(RadioGroupError::NoEnabledItems);
|
|
||||||
}
|
|
||||||
if !enabled && self.selected == *value {
|
|
||||||
let replacement = match self.disabled_selection_policy {
|
|
||||||
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.find(|item| item.enabled && item.value == self.default)
|
|
||||||
.map(|item| item.value.clone()),
|
|
||||||
DisabledSelectionPolicy::UseConfiguredDefault => None,
|
|
||||||
DisabledSelectionPolicy::UseFirstEnabled => self
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.find(|(other, item)| *other != index && item.enabled)
|
|
||||||
.map(|(_, item)| item.value.clone()),
|
|
||||||
DisabledSelectionPolicy::ReturnError => {
|
|
||||||
return Err(RadioGroupError::SelectedItemDisabled);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
|
|
||||||
}
|
|
||||||
self.items[index].enabled = enabled;
|
|
||||||
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
|
|
||||||
{
|
|
||||||
self.focus_next();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
pub fn default(&self) -> &T {
|
|
||||||
&self.default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::Rect,
|
|
||||||
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Reusable viewport policy for long, vertically stacked terminal content.
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct ScrollOptions {
|
|
||||||
pub show_scrollbar: bool,
|
|
||||||
pub render_partial_components: bool,
|
|
||||||
}
|
|
||||||
impl Default for ScrollOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
show_scrollbar: true,
|
|
||||||
render_partial_components: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct ScrollField {
|
|
||||||
pub offset: u16,
|
|
||||||
pub options: ScrollOptions,
|
|
||||||
}
|
|
||||||
impl ScrollField {
|
|
||||||
pub fn up(&mut self, amount: u16) {
|
|
||||||
self.offset = self.offset.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) {
|
|
||||||
self.offset = (self.offset.saturating_add(amount))
|
|
||||||
.min(content_height.saturating_sub(viewport_height));
|
|
||||||
}
|
|
||||||
pub fn render(
|
|
||||||
&self,
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
content: Paragraph<'_>,
|
|
||||||
content_height: u16,
|
|
||||||
) {
|
|
||||||
frame.render_widget(content.scroll((self.offset, 0)), area);
|
|
||||||
if self.options.show_scrollbar && content_height > area.height {
|
|
||||||
let mut state =
|
|
||||||
ScrollbarState::new(content_height as usize).position(self.offset as usize);
|
|
||||||
frame.render_stateful_widget(
|
|
||||||
Scrollbar::new(ScrollbarOrientation::VerticalRight),
|
|
||||||
area,
|
|
||||||
&mut state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
||||||
pub struct TextInput {
|
|
||||||
value: String,
|
|
||||||
cursor: usize,
|
|
||||||
label: Option<String>,
|
|
||||||
placeholder: Option<String>,
|
|
||||||
validation: Option<String>,
|
|
||||||
secret: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TextInput {
|
|
||||||
pub fn new(label: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
label: Some(label.into()),
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn value(&self) -> &str {
|
|
||||||
&self.value
|
|
||||||
}
|
|
||||||
pub fn display_value(&self) -> String {
|
|
||||||
if self.secret {
|
|
||||||
"•".repeat(self.value.chars().count())
|
|
||||||
} else {
|
|
||||||
self.value.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_placeholder(&mut self, value: impl Into<String>) {
|
|
||||||
self.placeholder = Some(value.into());
|
|
||||||
}
|
|
||||||
pub fn placeholder(&self) -> Option<&str> {
|
|
||||||
self.placeholder.as_deref()
|
|
||||||
}
|
|
||||||
pub fn label(&self) -> Option<&str> {
|
|
||||||
self.label.as_deref()
|
|
||||||
}
|
|
||||||
pub fn validation(&self) -> Option<&str> {
|
|
||||||
self.validation.as_deref()
|
|
||||||
}
|
|
||||||
pub fn set_validation(&mut self, value: Option<String>) {
|
|
||||||
self.validation = value;
|
|
||||||
}
|
|
||||||
pub fn set_secret(&mut self, secret: bool) {
|
|
||||||
self.secret = secret;
|
|
||||||
}
|
|
||||||
pub fn handle_key(&mut self, key: KeyCode) -> bool {
|
|
||||||
match key {
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
if self.cursor > 0 {
|
|
||||||
let start = self.value[..self.cursor]
|
|
||||||
.char_indices()
|
|
||||||
.last()
|
|
||||||
.map(|(index, _)| index)
|
|
||||||
.unwrap_or(0);
|
|
||||||
self.value.drain(start..self.cursor);
|
|
||||||
self.cursor = start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Delete => {
|
|
||||||
if self.cursor < self.value.len() {
|
|
||||||
let end = self.value[self.cursor..]
|
|
||||||
.char_indices()
|
|
||||||
.nth(1)
|
|
||||||
.map(|(index, _)| self.cursor + index)
|
|
||||||
.unwrap_or(self.value.len());
|
|
||||||
self.value.drain(self.cursor..end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
if self.cursor > 0 {
|
|
||||||
self.cursor = self.value[..self.cursor]
|
|
||||||
.char_indices()
|
|
||||||
.last()
|
|
||||||
.map(|(index, _)| index)
|
|
||||||
.unwrap_or(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
if self.cursor < self.value.len() {
|
|
||||||
self.cursor = self.value[self.cursor..]
|
|
||||||
.char_indices()
|
|
||||||
.nth(1)
|
|
||||||
.map(|(index, _)| self.cursor + index)
|
|
||||||
.unwrap_or(self.value.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Home => self.cursor = 0,
|
|
||||||
KeyCode::End => self.cursor = self.value.len(),
|
|
||||||
KeyCode::Char(character) => {
|
|
||||||
self.value.insert(self.cursor, character);
|
|
||||||
self.cursor += character.len_utf8();
|
|
||||||
}
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,11 +13,11 @@ use std::{
|
||||||
};
|
};
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||||
interaction_result::InteractionResult,
|
|
||||||
ipc_client::IpcClient,
|
ipc_client::IpcClient,
|
||||||
render_context::RenderContext,
|
screens::screens::IotaInteraction,
|
||||||
util::borders::draw_block_joins,
|
util::borders::draw_block_joins,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -300,18 +300,13 @@ impl Element for ConsoleCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) {
|
||||||
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
let inner = crate::controls::panel::render_panel(
|
let inner =
|
||||||
f,
|
crate::controls::panel::render_panel(f, r, &self.title, self.focused, theme);
|
||||||
r,
|
|
||||||
&self.title,
|
|
||||||
self.focused,
|
|
||||||
context.theme,
|
|
||||||
);
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
|
Paragraph::new(Line::from(self.render_cursor_spans(theme)))
|
||||||
.style(context.theme.console.text),
|
.style(theme.console.text),
|
||||||
inner,
|
inner,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -319,15 +314,15 @@ impl Element for ConsoleCard {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.title(self.title.clone())
|
.title(self.title.clone())
|
||||||
.title_style(context.theme.console.title)
|
.title_style(theme.console.title)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.console.focused_border
|
theme.console.focused_border
|
||||||
} else {
|
} else {
|
||||||
context.theme.console.border
|
theme.console.border
|
||||||
})
|
})
|
||||||
.style(context.theme.console.text);
|
.style(theme.console.text);
|
||||||
|
|
||||||
let spans = self.render_cursor_spans(context.theme);
|
let spans = self.render_cursor_spans(theme);
|
||||||
let par = Paragraph::new(Line::from(spans))
|
let par = Paragraph::new(Line::from(spans))
|
||||||
.block(block)
|
.block(block)
|
||||||
.scroll((0, 0));
|
.scroll((0, 0));
|
||||||
|
|
@ -338,9 +333,9 @@ impl Element for ConsoleCard {
|
||||||
self.borders,
|
self.borders,
|
||||||
self.joins,
|
self.joins,
|
||||||
if self.focused {
|
if self.focused {
|
||||||
context.theme.borders.focused
|
theme.borders.focused
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
theme.borders.normal
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +384,7 @@ impl InteractableElement for ConsoleCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
fn interact(&mut self, key: KeyEvent) -> IotaInteraction {
|
||||||
// Check if a previously failed command should be restored.
|
// Check if a previously failed command should be restored.
|
||||||
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
||||||
self.content = restored;
|
self.content = restored;
|
||||||
|
|
@ -401,26 +396,26 @@ impl InteractableElement for ConsoleCard {
|
||||||
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
||||||
self.dispatch_command(command);
|
self.dispatch_command(command);
|
||||||
}
|
}
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if self.content.is_empty() {
|
if self.content.is_empty() {
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
let command = self.content.clone();
|
let command = self.content.clone();
|
||||||
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
|
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
|
||||||
self.message = Some(error);
|
self.message = Some(error);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
if command.trim_start_matches('/').trim() == "help" {
|
if command.trim_start_matches('/').trim() == "help" {
|
||||||
self.message = Some(format!(
|
self.message = Some(format!(
|
||||||
"Commands: {}",
|
"Commands: {}",
|
||||||
iota_ipc::text_commands::COMMANDS.join(", ")
|
iota_ipc::text_commands::COMMANDS.join(", ")
|
||||||
));
|
));
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
if self.history.last() != Some(&command) {
|
if self.history.last() != Some(&command) {
|
||||||
self.history.push(command.clone());
|
self.history.push(command.clone());
|
||||||
|
|
@ -434,12 +429,12 @@ impl InteractableElement for ConsoleCard {
|
||||||
} else {
|
} else {
|
||||||
self.dispatch_command(command);
|
self.dispatch_command(command);
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
self.message = None;
|
self.message = None;
|
||||||
self.delete_at_cursor();
|
self.delete_at_cursor();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
self.message = None;
|
self.message = None;
|
||||||
|
|
@ -454,41 +449,41 @@ impl InteractableElement for ConsoleCard {
|
||||||
.unwrap_or(self.content.len());
|
.unwrap_or(self.content.len());
|
||||||
self.content.replace_range(start..end, "");
|
self.content.replace_range(start..end, "");
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
self.move_cursor_left();
|
self.move_cursor_left();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
self.move_cursor_right();
|
self.move_cursor_right();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
self.cursor_position = 0;
|
self.cursor_position = 0;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::End => {
|
KeyCode::End => {
|
||||||
self.cursor_position = self.content.chars().count();
|
self.cursor_position = self.content.chars().count();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
self.history_previous();
|
self.history_previous();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
self.history_next();
|
self.history_next();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Tab if !self.content.is_empty() => {
|
KeyCode::Tab if !self.content.is_empty() => {
|
||||||
if self.complete() {
|
if self.complete() {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
} else {
|
} else {
|
||||||
self.message = Some("No command completion.".into());
|
self.message = Some("No command completion.".into());
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
KeyCode::Tab | KeyCode::BackTab => IotaInteraction::Unhandled,
|
||||||
_ => {
|
_ => {
|
||||||
if !key
|
if !key
|
||||||
.modifiers
|
.modifiers
|
||||||
|
|
@ -497,10 +492,10 @@ impl InteractableElement for ConsoleCard {
|
||||||
if let Some(c) = key.code.as_char() {
|
if let Some(c) = key.code.as_char() {
|
||||||
self.insert_at_cursor(c);
|
self.insert_at_cursor(c);
|
||||||
self.message = None;
|
self.message = None;
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InteractionResult::Unhandled
|
IotaInteraction::Unhandled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,15 @@ use std::any::Any;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::{Frame, layout::Rect, widgets::Borders};
|
use ratatui::{Frame, layout::Rect, widgets::Borders};
|
||||||
|
|
||||||
use crate::{
|
use crate::screens::screens::{IotaInteraction, IotaScreen};
|
||||||
interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen,
|
use crate::theme::ResolvedTheme;
|
||||||
};
|
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub trait Element: Send + Sync + Any {
|
pub trait Element: Send + Sync + Any {
|
||||||
fn as_any(&self) -> &dyn Any;
|
fn as_any(&self) -> &dyn Any;
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>);
|
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
|
|
@ -33,7 +32,7 @@ pub trait InfoElement: Send + Sync + Any {
|
||||||
fn as_element(&self) -> &dyn Element;
|
fn as_element(&self) -> &dyn Element;
|
||||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||||
|
|
||||||
fn get_info_screen(&self) -> Box<dyn Screen>;
|
fn get_info_screen(&self) -> Box<dyn IotaScreen>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
|
|
@ -43,7 +42,7 @@ pub trait InteractableElement: Send + Sync + Any {
|
||||||
fn as_element(&self) -> &dyn Element;
|
fn as_element(&self) -> &dyn Element;
|
||||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||||
|
|
||||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult;
|
fn interact(&mut self, key: KeyEvent) -> IotaInteraction;
|
||||||
|
|
||||||
fn can_focus(&self) -> bool;
|
fn can_focus(&self) -> bool;
|
||||||
fn is_focused(&self) -> bool;
|
fn is_focused(&self) -> bool;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::{any::Any, sync::Arc};
|
use std::any::Any;
|
||||||
|
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use iota_state::ClientState;
|
use iota_state::ClientState;
|
||||||
|
|
@ -11,11 +11,10 @@ use ratatui::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||||
interaction_result::InteractionResult,
|
screens::screens::IotaInteraction,
|
||||||
render_context::RenderContext,
|
|
||||||
ui::UI,
|
|
||||||
util::borders::draw_block_joins,
|
util::borders::draw_block_joins,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,7 +66,6 @@ impl GRAPHS {
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct GraphCard {
|
pub struct GraphCard {
|
||||||
ui: Arc<UI>,
|
|
||||||
state: ClientState,
|
state: ClientState,
|
||||||
graph_type: GRAPHS,
|
graph_type: GRAPHS,
|
||||||
|
|
||||||
|
|
@ -82,9 +80,8 @@ pub struct GraphCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraphCard {
|
impl GraphCard {
|
||||||
pub fn new(ui: Arc<UI>, state: ClientState, graph_type: GRAPHS, title: String) -> Self {
|
pub fn new(state: ClientState, graph_type: GRAPHS, title: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ui,
|
|
||||||
state,
|
state,
|
||||||
graph_type,
|
graph_type,
|
||||||
focused: false,
|
focused: false,
|
||||||
|
|
@ -113,7 +110,7 @@ impl Element for GraphCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) {
|
||||||
if self.open {
|
if self.open {
|
||||||
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
|
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
|
||||||
if graph.is_empty() {
|
if graph.is_empty() {
|
||||||
|
|
@ -121,13 +118,13 @@ impl Element for GraphCard {
|
||||||
.title(format!(" {} ", self.title))
|
.title(format!(" {} ", self.title))
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.graphs.focused_border
|
theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
context.theme.graphs.border
|
theme.graphs.border
|
||||||
});
|
});
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
ratatui::widgets::Paragraph::new("No metric samples yet.")
|
ratatui::widgets::Paragraph::new("No metric samples yet.")
|
||||||
.style(context.theme.text.muted)
|
.style(theme.text.muted)
|
||||||
.block(block),
|
.block(block),
|
||||||
r,
|
r,
|
||||||
);
|
);
|
||||||
|
|
@ -149,7 +146,7 @@ impl Element for GraphCard {
|
||||||
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
|
let surface = matches!(theme.chrome, crate::theme::ChromeMode::Surfaces);
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"{}: {}{} {}min/{}max",
|
"{}: {}{} {}min/{}max",
|
||||||
self.title,
|
self.title,
|
||||||
|
|
@ -159,7 +156,7 @@ impl Element for GraphCard {
|
||||||
max_y as i64
|
max_y as i64
|
||||||
);
|
);
|
||||||
let plot_area = if surface {
|
let plot_area = if surface {
|
||||||
crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme)
|
crate::controls::panel::render_panel(f, r, &title, self.focused, theme)
|
||||||
} else {
|
} else {
|
||||||
r
|
r
|
||||||
};
|
};
|
||||||
|
|
@ -178,9 +175,9 @@ impl Element for GraphCard {
|
||||||
})
|
})
|
||||||
.borders(if surface { Borders::NONE } else { self.borders })
|
.borders(if surface { Borders::NONE } else { self.borders })
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.graphs.focused_border
|
theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
context.theme.graphs.border
|
theme.graphs.border
|
||||||
});
|
});
|
||||||
|
|
||||||
let canvas = Canvas::default()
|
let canvas = Canvas::default()
|
||||||
|
|
@ -194,7 +191,7 @@ impl Element for GraphCard {
|
||||||
y1: 0.0,
|
y1: 0.0,
|
||||||
x2: *x,
|
x2: *x,
|
||||||
y2: *y,
|
y2: *y,
|
||||||
color: self.graph_type.get_color(context.theme),
|
color: self.graph_type.get_color(theme),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -204,22 +201,22 @@ impl Element for GraphCard {
|
||||||
.title("")
|
.title("")
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.graphs.focused_border
|
theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
context.theme.graphs.border
|
theme.graphs.border
|
||||||
});
|
});
|
||||||
f.render_widget(block, r);
|
f.render_widget(block, r);
|
||||||
}
|
}
|
||||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
draw_block_joins(
|
draw_block_joins(
|
||||||
f,
|
f,
|
||||||
r,
|
r,
|
||||||
self.borders,
|
self.borders,
|
||||||
self.joins,
|
self.joins,
|
||||||
if self.focused {
|
if self.focused {
|
||||||
context.theme.borders.focused
|
theme.borders.focused
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
theme.borders.normal
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,8 +266,8 @@ impl InteractableElement for GraphCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interact(&mut self, _key: KeyEvent) -> InteractionResult {
|
fn interact(&mut self, _key: KeyEvent) -> IotaInteraction {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn can_focus(&self) -> bool {
|
fn can_focus(&self) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||||
use crate::util::borders::draw_block_joins;
|
use crate::util::borders::draw_block_joins;
|
||||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
use crate::{screens::screens::IotaInteraction, theme::ResolvedTheme};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use iota_state::{ClientState, UiLogEntry};
|
use iota_state::{ClientState, UiLogEntry};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|
@ -270,7 +270,7 @@ impl LogCard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_up(&mut self) {
|
pub fn scroll_older(&mut self) {
|
||||||
let max_offset = self
|
let max_offset = self
|
||||||
.last_total_lines
|
.last_total_lines
|
||||||
.load(Ordering::Relaxed)
|
.load(Ordering::Relaxed)
|
||||||
|
|
@ -278,7 +278,7 @@ impl LogCard {
|
||||||
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_down(&mut self) {
|
pub fn scroll_newer(&mut self) {
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,25 +318,19 @@ impl Element for LogCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
||||||
let entries = self.get_logs();
|
let entries = self.get_logs();
|
||||||
|
|
||||||
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
let inner_area = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
crate::controls::panel::render_panel(
|
crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, theme)
|
||||||
f,
|
|
||||||
area,
|
|
||||||
&self.build_title(),
|
|
||||||
self.focused,
|
|
||||||
context.theme,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(self.build_title())
|
.title(self.build_title())
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.logs.focused_border
|
theme.logs.focused_border
|
||||||
} else {
|
} else {
|
||||||
context.theme.logs.border
|
theme.logs.border
|
||||||
});
|
});
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
@ -350,9 +344,9 @@ impl Element for LogCard {
|
||||||
self.borders,
|
self.borders,
|
||||||
self.joins,
|
self.joins,
|
||||||
if self.focused {
|
if self.focused {
|
||||||
context.theme.borders.focused
|
theme.borders.focused
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
theme.borders.normal
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -376,24 +370,21 @@ impl Element for LogCard {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
let (prefix, rest) = Self::split_line_prefix(line);
|
let (prefix, rest) = Self::split_line_prefix(line);
|
||||||
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
let prefix = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
prefix
|
prefix
|
||||||
};
|
};
|
||||||
|
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(prefix.to_string(), source.style(theme)));
|
||||||
prefix.to_string(),
|
|
||||||
source.style(context.theme),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
||||||
let text_style = if *is_error {
|
let text_style = if *is_error {
|
||||||
context.theme.logs.error
|
theme.logs.error
|
||||||
} else {
|
} else {
|
||||||
context.theme.logs.text
|
theme.logs.text
|
||||||
};
|
};
|
||||||
|
|
||||||
if !content.is_empty() {
|
if !content.is_empty() {
|
||||||
|
|
@ -401,10 +392,7 @@ impl Element for LogCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !timestamp.is_empty() {
|
if !timestamp.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(timestamp.to_string(), theme.logs.timestamp));
|
||||||
timestamp.to_string(),
|
|
||||||
context.theme.logs.timestamp,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
|
|
@ -421,16 +409,16 @@ impl Element for LogCard {
|
||||||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
draw_block_joins(
|
draw_block_joins(
|
||||||
f,
|
f,
|
||||||
area,
|
area,
|
||||||
self.borders,
|
self.borders,
|
||||||
self.joins,
|
self.joins,
|
||||||
if self.focused {
|
if self.focused {
|
||||||
context.theme.borders.focused
|
theme.borders.focused
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
theme.borders.normal
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -480,7 +468,7 @@ impl InteractableElement for LogCard {
|
||||||
&mut *self
|
&mut *self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
fn interact(&mut self, key: KeyEvent) -> IotaInteraction {
|
||||||
if self.filtering {
|
if self.filtering {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
|
@ -501,7 +489,7 @@ impl InteractableElement for LogCard {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
let entries = self.get_logs();
|
let entries = self.get_logs();
|
||||||
let width = self.last_width.load(Ordering::Relaxed).max(1);
|
let width = self.last_width.load(Ordering::Relaxed).max(1);
|
||||||
|
|
@ -517,51 +505,51 @@ impl InteractableElement for LogCard {
|
||||||
self.filtering = true;
|
self.filtering = true;
|
||||||
self.filter.clear();
|
self.filter.clear();
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||||
self.selected = !self.selected;
|
self.selected = !self.selected;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('j') | KeyCode::Char('J') => {
|
KeyCode::Char('j') | KeyCode::Char('J') => {
|
||||||
let (can_up, _) = self.get_title_hints();
|
let (can_up, _) = self.get_title_hints();
|
||||||
if can_up {
|
if can_up {
|
||||||
self.scroll_up();
|
self.scroll_older();
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||||
let (_, can_down) = self.get_title_hints();
|
let (_, can_down) = self.get_title_hints();
|
||||||
if can_down {
|
if can_down {
|
||||||
self.scroll_down();
|
self.scroll_newer();
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Up if self.selected => {
|
KeyCode::Up if self.selected => {
|
||||||
let (can_up, _) = self.get_title_hints();
|
let (can_up, _) = self.get_title_hints();
|
||||||
if can_up {
|
if can_up {
|
||||||
self.scroll_up();
|
self.scroll_older();
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Down if self.selected => {
|
KeyCode::Down if self.selected => {
|
||||||
let (_, can_down) = self.get_title_hints();
|
let (_, can_down) = self.get_title_hints();
|
||||||
if can_down {
|
if can_down {
|
||||||
self.scroll_down();
|
self.scroll_newer();
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
if total_lines > visible_height {
|
if total_lines > visible_height {
|
||||||
self.scroll_offset = total_lines - visible_height;
|
self.scroll_offset = total_lines - visible_height;
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::End => {
|
KeyCode::End => {
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@ use ratatui::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
interaction_result::InteractionResult,
|
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, Screen, UiEvent},
|
|
||||||
theme::ResolvedTheme,
|
theme::ResolvedTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -139,19 +137,11 @@ impl HelpOverlay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for HelpOverlay {
|
impl IotaScreen for HelpOverlay {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||||
self
|
let area = ratatool::layout::centered_rect(
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let area = crate::layout::fit::centered_rect(
|
|
||||||
rect,
|
rect,
|
||||||
crate::layout::fit::RequiredSize {
|
ratatool::layout::RequiredSize {
|
||||||
width: 52,
|
width: 52,
|
||||||
height: 40,
|
height: 40,
|
||||||
},
|
},
|
||||||
|
|
@ -161,44 +151,43 @@ impl Screen for HelpOverlay {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Keyboard Shortcuts (?) ")
|
.title(" Keyboard Shortcuts (?) ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.focused)
|
.border_style(theme.borders.focused)
|
||||||
.style(context.theme.surfaces.overlay);
|
.style(theme.surfaces.overlay);
|
||||||
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
let lines = self.build_lines(context.theme);
|
let lines = self.build_lines(theme);
|
||||||
let paragraph = Paragraph::new(lines)
|
let paragraph = Paragraph::new(lines)
|
||||||
.scroll((self.scroll as u16, 0))
|
.scroll((self.scroll as u16, 0))
|
||||||
.style(context.theme.text.normal);
|
.style(theme.text.normal);
|
||||||
f.render_widget(paragraph, inner);
|
f.render_widget(paragraph, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let UiEvent::Key(key) = event else {
|
let UiEvent::Key(key) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => IotaInteraction::Pop,
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
self.scroll = self.scroll.saturating_add(1);
|
self.scroll = self.scroll.saturating_add(1);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
self.scroll = self.scroll.saturating_sub(1);
|
self.scroll = self.scroll.saturating_sub(1);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
self.scroll = self.scroll.saturating_add(10);
|
self.scroll = self.scroll.saturating_add(10);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::PageUp => {
|
KeyCode::PageUp => {
|
||||||
self.scroll = self.scroll.saturating_sub(10);
|
self.scroll = self.scroll.saturating_sub(10);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,5 +204,3 @@ impl Screen for HelpOverlay {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::any::Any;
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
use crate::{screens::screens::UiEvent, ui::UI};
|
|
||||||
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let cancellation = ui.cancellation_token();
|
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
|
||||||
let worker_cancellation = cancellation.clone();
|
|
||||||
let worker = tokio::task::spawn_blocking(move || -> Result<(), String> {
|
|
||||||
while !worker_cancellation.is_cancelled() {
|
|
||||||
if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
|
|
||||||
tx.send(read().map_err(|e| e.to_string())?)
|
|
||||||
.map_err(|_| "input session closed".to_string())?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
loop {
|
|
||||||
if ui.is_shutdown() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
event = rx.recv() => match event {
|
|
||||||
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
|
||||||
Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
|
|
||||||
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
|
|
||||||
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
|
|
||||||
Some(_) => {},
|
|
||||||
None => break,
|
|
||||||
},
|
|
||||||
_ = cancellation.cancelled() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let result = match worker.await {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(error) if error.is_cancelled() => Ok(()),
|
|
||||||
Err(error) => Err(format!("input worker failed: {error}")),
|
|
||||||
};
|
|
||||||
if result.is_err() {
|
|
||||||
ui.request_shutdown();
|
|
||||||
}
|
|
||||||
result
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
|
||||||
if matches!(
|
|
||||||
key.code,
|
|
||||||
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
|
|
||||||
) && key.modifiers.contains(KeyModifiers::CONTROL)
|
|
||||||
{
|
|
||||||
ui.request_shutdown();
|
|
||||||
} else {
|
|
||||||
ui.handle_event(UiEvent::Key(key)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
use std::fmt::{Debug, Formatter};
|
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
|
|
||||||
use crate::screens::screens::{Screen, UiEvent};
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
pub enum InteractionResult {
|
|
||||||
CloseScreen,
|
|
||||||
OpenScreen {
|
|
||||||
screen: Box<dyn Screen>,
|
|
||||||
},
|
|
||||||
OpenFutureScreen {
|
|
||||||
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
|
||||||
},
|
|
||||||
AppTask {
|
|
||||||
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
|
|
||||||
},
|
|
||||||
Handled,
|
|
||||||
Unhandled,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Debug for InteractionResult {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
|
||||||
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
|
||||||
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
|
|
||||||
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
|
||||||
InteractionResult::Handled => write!(f, "Handled"),
|
|
||||||
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for InteractionResult {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
match (self, other) {
|
|
||||||
(
|
|
||||||
InteractionResult::OpenScreen { screen: _ },
|
|
||||||
InteractionResult::OpenScreen { screen: _ },
|
|
||||||
) => true,
|
|
||||||
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
(
|
|
||||||
InteractionResult::OpenFutureScreen { screen: _ },
|
|
||||||
InteractionResult::OpenFutureScreen { screen: _ },
|
|
||||||
) => true,
|
|
||||||
(InteractionResult::CloseScreen, InteractionResult::CloseScreen) => true,
|
|
||||||
(InteractionResult::Handled, InteractionResult::Handled) => true,
|
|
||||||
(InteractionResult::Unhandled, InteractionResult::Unhandled) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -76,6 +76,7 @@ pub struct IpcClient {
|
||||||
pending: Mutex<HashMap<u64, PendingRequest>>,
|
pending: Mutex<HashMap<u64, PendingRequest>>,
|
||||||
connection_state: watch::Sender<IpcConnectionState>,
|
connection_state: watch::Sender<IpcConnectionState>,
|
||||||
daemon_status: watch::Sender<DaemonStatus>,
|
daemon_status: watch::Sender<DaemonStatus>,
|
||||||
|
ui_updates: watch::Sender<u64>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
reconnector_started: AtomicBool,
|
reconnector_started: AtomicBool,
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
|
|
@ -110,6 +111,7 @@ impl IpcClient {
|
||||||
};
|
};
|
||||||
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
|
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
|
||||||
let (daemon_status_tx, _) = watch::channel(initial_status);
|
let (daemon_status_tx, _) = watch::channel(initial_status);
|
||||||
|
let (ui_updates_tx, _) = watch::channel(0);
|
||||||
let client = Arc::new(Self {
|
let client = Arc::new(Self {
|
||||||
state: ClientState::new(),
|
state: ClientState::new(),
|
||||||
writer: Mutex::new(Some(ActiveWriter {
|
writer: Mutex::new(Some(ActiveWriter {
|
||||||
|
|
@ -122,6 +124,7 @@ impl IpcClient {
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
connection_state: conn_state_tx,
|
connection_state: conn_state_tx,
|
||||||
daemon_status: daemon_status_tx,
|
daemon_status: daemon_status_tx,
|
||||||
|
ui_updates: ui_updates_tx,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
reconnector_started: AtomicBool::new(false),
|
reconnector_started: AtomicBool::new(false),
|
||||||
cancellation: CancellationToken::new(),
|
cancellation: CancellationToken::new(),
|
||||||
|
|
@ -415,6 +418,10 @@ impl IpcClient {
|
||||||
self.daemon_status.subscribe()
|
self.daemon_status.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ui_updates(&self) -> watch::Receiver<u64> {
|
||||||
|
self.ui_updates.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the IPC reader/reconnector and release the socket writer. This
|
/// Stop the IPC reader/reconnector and release the socket writer. This
|
||||||
/// is deliberately bounded so UI shutdown cannot hang on a peer.
|
/// is deliberately bounded so UI shutdown cannot hang on a peer.
|
||||||
pub async fn shutdown(&self) {
|
pub async fn shutdown(&self) {
|
||||||
|
|
@ -903,5 +910,8 @@ impl IpcClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.ui_updates.send_modify(|generation| {
|
||||||
|
*generation = generation.wrapping_add(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
use ratatui::layout::Rect;
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct RequiredSize {
|
|
||||||
pub width: u16,
|
|
||||||
pub height: u16,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum FitLevel {
|
|
||||||
Preferred,
|
|
||||||
Compact,
|
|
||||||
Fallback,
|
|
||||||
}
|
|
||||||
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
|
|
||||||
if area.width >= preferred.width && area.height >= preferred.height {
|
|
||||||
FitLevel::Preferred
|
|
||||||
} else if area.width >= compact.width && area.height >= compact.height {
|
|
||||||
FitLevel::Compact
|
|
||||||
} else {
|
|
||||||
FitLevel::Fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
|
|
||||||
let width = area.width.min(maximum.width);
|
|
||||||
let height = area.height.min(maximum.height);
|
|
||||||
Rect {
|
|
||||||
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
|
||||||
y: area
|
|
||||||
.y
|
|
||||||
.saturating_add(area.height.saturating_sub(height) / 2),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
|
|
||||||
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
|
|
||||||
Some(Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y.checked_add(top)?,
|
|
||||||
width: area.width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
|
|
||||||
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
|
|
||||||
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
|
|
||||||
Some(Rect {
|
|
||||||
x: area.x.checked_add(horizontal)?,
|
|
||||||
y: area.y.checked_add(vertical)?,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod fit;
|
|
||||||
pub mod text_measure;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
|
|
||||||
if width == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
text.split('\n')
|
|
||||||
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
|
|
||||||
.sum::<usize>()
|
|
||||||
.min(u16::MAX as usize) as u16
|
|
||||||
}
|
|
||||||
|
|
@ -24,12 +24,7 @@ pub mod util {
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
pub mod controls;
|
pub mod controls;
|
||||||
pub mod help_overlay;
|
pub mod help_overlay;
|
||||||
pub mod input_handler;
|
|
||||||
pub mod interaction_result;
|
|
||||||
pub mod ipc_client;
|
pub mod ipc_client;
|
||||||
pub mod layout;
|
|
||||||
pub mod notification;
|
|
||||||
pub mod render_context;
|
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub use ui::TuiSession;
|
pub use ui::TuiSession;
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::Rect,
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::Paragraph,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::theme::ResolvedTheme;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum NotificationKind {
|
|
||||||
Success,
|
|
||||||
Warning,
|
|
||||||
Error,
|
|
||||||
Info,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Notification {
|
|
||||||
pub message: String,
|
|
||||||
pub kind: NotificationKind,
|
|
||||||
pub created_at: Instant,
|
|
||||||
pub duration: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Notification {
|
|
||||||
pub fn success(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Success, Duration::from_secs(3))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn warning(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Warning, Duration::from_secs(4))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Error, Duration::from_secs(5))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Info, Duration::from_secs(3))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(message: impl Into<String>, kind: NotificationKind, duration: Duration) -> Self {
|
|
||||||
Self {
|
|
||||||
message: message.into(),
|
|
||||||
kind,
|
|
||||||
created_at: Instant::now(),
|
|
||||||
duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_expired(&self) -> bool {
|
|
||||||
self.created_at.elapsed() >= self.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remaining(&self) -> Duration {
|
|
||||||
self.duration.saturating_sub(self.created_at.elapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self) -> f64 {
|
|
||||||
let elapsed = self.created_at.elapsed().as_secs_f64();
|
|
||||||
let total = self.duration.as_secs_f64();
|
|
||||||
(elapsed / total).min(1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_notification(
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
notification: &Notification,
|
|
||||||
theme: &ResolvedTheme,
|
|
||||||
) {
|
|
||||||
let (prefix, style) = match notification.kind {
|
|
||||||
NotificationKind::Success => ("✓ ", theme.status.success),
|
|
||||||
NotificationKind::Warning => ("⚠ ", theme.status.warning),
|
|
||||||
NotificationKind::Error => ("✗ ", theme.status.error),
|
|
||||||
NotificationKind::Info => ("ℹ ", theme.status.info),
|
|
||||||
};
|
|
||||||
|
|
||||||
let remaining = notification.remaining().as_secs();
|
|
||||||
let progress = notification.progress();
|
|
||||||
|
|
||||||
let mut spans = vec![
|
|
||||||
Span::styled(prefix, style),
|
|
||||||
Span::styled(¬ification.message, theme.text.normal),
|
|
||||||
];
|
|
||||||
|
|
||||||
if remaining > 0 {
|
|
||||||
let bar_width = 10;
|
|
||||||
let filled = ((1.0 - progress) * bar_width as f64) as usize;
|
|
||||||
let empty = bar_width - filled;
|
|
||||||
let bar: String = "█".repeat(filled) + &"░".repeat(empty);
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" [{bar}] {remaining}s"),
|
|
||||||
theme.text.muted,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Line::from(spans));
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_notification_area(
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
notifications: &[Notification],
|
|
||||||
theme: &ResolvedTheme,
|
|
||||||
) {
|
|
||||||
if notifications.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let visible_height = area.height as usize;
|
|
||||||
let start = notifications.len().saturating_sub(visible_height);
|
|
||||||
let visible = ¬ifications[start..];
|
|
||||||
|
|
||||||
for (i, notification) in visible.iter().enumerate() {
|
|
||||||
let row = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y + i as u16,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
render_notification(frame, row, notification, theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
use crate::theme::ResolvedTheme;
|
|
||||||
|
|
||||||
/// Immutable state shared by every component during one render pass.
|
|
||||||
pub struct RenderContext<'a> {
|
|
||||||
pub theme: &'a ResolvedTheme,
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +1,36 @@
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::{
|
controls::{
|
||||||
button::{ActionButton, ButtonIntent, render_button},
|
button::{ActionButton, ButtonIntent, render_button},
|
||||||
choice::{ChoiceKind, render_choice_line},
|
choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
radio_group::{RadioGroup, RadioItem},
|
|
||||||
},
|
},
|
||||||
interaction_result::InteractionResult,
|
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, Screen, UiEvent},
|
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
use ratatool::{DisabledFocusPolicy, RadioGroup, RadioGroupError, RadioItem};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
text::{Line, Text},
|
text::{Line, Text},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use std::any::Any;
|
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
|
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
|
||||||
/// setup choice screen is intentionally closed before its decision is sent,
|
/// setup choice screen is intentionally closed before its decision is sent,
|
||||||
/// so without this the terminal would otherwise be blank during startup.
|
/// so without this the terminal would otherwise be blank during startup.
|
||||||
pub struct DaemonStartingScreen;
|
pub struct DaemonStartingScreen;
|
||||||
impl Screen for DaemonStartingScreen {
|
impl IotaScreen for DaemonStartingScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(
|
||||||
self
|
|
||||||
}
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
fn render(
|
|
||||||
&self,
|
&self,
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
context: &RenderContext<'_>,
|
theme: &ResolvedTheme,
|
||||||
_hits: &mut HitMap,
|
_hits: &mut HitMap,
|
||||||
) {
|
) {
|
||||||
let popup = crate::layout::fit::centered_rect(
|
let popup = ratatool::layout::centered_rect(
|
||||||
area,
|
area,
|
||||||
crate::layout::fit::RequiredSize {
|
ratatool::layout::RequiredSize {
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 5,
|
height: 5,
|
||||||
},
|
},
|
||||||
|
|
@ -52,13 +44,13 @@ impl Screen for DaemonStartingScreen {
|
||||||
Block::default()
|
Block::default()
|
||||||
.title(" Iota daemon ")
|
.title(" Iota daemon ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.normal),
|
.border_style(theme.borders.normal),
|
||||||
),
|
),
|
||||||
popup,
|
popup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, _: &UiEvent) -> IotaInteraction {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +91,7 @@ impl DaemonSetupScreen {
|
||||||
options: Vec<LaunchOption>,
|
options: Vec<LaunchOption>,
|
||||||
message: impl Into<String>,
|
message: impl Into<String>,
|
||||||
sender: oneshot::Sender<DaemonSetupDecision>,
|
sender: oneshot::Sender<DaemonSetupDecision>,
|
||||||
) -> Result<Self, crate::controls::radio_group::RadioGroupError> {
|
) -> Result<Self, RadioGroupError> {
|
||||||
let items: Vec<RadioItem<DaemonLaunchMode>> = options
|
let items: Vec<RadioItem<DaemonLaunchMode>> = options
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|o| RadioItem {
|
.map(|o| RadioItem {
|
||||||
|
|
@ -119,9 +111,9 @@ impl DaemonSetupScreen {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| item.enabled)
|
.find(|item| item.enabled)
|
||||||
.map(|item| item.value)
|
.map(|item| item.value)
|
||||||
.ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?;
|
.ok_or(RadioGroupError::NoEnabledItems)?;
|
||||||
let mut choices = RadioGroup::new(items, None, default)?;
|
let mut choices = RadioGroup::new(items, None, default)?;
|
||||||
choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include);
|
choices.set_focus_policy(DisabledFocusPolicy::Include);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
choices,
|
choices,
|
||||||
focus: Focus::Options,
|
focus: Focus::Options,
|
||||||
|
|
@ -134,15 +126,15 @@ impl DaemonSetupScreen {
|
||||||
let _ = tx.send(d);
|
let _ = tx.send(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn activate(&mut self) -> InteractionResult {
|
fn activate(&mut self) -> IotaInteraction {
|
||||||
match self.focus {
|
match self.focus {
|
||||||
Focus::Options => {
|
Focus::Options => {
|
||||||
self.choices.select_focused();
|
self.choices.select_focused();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Exit => {
|
Focus::Exit => {
|
||||||
self.complete(DaemonSetupDecision::Exit);
|
self.complete(DaemonSetupDecision::Exit);
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
Focus::Action => {
|
Focus::Action => {
|
||||||
let choice = *self.choices.selected();
|
let choice = *self.choices.selected();
|
||||||
|
|
@ -154,9 +146,9 @@ impl DaemonSetupScreen {
|
||||||
.is_some_and(|i| i.enabled)
|
.is_some_and(|i| i.enabled)
|
||||||
{
|
{
|
||||||
self.complete(DaemonSetupDecision::Start(choice));
|
self.complete(DaemonSetupDecision::Start(choice));
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -190,23 +182,17 @@ impl DaemonSetupScreen {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Screen for DaemonSetupScreen {
|
impl IotaScreen for DaemonSetupScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(
|
||||||
self
|
|
||||||
}
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
fn render(
|
|
||||||
&self,
|
&self,
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
context: &RenderContext<'_>,
|
theme: &ResolvedTheme,
|
||||||
_hits: &mut HitMap,
|
_hits: &mut HitMap,
|
||||||
) {
|
) {
|
||||||
let popup = crate::layout::fit::centered_rect(
|
let popup = ratatool::layout::centered_rect(
|
||||||
area,
|
area,
|
||||||
crate::layout::fit::RequiredSize {
|
ratatool::layout::RequiredSize {
|
||||||
width: 68,
|
width: 68,
|
||||||
height: 16,
|
height: 16,
|
||||||
},
|
},
|
||||||
|
|
@ -216,14 +202,18 @@ impl Screen for DaemonSetupScreen {
|
||||||
lines.push(render_choice_line(
|
lines.push(render_choice_line(
|
||||||
&item.label,
|
&item.label,
|
||||||
ChoiceKind::Radio,
|
ChoiceKind::Radio,
|
||||||
self.choices.visual_state(&item.value),
|
{
|
||||||
context.theme,
|
let state = self.choices.visual_state(&item.value);
|
||||||
|
ChoiceVisualState {
|
||||||
|
selected: state.selected,
|
||||||
|
focused: state.focused,
|
||||||
|
enabled: state.enabled,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
theme,
|
||||||
));
|
));
|
||||||
if let Some(reason) = &item.description {
|
if let Some(reason) = &item.description {
|
||||||
lines.push(Line::styled(
|
lines.push(Line::styled(format!(" {reason}"), theme.text.muted));
|
||||||
format!(" {reason}"),
|
|
||||||
context.theme.text.muted,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
|
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
|
||||||
|
|
@ -248,7 +238,7 @@ impl Screen for DaemonSetupScreen {
|
||||||
focused: self.focus == Focus::Exit,
|
focused: self.focus == Focus::Exit,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
let selected = *self.choices.selected();
|
let selected = *self.choices.selected();
|
||||||
let enabled = self
|
let enabled = self
|
||||||
|
|
@ -275,28 +265,29 @@ impl Screen for DaemonSetupScreen {
|
||||||
focused: self.focus == Focus::Action,
|
focused: self.focus == Focus::Action,
|
||||||
enabled,
|
enabled,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.complete(DaemonSetupDecision::Exit);
|
self.complete(DaemonSetupDecision::Exit);
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||||
self.next();
|
self.next();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
|
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
|
||||||
self.previous();
|
self.previous();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
elements::{
|
elements::{
|
||||||
|
|
@ -6,97 +7,97 @@ use crate::{
|
||||||
graph_card::{GRAPHS, GraphCard},
|
graph_card::{GRAPHS, GraphCard},
|
||||||
log_card::LogCard,
|
log_card::LogCard,
|
||||||
},
|
},
|
||||||
interaction_result::InteractionResult,
|
screens::screens::{
|
||||||
ipc_client::{DaemonStatus, IpcConnectionState},
|
AppAction, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint,
|
||||||
render_context::RenderContext,
|
NavDirection, UiEvent,
|
||||||
screens::{
|
|
||||||
overview::OverviewScreen,
|
|
||||||
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
|
|
||||||
},
|
},
|
||||||
ui::UI,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, MouseEventKind};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
widgets::Borders,
|
widgets::Borders,
|
||||||
};
|
};
|
||||||
use tokio::sync::watch;
|
use std::sync::{
|
||||||
|
|
||||||
use std::{
|
|
||||||
any::Any,
|
|
||||||
sync::{
|
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicU16, Ordering},
|
atomic::{AtomicU16, Ordering},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct MainScreen {
|
pub struct MainScreen {
|
||||||
elements: Vec<Box<dyn InteractableElement>>,
|
elements: Vec<Box<dyn InteractableElement>>,
|
||||||
nav_grid: Vec<Vec<Option<usize>>>,
|
focus_graph: ratatool::FocusGraph<MainFocus>,
|
||||||
selected_coords: (usize, usize),
|
focus: MainFocus,
|
||||||
graphs_open: bool,
|
graphs_open: bool,
|
||||||
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
|
||||||
daemon_status_rx: watch::Receiver<DaemonStatus>,
|
|
||||||
layout_width: AtomicU16,
|
layout_width: AtomicU16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
|
enum MainFocus {
|
||||||
|
#[default]
|
||||||
|
Logs,
|
||||||
|
Console,
|
||||||
|
Ram,
|
||||||
|
Cpu,
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MainFocus {
|
||||||
|
const ORDER: [Self; 5] = [Self::Logs, Self::Ram, Self::Cpu, Self::Console, Self::Ping];
|
||||||
|
|
||||||
|
const fn element_index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Logs => 0,
|
||||||
|
Self::Console => 1,
|
||||||
|
Self::Ram => 2,
|
||||||
|
Self::Cpu => 3,
|
||||||
|
Self::Ping => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn is_metric(self) -> bool {
|
||||||
|
matches!(self, Self::Ram | Self::Cpu | Self::Ping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MainScreen {
|
impl MainScreen {
|
||||||
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
|
pub fn new(ipc: Arc<crate::ipc_client::IpcClient>) -> Self {
|
||||||
self.connection_status_rx.clone()
|
|
||||||
}
|
|
||||||
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
|
|
||||||
self.daemon_status_rx.clone()
|
|
||||||
}
|
|
||||||
pub async fn new(ui: Arc<UI>) -> Self {
|
|
||||||
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
||||||
|
|
||||||
let nav_grid = vec![
|
let mut focus_graph = ratatool::FocusGraph::default();
|
||||||
vec![Some(0), Some(2)],
|
focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Down, MainFocus::Console);
|
||||||
vec![Some(0), Some(3)],
|
focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Right, MainFocus::Ram);
|
||||||
vec![Some(1), Some(4)],
|
focus_graph.connect_bidirectional(MainFocus::Ram, NavDirection::Down, MainFocus::Cpu);
|
||||||
];
|
focus_graph.connect_bidirectional(MainFocus::Cpu, NavDirection::Down, MainFocus::Ping);
|
||||||
|
focus_graph.connect_bidirectional(MainFocus::Console, NavDirection::Right, MainFocus::Ping);
|
||||||
|
|
||||||
let state = ui
|
let state = ipc.state();
|
||||||
.client_state()
|
|
||||||
.await
|
|
||||||
.expect("MainScreen requires an attached daemon");
|
|
||||||
let mut log_card = LogCard::new(state.clone());
|
let mut log_card = LogCard::new(state.clone());
|
||||||
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
|
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
|
||||||
let ipc = ui
|
|
||||||
.ipc()
|
|
||||||
.await
|
|
||||||
.expect("MainScreen requires an attached daemon");
|
|
||||||
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
|
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
|
||||||
console_card.set_joins(Borders::TOP);
|
console_card.set_joins(Borders::TOP);
|
||||||
|
|
||||||
elements.push(Box::new(log_card));
|
elements.push(Box::new(log_card));
|
||||||
elements.push(Box::new(console_card));
|
elements.push(Box::new(console_card));
|
||||||
|
|
||||||
let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into());
|
let mut ram_graph = GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into());
|
||||||
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
||||||
elements.push(Box::new(ram_graph));
|
elements.push(Box::new(ram_graph));
|
||||||
let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into());
|
let mut cpu_graph = GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into());
|
||||||
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
|
||||||
cpu_graph.set_joins(Borders::TOP);
|
cpu_graph.set_joins(Borders::TOP);
|
||||||
elements.push(Box::new(cpu_graph));
|
elements.push(Box::new(cpu_graph));
|
||||||
let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into());
|
let mut ping_graph = GraphCard::new(state, GRAPHS::Ping, "Ping".into());
|
||||||
ping_graph.set_joins(Borders::TOP);
|
ping_graph.set_joins(Borders::TOP);
|
||||||
elements.push(Box::new(ping_graph));
|
elements.push(Box::new(ping_graph));
|
||||||
|
|
||||||
let graphs_open = true;
|
let graphs_open = true;
|
||||||
|
|
||||||
let connection_status_rx = ipc.connection_status();
|
|
||||||
let daemon_status_rx = ipc.daemon_status();
|
|
||||||
|
|
||||||
let mut screen = MainScreen {
|
let mut screen = MainScreen {
|
||||||
elements,
|
elements,
|
||||||
nav_grid,
|
focus_graph,
|
||||||
selected_coords: (1, 0),
|
focus: MainFocus::Logs,
|
||||||
graphs_open,
|
graphs_open,
|
||||||
connection_status_rx,
|
|
||||||
daemon_status_rx,
|
|
||||||
layout_width: AtomicU16::new(0),
|
layout_width: AtomicU16::new(0),
|
||||||
};
|
};
|
||||||
screen.focus_current();
|
screen.focus_current();
|
||||||
|
|
@ -104,106 +105,43 @@ impl MainScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn focus_current(&mut self) {
|
fn focus_current(&mut self) {
|
||||||
let (y, x) = self.selected_coords;
|
if let Some(element) = self.elements.get_mut(self.focus.element_index())
|
||||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
|
&& element.can_focus()
|
||||||
if let Some(element) = self.elements.get_mut(*index) {
|
{
|
||||||
if element.can_focus() {
|
|
||||||
element.focus(true);
|
element.focus(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unfocus_current(&mut self, y: usize, x: usize) {
|
fn unfocus_current(&mut self) {
|
||||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
|
if let Some(element) = self.elements.get_mut(self.focus.element_index()) {
|
||||||
if let Some(element) = self.elements.get_mut(*index) {
|
|
||||||
element.focus(false);
|
element.focus(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn navigate(&mut self, direction: NavDirection) {
|
fn navigate(&mut self, direction: NavDirection) {
|
||||||
let (current_row, current_col) = self.selected_coords;
|
let Some(target) = self.focus_graph.target(&self.focus, direction).copied() else {
|
||||||
let current_element = self.nav_grid[current_row][current_col];
|
return;
|
||||||
|
|
||||||
self.unfocus_current(current_row, current_col);
|
|
||||||
|
|
||||||
let (delta_row, delta_col) = match direction {
|
|
||||||
NavDirection::Up => (-1isize, 0),
|
|
||||||
NavDirection::Down => (1, 0),
|
|
||||||
NavDirection::Left => (0, -1),
|
|
||||||
NavDirection::Right => (0, 1),
|
|
||||||
_ => (0, 0),
|
|
||||||
};
|
};
|
||||||
|
if target.is_metric()
|
||||||
let mut next_row = current_row as isize;
|
&& (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70)
|
||||||
let mut next_col = current_col as isize;
|
{
|
||||||
|
|
||||||
loop {
|
|
||||||
next_row += delta_row;
|
|
||||||
next_col += delta_col;
|
|
||||||
|
|
||||||
if next_row < 0 || next_col < 0 {
|
|
||||||
self.selected_coords = (
|
|
||||||
(next_row - delta_row) as usize,
|
|
||||||
(next_col - delta_col) as usize,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let next_row_u = next_row as usize;
|
|
||||||
let next_col_u = next_col as usize;
|
|
||||||
|
|
||||||
if next_row_u >= self.nav_grid.len() {
|
|
||||||
self.selected_coords = (
|
|
||||||
(next_row - delta_row) as usize,
|
|
||||||
(next_col - delta_col) as usize,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(row) = self.nav_grid.get(next_row_u) {
|
|
||||||
if next_col_u >= row.len() {
|
|
||||||
self.selected_coords = (
|
|
||||||
(next_row - delta_row) as usize,
|
|
||||||
(next_col - delta_col) as usize,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(next_element) = row[next_col_u] {
|
|
||||||
if Some(next_element) != current_element {
|
|
||||||
self.selected_coords = (next_row_u, next_col_u);
|
|
||||||
self.focus_current();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
self.unfocus_current();
|
||||||
}
|
self.focus = target;
|
||||||
}
|
|
||||||
|
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle focus between unique elements in the navigation grid.
|
/// Cycle focus between unique elements in the navigation grid.
|
||||||
fn navigate_focus(&mut self, forward: bool) {
|
fn navigate_focus(&mut self, forward: bool) {
|
||||||
// Collect unique elements in grid order.
|
let positions = MainFocus::ORDER
|
||||||
let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col)
|
.into_iter()
|
||||||
let mut seen: Vec<Option<usize>> = Vec::new();
|
.filter(|focus| {
|
||||||
for (y, row) in self.nav_grid.iter().enumerate() {
|
!focus.is_metric()
|
||||||
for (x, elem_opt) in row.iter().enumerate() {
|
|| (self.graphs_open && self.layout_width.load(Ordering::Relaxed) >= 70)
|
||||||
if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) {
|
})
|
||||||
continue;
|
.collect::<Vec<_>>();
|
||||||
}
|
let current_pos = positions.iter().position(|focus| *focus == self.focus);
|
||||||
if elem_opt.is_some() && !seen.contains(elem_opt) {
|
|
||||||
seen.push(*elem_opt);
|
|
||||||
positions.push((y, x));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let current = self.selected_coords;
|
|
||||||
let current_pos = positions
|
|
||||||
.iter()
|
|
||||||
.position(|&(r, c)| r == current.0 && c == current.1);
|
|
||||||
|
|
||||||
let next_pos = if let Some(idx) = current_pos {
|
let next_pos = if let Some(idx) = current_pos {
|
||||||
if forward {
|
if forward {
|
||||||
|
|
@ -215,25 +153,17 @@ impl MainScreen {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
self.unfocus_current();
|
||||||
self.selected_coords = positions[next_pos];
|
self.focus = positions[next_pos];
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for MainScreen {
|
impl IotaScreen for MainScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, hits: &mut HitMap) {
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
|
|
||||||
self.layout_width.store(rect.width, Ordering::Relaxed);
|
self.layout_width.store(rect.width, Ordering::Relaxed);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
|
ratatui::widgets::Block::default().style(theme.surfaces.canvas),
|
||||||
rect,
|
rect,
|
||||||
);
|
);
|
||||||
let inner = rect;
|
let inner = rect;
|
||||||
|
|
@ -252,9 +182,9 @@ impl Screen for MainScreen {
|
||||||
|
|
||||||
let left_area = horizontal_chunks[0];
|
let left_area = horizontal_chunks[0];
|
||||||
let right_area = horizontal_chunks[1];
|
let right_area = horizontal_chunks[1];
|
||||||
hits.register(left_area, AppAction::FocusLogs);
|
hits.register(left_area, IotaMessage::Action(AppAction::FocusLogs));
|
||||||
if metrics_visible {
|
if metrics_visible {
|
||||||
hits.register(right_area, AppAction::FocusMetrics);
|
hits.register(right_area, IotaMessage::Action(AppAction::FocusMetrics));
|
||||||
}
|
}
|
||||||
|
|
||||||
if inner.width >= 70 {
|
if inner.width >= 70 {
|
||||||
|
|
@ -277,21 +207,24 @@ impl Screen for MainScreen {
|
||||||
focused: false,
|
focused: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
|
);
|
||||||
|
hits.register(
|
||||||
|
metrics_button,
|
||||||
|
IotaMessage::Action(AppAction::ToggleMetrics),
|
||||||
);
|
);
|
||||||
hits.register(metrics_button, AppAction::ToggleMetrics);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let left_rows =
|
let left_rows =
|
||||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
||||||
hits.register(left_rows[1], AppAction::FocusConsole);
|
hits.register(left_rows[1], IotaMessage::Action(AppAction::FocusConsole));
|
||||||
|
|
||||||
if let Some(log) = self.elements.get(0) {
|
if let Some(log) = self.elements.first() {
|
||||||
log.as_element().render(f, left_rows[0], context);
|
log.as_element().render(f, left_rows[0], theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(console) = self.elements.get(1) {
|
if let Some(console) = self.elements.get(1) {
|
||||||
console.as_element().render(f, left_rows[1], context);
|
console.as_element().render(f, left_rows[1], theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
let graph_elements: Vec<_> = self
|
let graph_elements: Vec<_> = self
|
||||||
|
|
@ -310,90 +243,98 @@ impl Screen for MainScreen {
|
||||||
.split(right_area);
|
.split(right_area);
|
||||||
|
|
||||||
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
|
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
|
||||||
el.as_element().render(f, *area, context);
|
el.as_element().render(f, *area, theme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
|
if let UiEvent::Pointer {
|
||||||
|
event,
|
||||||
|
hit: Some(IotaMessage::Action(AppAction::FocusLogs)),
|
||||||
|
} = &event
|
||||||
|
{
|
||||||
|
let Some(log) = self
|
||||||
|
.elements
|
||||||
|
.get_mut(0)
|
||||||
|
.and_then(|element| element.as_any_mut().downcast_mut::<LogCard>())
|
||||||
|
else {
|
||||||
|
return IotaInteraction::Unhandled;
|
||||||
|
};
|
||||||
|
match event.kind {
|
||||||
|
MouseEventKind::ScrollUp => log.scroll_older(),
|
||||||
|
MouseEventKind::ScrollDown => log.scroll_newer(),
|
||||||
|
_ => return IotaInteraction::Unhandled,
|
||||||
|
}
|
||||||
|
self.unfocus_current();
|
||||||
|
self.focus = MainFocus::Logs;
|
||||||
|
self.focus_current();
|
||||||
|
return IotaInteraction::Changed;
|
||||||
|
}
|
||||||
if let UiEvent::Paste(text) = &event {
|
if let UiEvent::Paste(text) = &event {
|
||||||
if self.selected_coords == (2, 0) {
|
if self.focus == MainFocus::Console
|
||||||
if let Some(console) = self
|
&& let Some(console) = self
|
||||||
.elements
|
.elements
|
||||||
.get_mut(1)
|
.get_mut(1)
|
||||||
.and_then(|element| element.as_any_mut().downcast_mut::<ConsoleCard>())
|
.and_then(|element| element.as_any_mut().downcast_mut::<ConsoleCard>())
|
||||||
{
|
{
|
||||||
console.handle_paste(text);
|
console.handle_paste(text);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
|
return IotaInteraction::Unhandled;
|
||||||
}
|
}
|
||||||
return InteractionResult::Unhandled;
|
if let UiEvent::Resize { width, .. } = &event {
|
||||||
}
|
|
||||||
if let UiEvent::Resize(width, _) = &event {
|
|
||||||
self.layout_width.store(*width, Ordering::Relaxed);
|
self.layout_width.store(*width, Ordering::Relaxed);
|
||||||
if *width < 70 && self.selected_coords.1 == 1 {
|
if *width < 70 && self.focus.is_metric() {
|
||||||
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
self.unfocus_current();
|
||||||
self.selected_coords = (0, 0);
|
self.focus = MainFocus::Logs;
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
}
|
}
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
// A focused console consumes text and cursor keys before dashboard
|
// A focused console consumes text and cursor keys before dashboard
|
||||||
// shortcuts; commands such as `users` must remain typeable.
|
// shortcuts; commands such as `users` must remain typeable.
|
||||||
if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
|
if self.focus == MainFocus::Console
|
||||||
|
&& !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
|
||||||
|
&& let Some(console) = self.elements.get_mut(1)
|
||||||
{
|
{
|
||||||
if let Some(console) = self.elements.get_mut(1) {
|
|
||||||
return console.interact(event);
|
return console.interact(event);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.navigate_focus(true);
|
self.navigate_focus(true);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
KeyCode::BackTab => {
|
KeyCode::BackTab => {
|
||||||
self.navigate_focus(false);
|
self.navigate_focus(false);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||||
let conn_rx = self.connection_status_rx.clone();
|
return IotaInteraction::Effect(IotaEffect::OpenOverview);
|
||||||
let daemon_rx = self.daemon_status_rx.clone();
|
|
||||||
return InteractionResult::OpenScreen {
|
|
||||||
screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('u') | KeyCode::Char('U') => {
|
KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||||
return InteractionResult::AppTask {
|
return IotaInteraction::Effect(IotaEffect::OpenUsers);
|
||||||
task: Box::pin(async {
|
|
||||||
UiEvent::App(crate::screens::screens::AppEvent::OpenUsers)
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('m') | KeyCode::Char('M') => {
|
KeyCode::Char('m') | KeyCode::Char('M') => {
|
||||||
return InteractionResult::AppTask {
|
return IotaInteraction::Effect(IotaEffect::OpenMetrics);
|
||||||
task: Box::pin(async {
|
|
||||||
UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics)
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
|
KeyCode::Enter | KeyCode::Char(' ') if self.focus.is_metric() => {
|
||||||
self.graphs_open = !self.graphs_open;
|
self.graphs_open = !self.graphs_open;
|
||||||
for element in self.elements.iter_mut() {
|
for element in self.elements.iter_mut() {
|
||||||
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
|
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
|
||||||
graph.set_open(self.graphs_open);
|
graph.set_open(self.graphs_open);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let (y, x) = self.selected_coords;
|
if let Some(el) = self.elements.get_mut(self.focus.element_index()) {
|
||||||
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
|
|
||||||
if let Some(el) = self.elements.get_mut(*index) {
|
|
||||||
let result = el.interact(event);
|
let result = el.interact(event);
|
||||||
if matches!(result, InteractionResult::Unhandled) {
|
if matches!(result, IotaInteraction::Unhandled) {
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Up => self.navigate(NavDirection::Up),
|
KeyCode::Up => self.navigate(NavDirection::Up),
|
||||||
KeyCode::Down => self.navigate(NavDirection::Down),
|
KeyCode::Down => self.navigate(NavDirection::Down),
|
||||||
|
|
@ -406,11 +347,10 @@ impl Screen for MainScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
|
||||||
match action {
|
match action {
|
||||||
AppAction::ToggleMetrics => {
|
AppAction::ToggleMetrics => {
|
||||||
self.graphs_open = !self.graphs_open;
|
self.graphs_open = !self.graphs_open;
|
||||||
|
|
@ -419,37 +359,33 @@ impl Screen for MainScreen {
|
||||||
graph.set_open(self.graphs_open);
|
graph.set_open(self.graphs_open);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
|
||||||
AppAction::OpenOverview => {
|
|
||||||
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o'))))
|
|
||||||
}
|
|
||||||
AppAction::OpenUsers => {
|
|
||||||
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u'))))
|
|
||||||
}
|
}
|
||||||
|
AppAction::OpenOverview => IotaInteraction::Effect(IotaEffect::OpenOverview),
|
||||||
|
AppAction::OpenUsers => IotaInteraction::Effect(IotaEffect::OpenUsers),
|
||||||
AppAction::FocusLogs => {
|
AppAction::FocusLogs => {
|
||||||
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
self.unfocus_current();
|
||||||
self.selected_coords = (0, 0);
|
self.focus = MainFocus::Logs;
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
AppAction::FocusConsole => {
|
AppAction::FocusConsole => {
|
||||||
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
self.unfocus_current();
|
||||||
self.selected_coords = (2, 0);
|
self.focus = MainFocus::Console;
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
AppAction::FocusMetrics => {
|
AppAction::FocusMetrics => {
|
||||||
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
self.unfocus_current();
|
||||||
self.selected_coords = (0, 1);
|
self.focus = MainFocus::Ram;
|
||||||
self.focus_current();
|
self.focus_current();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
if self.selected_coords == (2, 0) {
|
if self.focus == MainFocus::Console {
|
||||||
vec![
|
vec![
|
||||||
KeyHint {
|
KeyHint {
|
||||||
keys: "Enter",
|
keys: "Enter",
|
||||||
|
|
@ -468,7 +404,7 @@ impl Screen for MainScreen {
|
||||||
action: "Header",
|
action: "Header",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} else if self.selected_coords == (0, 0) {
|
} else if self.focus == MainFocus::Logs {
|
||||||
vec![
|
vec![
|
||||||
KeyHint {
|
KeyHint {
|
||||||
keys: "J/K",
|
keys: "J/K",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
use crossterm::event::{self, Event, KeyCode};
|
use crate::{
|
||||||
|
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
|
||||||
|
theme::{ResolvedTheme, TextSemantics},
|
||||||
|
};
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal,
|
|
||||||
prelude::*,
|
prelude::*,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use std::{any::Any, time::Duration};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, Screen, UiEvent},
|
|
||||||
theme::{ResolvedTheme, TextSemantics, ThemeName},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct FileViewer {
|
pub struct FileViewer {
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -21,25 +16,19 @@ pub struct FileViewer {
|
||||||
scroll_x: u16,
|
scroll_x: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for FileViewer {
|
impl IotaScreen for FileViewer {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||||
self
|
self.draw(f, rect, theme);
|
||||||
}
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
self.draw(f, rect, context.theme);
|
let event = event.clone();
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc => {
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
return InteractionResult::CloseScreen;
|
return IotaInteraction::Pop;
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
|
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
|
||||||
|
|
@ -52,7 +41,7 @@ impl Screen for FileViewer {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
InteractionResult::Unhandled
|
IotaInteraction::Unhandled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,26 +54,6 @@ impl FileViewer {
|
||||||
scroll_x: 0,
|
scroll_x: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal {
|
|
||||||
loop {
|
|
||||||
terminal
|
|
||||||
.draw(|f| {
|
|
||||||
let area = f.area();
|
|
||||||
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(100)).unwrap() {
|
|
||||||
let ev = event::read().unwrap();
|
|
||||||
self.handle_event(&ev);
|
|
||||||
|
|
||||||
if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
terminal
|
|
||||||
}
|
|
||||||
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
||||||
use ratatui::text::Text;
|
use ratatui::text::Text;
|
||||||
|
|
||||||
|
|
@ -147,20 +116,6 @@ impl FileViewer {
|
||||||
|
|
||||||
f.render_widget(paragraph, area);
|
f.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_event(&mut self, event: &Event) {
|
|
||||||
if let Event::Key(key) = event {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
|
|
||||||
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
|
|
||||||
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
|
|
||||||
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
|
|
||||||
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
|
|
||||||
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
|
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
|
||||||
let mut lines_vec = Vec::new();
|
let mut lines_vec = Vec::new();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use std::any::Any;
|
|
||||||
|
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -7,15 +5,15 @@ use ratatui::{
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
elements::{
|
elements::{
|
||||||
elements::Element,
|
elements::Element,
|
||||||
graph_card::{GRAPHS, GraphCard},
|
graph_card::{GRAPHS, GraphCard},
|
||||||
},
|
},
|
||||||
interaction_result::InteractionResult,
|
screens::screens::{
|
||||||
render_context::RenderContext,
|
AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent,
|
||||||
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
|
},
|
||||||
ui::UI,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
|
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
|
||||||
|
|
@ -26,18 +24,17 @@ pub struct MetricsScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MetricsScreen {
|
impl MetricsScreen {
|
||||||
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
|
pub fn from_state(state: iota_state::ClientState) -> Self {
|
||||||
let state = ui.client_state().await?;
|
|
||||||
let mut screen = Self {
|
let mut screen = Self {
|
||||||
graphs: vec![
|
graphs: vec![
|
||||||
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()),
|
GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into()),
|
||||||
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()),
|
GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into()),
|
||||||
GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()),
|
GraphCard::new(state, GRAPHS::Ping, "Ping".into()),
|
||||||
],
|
],
|
||||||
range_index: 0,
|
range_index: 0,
|
||||||
};
|
};
|
||||||
screen.apply_range();
|
screen.apply_range();
|
||||||
Some(screen)
|
screen
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_range(&mut self) {
|
fn apply_range(&mut self) {
|
||||||
|
|
@ -54,26 +51,18 @@ impl MetricsScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for MetricsScreen {
|
impl IotaScreen for MetricsScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(
|
|
||||||
&self,
|
&self,
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
context: &RenderContext<'_>,
|
theme: &ResolvedTheme,
|
||||||
hits: &mut HitMap,
|
hits: &mut HitMap,
|
||||||
) {
|
) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Metrics ")
|
.title(" Metrics ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.normal);
|
.border_style(theme.borders.normal);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
let rows = Layout::vertical([
|
let rows = Layout::vertical([
|
||||||
|
|
@ -88,32 +77,31 @@ impl Screen for MetricsScreen {
|
||||||
"Range: {} ({} samples) Left/Right to change",
|
"Range: {} ({} samples) Left/Right to change",
|
||||||
RANGES[self.range_index].1, RANGES[self.range_index].0
|
RANGES[self.range_index].1, RANGES[self.range_index].0
|
||||||
))
|
))
|
||||||
.style(context.theme.text.heading),
|
.style(theme.text.heading),
|
||||||
rows[0],
|
rows[0],
|
||||||
);
|
);
|
||||||
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
|
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
|
||||||
graph.render(frame, *graph_area, context);
|
graph.render(frame, *graph_area, theme);
|
||||||
}
|
}
|
||||||
hits.register(rows[0], AppAction::OpenMetrics);
|
hits.register(rows[0], IotaMessage::Action(AppAction::OpenMetrics));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let UiEvent::Key(key) = event else {
|
let UiEvent::Key(key) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
self.change_range(-1);
|
self.change_range(-1);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
self.change_range(1);
|
self.change_range(1);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
|
||||||
InteractionResult::CloseScreen
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
|
||||||
_ => InteractionResult::Unhandled,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
interaction_result::InteractionResult,
|
|
||||||
ipc_client::{DaemonStatus, IpcConnectionState},
|
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||||
render_context::RenderContext,
|
screens::screens::{
|
||||||
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
|
AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|
@ -12,10 +13,7 @@ use ratatui::{
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
any::Any,
|
|
||||||
sync::atomic::{AtomicUsize, Ordering},
|
|
||||||
};
|
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
pub struct OverviewScreen {
|
pub struct OverviewScreen {
|
||||||
|
|
@ -175,22 +173,15 @@ fn truncate_id(id: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for OverviewScreen {
|
impl IotaScreen for OverviewScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||||
self
|
|
||||||
}
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Overview ")
|
.title(" Overview ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.normal)
|
.border_style(theme.borders.normal)
|
||||||
.title_style(context.theme.borders.title);
|
.title_style(theme.borders.title);
|
||||||
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
let inner = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme)
|
crate::controls::panel::render_panel(f, rect, "Overview", false, theme)
|
||||||
} else {
|
} else {
|
||||||
let inner = block.inner(rect);
|
let inner = block.inner(rect);
|
||||||
f.render_widget(block, rect);
|
f.render_widget(block, rect);
|
||||||
|
|
@ -202,7 +193,7 @@ impl Screen for OverviewScreen {
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
let lines = self.build_lines(context.theme);
|
let lines = self.build_lines(theme);
|
||||||
self.content_height.store(lines.len(), Ordering::Relaxed);
|
self.content_height.store(lines.len(), Ordering::Relaxed);
|
||||||
self.viewport_height
|
self.viewport_height
|
||||||
.store(rows[0].height as usize, Ordering::Relaxed);
|
.store(rows[0].height as usize, Ordering::Relaxed);
|
||||||
|
|
@ -219,30 +210,29 @@ impl Screen for OverviewScreen {
|
||||||
focused: self._focus == Focus::Back,
|
focused: self._focus == Focus::Back,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
_hits.register(rows[1], AppAction::Back);
|
_hits.register(rows[1], IotaMessage::Action(AppAction::Back));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
let max = self
|
let max = self
|
||||||
.content_height
|
.content_height
|
||||||
.load(Ordering::Relaxed)
|
.load(Ordering::Relaxed)
|
||||||
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||||
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
|
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
|
@ -251,32 +241,32 @@ impl Screen for OverviewScreen {
|
||||||
.load(Ordering::Relaxed)
|
.load(Ordering::Relaxed)
|
||||||
.saturating_sub(page);
|
.saturating_sub(page);
|
||||||
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
|
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::PageUp => {
|
KeyCode::PageUp => {
|
||||||
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(page);
|
self.scroll_offset = self.scroll_offset.saturating_sub(page);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::End => {
|
KeyCode::End => {
|
||||||
self.scroll_offset = self
|
self.scroll_offset = self
|
||||||
.content_height
|
.content_height
|
||||||
.load(Ordering::Relaxed)
|
.load(Ordering::Relaxed)
|
||||||
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
|
||||||
if action == AppAction::Back {
|
if action == AppAction::Back {
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Unhandled
|
IotaInteraction::Unhandled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,21 @@
|
||||||
use std::any::Any;
|
use std::{fmt, path::PathBuf};
|
||||||
|
|
||||||
use crossterm::event::{KeyEvent, MouseEvent};
|
|
||||||
use ratatui::{Frame, layout::Rect};
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
use crate::theme::{CliOutputFormat, ResolvedTheme, TerminalPolicy, ThemeName};
|
||||||
|
|
||||||
/// All terminal input that can affect the UI. Keeping this as one type makes
|
|
||||||
/// it impossible for screens to accidentally ignore a newly supported event.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum UiEvent {
|
|
||||||
Key(KeyEvent),
|
|
||||||
Mouse(MouseEvent),
|
|
||||||
Paste(String),
|
|
||||||
Resize(u16, u16),
|
|
||||||
App(AppEvent),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Completion of background UI work. Keeping it in the regular event stream
|
|
||||||
/// gives screens an explicit success/failure path instead of detached tasks.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
OpenUsers,
|
|
||||||
OpenMetrics,
|
|
||||||
ApplyTheme {
|
|
||||||
theme: crate::theme::ThemeName,
|
|
||||||
persist: bool,
|
|
||||||
},
|
|
||||||
SaveSettings {
|
|
||||||
theme: crate::theme::ThemeName,
|
|
||||||
color: crate::theme::TerminalPolicy,
|
|
||||||
unicode: crate::theme::TerminalPolicy,
|
|
||||||
cli_output: crate::theme::CliOutputFormat,
|
|
||||||
cli_require_confirmation: bool,
|
|
||||||
},
|
|
||||||
ThemeSaved(Result<(), String>),
|
ThemeSaved(Result<(), String>),
|
||||||
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
||||||
TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
|
TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
|
||||||
UserOperationFinished(Result<String, String>),
|
UserOperationFinished(Result<String, String>),
|
||||||
CredentialExportFinished(Result<String, String>),
|
CredentialExportFinished(Result<String, String>),
|
||||||
RegenerateKeysRequested,
|
|
||||||
KeysRegenerated(Result<(), String>),
|
KeysRegenerated(Result<(), String>),
|
||||||
|
TermsLoaded {
|
||||||
|
kind: iota_terms::TermsType,
|
||||||
|
result: Result<String, String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -65,61 +41,122 @@ pub enum AppAction {
|
||||||
RegenerateKeys,
|
RegenerateKeys,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum IotaEffect {
|
||||||
|
OpenUsers,
|
||||||
|
OpenMetrics,
|
||||||
|
OpenOverview,
|
||||||
|
ApplyTheme {
|
||||||
|
theme: ThemeName,
|
||||||
|
persist: bool,
|
||||||
|
},
|
||||||
|
SaveSettings {
|
||||||
|
theme: ThemeName,
|
||||||
|
color: TerminalPolicy,
|
||||||
|
unicode: TerminalPolicy,
|
||||||
|
cli_output: CliOutputFormat,
|
||||||
|
cli_require_confirmation: bool,
|
||||||
|
},
|
||||||
|
RegenerateKeys,
|
||||||
|
InspectTu {
|
||||||
|
credential: iota_ipc::SecretString,
|
||||||
|
},
|
||||||
|
AttachUserFromTu {
|
||||||
|
credential: iota_ipc::SecretString,
|
||||||
|
},
|
||||||
|
RunUserOperation {
|
||||||
|
action: crate::screens::users::model::UserAction,
|
||||||
|
user_id: i64,
|
||||||
|
},
|
||||||
|
ExportCredential {
|
||||||
|
user_id: i64,
|
||||||
|
destination: PathBuf,
|
||||||
|
},
|
||||||
|
CreateUser {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
CreateInvitation {
|
||||||
|
authority: iota_ipc::InvitationAuthority,
|
||||||
|
},
|
||||||
|
RefreshUsers,
|
||||||
|
LoadNewestTerms {
|
||||||
|
kind: iota_terms::TermsType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum IotaMessage {
|
||||||
|
Action(AppAction),
|
||||||
|
Effect(IotaEffect),
|
||||||
|
Event(AppEvent),
|
||||||
|
Invalidate,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type UiEvent = ratatool::UiEvent<IotaMessage>;
|
||||||
|
pub type HitMap = ratatool::HitMap<IotaMessage>;
|
||||||
|
pub type NavDirection = ratatool::Direction;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct KeyHint {
|
pub struct KeyHint {
|
||||||
pub keys: &'static str,
|
pub keys: &'static str,
|
||||||
pub action: &'static str,
|
pub action: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub enum IotaInteraction {
|
||||||
pub struct HitRegion {
|
Unhandled,
|
||||||
pub area: Rect,
|
Changed,
|
||||||
pub action: AppAction,
|
Push {
|
||||||
|
screen: Box<dyn IotaScreen>,
|
||||||
|
presentation: ratatool::ScreenPresentation,
|
||||||
|
},
|
||||||
|
Pop,
|
||||||
|
Effect(IotaEffect),
|
||||||
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
impl fmt::Debug for IotaInteraction {
|
||||||
pub struct HitMap {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
regions: Vec<HitRegion>,
|
match self {
|
||||||
}
|
Self::Unhandled => formatter.write_str("Unhandled"),
|
||||||
|
Self::Changed => formatter.write_str("Changed"),
|
||||||
impl HitMap {
|
Self::Push { presentation, .. } => {
|
||||||
pub fn register(&mut self, area: Rect, action: AppAction) {
|
formatter.debug_tuple("Push").field(presentation).finish()
|
||||||
self.regions.push(HitRegion { area, action });
|
}
|
||||||
|
Self::Pop => formatter.write_str("Pop"),
|
||||||
|
Self::Effect(effect) => formatter.debug_tuple("Effect").field(effect).finish(),
|
||||||
|
Self::Exit => formatter.write_str("Exit"),
|
||||||
}
|
}
|
||||||
pub fn action_at(&self, column: u16, row: u16) -> Option<AppAction> {
|
|
||||||
self.regions
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|region| {
|
|
||||||
column >= region.area.x
|
|
||||||
&& column < region.area.x.saturating_add(region.area.width)
|
|
||||||
&& row >= region.area.y
|
|
||||||
&& row < region.area.y.saturating_add(region.area.height)
|
|
||||||
})
|
|
||||||
.map(|region| region.action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub trait IotaScreen: Send {
|
||||||
pub enum NavDirection {
|
fn render(
|
||||||
Up,
|
&mut self,
|
||||||
Down,
|
ui: &mut ratatool::UiFrame<'_, '_, IotaMessage>,
|
||||||
Left,
|
area: Rect,
|
||||||
Right,
|
theme: &ResolvedTheme,
|
||||||
|
) {
|
||||||
Next,
|
let mut hits = HitMap::new();
|
||||||
Prev,
|
self.render_legacy(ui.frame(), area, theme, &mut hits);
|
||||||
}
|
for region in hits.regions() {
|
||||||
|
ui.hit_map().register(region.area, region.action.clone());
|
||||||
pub trait Screen: Send + Sync + Any {
|
|
||||||
fn as_any(&self) -> &dyn Any;
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap);
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
|
|
||||||
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
|
|
||||||
InteractionResult::Unhandled
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_legacy(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
theme: &ResolvedTheme,
|
||||||
|
hits: &mut HitMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction;
|
||||||
|
|
||||||
|
fn handle_action(&mut self, _action: AppAction) -> IotaInteraction {
|
||||||
|
IotaInteraction::Unhandled
|
||||||
|
}
|
||||||
|
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
vec![
|
vec![
|
||||||
KeyHint {
|
KeyHint {
|
||||||
|
|
@ -141,3 +178,7 @@ pub trait Screen: Send + Sync + Any {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn action_message(action: AppAction) -> IotaMessage {
|
||||||
|
IotaMessage::Action(action)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use std::any::Any;
|
|
||||||
|
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -8,11 +6,13 @@ use ratatui::{
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
interaction_result::InteractionResult,
|
screens::screens::{
|
||||||
render_context::RenderContext,
|
AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint,
|
||||||
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
UiEvent,
|
||||||
|
},
|
||||||
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
|
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68,11 +68,9 @@ impl SettingsScreen {
|
||||||
ThemeName::ALL[self.selected]
|
ThemeName::ALL[self.selected]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply(&self, persist: bool) -> InteractionResult {
|
fn apply(&self, persist: bool) -> IotaInteraction {
|
||||||
let theme = self.selected_theme();
|
let theme = self.selected_theme();
|
||||||
InteractionResult::AppTask {
|
IotaInteraction::Effect(IotaEffect::ApplyTheme { theme, persist })
|
||||||
task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
|
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
|
||||||
|
|
@ -103,18 +101,16 @@ impl SettingsScreen {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activate(&mut self) -> InteractionResult {
|
fn activate(&mut self) -> IotaInteraction {
|
||||||
if self.pending {
|
if self.pending {
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
if let Some(dialog) = self.dialog.take() {
|
if let Some(dialog) = self.dialog.take() {
|
||||||
match dialog {
|
match dialog {
|
||||||
Dialog::ConfirmRegenerateKeys => {
|
Dialog::ConfirmRegenerateKeys => {
|
||||||
self.pending = true;
|
self.pending = true;
|
||||||
self.message = "Regenerating keys…".into();
|
self.message = "Regenerating keys…".into();
|
||||||
return InteractionResult::AppTask {
|
return IotaInteraction::Effect(IotaEffect::RegenerateKeys);
|
||||||
task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +120,7 @@ impl SettingsScreen {
|
||||||
}
|
}
|
||||||
Focus::CliOutput => {
|
Focus::CliOutput => {
|
||||||
self.message = "Output format updated.".into();
|
self.message = "Output format updated.".into();
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
Focus::CliConfirm => {
|
Focus::CliConfirm => {
|
||||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
self.cli_require_confirmation = !self.cli_require_confirmation;
|
||||||
|
|
@ -136,53 +132,41 @@ impl SettingsScreen {
|
||||||
"Off"
|
"Off"
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
Focus::RegenerateKeys => {
|
Focus::RegenerateKeys => {
|
||||||
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
Focus::Back => return InteractionResult::CloseScreen,
|
Focus::Back => return IotaInteraction::Pop,
|
||||||
}
|
}
|
||||||
let theme = self.selected_theme();
|
let theme = self.selected_theme();
|
||||||
let color = self.color;
|
let color = self.color;
|
||||||
let unicode = self.unicode;
|
let unicode = self.unicode;
|
||||||
let cli_output = self.cli_output;
|
let cli_output = self.cli_output;
|
||||||
let cli_require_confirmation = self.cli_require_confirmation;
|
let cli_require_confirmation = self.cli_require_confirmation;
|
||||||
InteractionResult::AppTask {
|
IotaInteraction::Effect(IotaEffect::SaveSettings {
|
||||||
task: Box::pin(async move {
|
|
||||||
UiEvent::App(AppEvent::SaveSettings {
|
|
||||||
theme,
|
theme,
|
||||||
color,
|
color,
|
||||||
unicode,
|
unicode,
|
||||||
cli_output,
|
cli_output,
|
||||||
cli_require_confirmation,
|
cli_require_confirmation,
|
||||||
})
|
})
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for SettingsScreen {
|
impl IotaScreen for SettingsScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(
|
|
||||||
&self,
|
&self,
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
context: &RenderContext<'_>,
|
theme: &ResolvedTheme,
|
||||||
hits: &mut HitMap,
|
hits: &mut HitMap,
|
||||||
) {
|
) {
|
||||||
let header_block = Block::default()
|
let header_block = Block::default()
|
||||||
.title(" Settings ")
|
.title(" Settings ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.focused);
|
.border_style(theme.borders.focused);
|
||||||
let inner = header_block.inner(area);
|
let inner = header_block.inner(area);
|
||||||
frame.render_widget(header_block, area);
|
frame.render_widget(header_block, area);
|
||||||
|
|
||||||
|
|
@ -200,7 +184,7 @@ impl Screen for SettingsScreen {
|
||||||
self.color,
|
self.color,
|
||||||
self.unicode,
|
self.unicode,
|
||||||
))
|
))
|
||||||
.style(context.theme.text.heading),
|
.style(theme.text.heading),
|
||||||
sections[0],
|
sections[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -218,8 +202,8 @@ impl Screen for SettingsScreen {
|
||||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]);
|
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]);
|
||||||
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(&self.message, context.theme.text.normal)),
|
Line::from(Span::styled(&self.message, theme.text.normal)),
|
||||||
Line::from(Span::styled(&cli_line, context.theme.text.normal)),
|
Line::from(Span::styled(&cli_line, theme.text.normal)),
|
||||||
Line::from("Preview"),
|
Line::from("Preview"),
|
||||||
Line::from("[OK] Healthy"),
|
Line::from("[OK] Healthy"),
|
||||||
Line::from("[WARN] Degraded"),
|
Line::from("[WARN] Degraded"),
|
||||||
|
|
@ -227,7 +211,7 @@ impl Screen for SettingsScreen {
|
||||||
Line::from("> Focused action <"),
|
Line::from("> Focused action <"),
|
||||||
];
|
];
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(lines).style(context.theme.text.normal),
|
Paragraph::new(lines).style(theme.text.normal),
|
||||||
bottom_rows[0],
|
bottom_rows[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -247,9 +231,9 @@ impl Screen for SettingsScreen {
|
||||||
focused: self.focus == Focus::Back && self.dialog.is_none(),
|
focused: self.focus == Focus::Back && self.dialog.is_none(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
hits.register(buttons_area[0], AppAction::Back);
|
hits.register(buttons_area[0], IotaMessage::Action(AppAction::Back));
|
||||||
|
|
||||||
render_button(
|
render_button(
|
||||||
frame,
|
frame,
|
||||||
|
|
@ -260,15 +244,18 @@ impl Screen for SettingsScreen {
|
||||||
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
|
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
|
||||||
enabled: !self.pending,
|
enabled: !self.pending,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
|
);
|
||||||
|
hits.register(
|
||||||
|
buttons_area[1],
|
||||||
|
IotaMessage::Action(AppAction::RegenerateKeys),
|
||||||
);
|
);
|
||||||
hits.register(buttons_area[1], AppAction::RegenerateKeys);
|
|
||||||
|
|
||||||
if self.dialog.is_some() {
|
if self.dialog.is_some() {
|
||||||
frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area);
|
frame.render_widget(Block::default().style(theme.surfaces.overlay), area);
|
||||||
let popup = crate::layout::fit::centered_rect(
|
let popup = ratatool::layout::centered_rect(
|
||||||
area,
|
area,
|
||||||
crate::layout::fit::RequiredSize {
|
ratatool::layout::RequiredSize {
|
||||||
width: 42,
|
width: 42,
|
||||||
height: 7,
|
height: 7,
|
||||||
},
|
},
|
||||||
|
|
@ -276,14 +263,14 @@ impl Screen for SettingsScreen {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Confirm ")
|
.title(" Confirm ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.focused)
|
.border_style(theme.borders.focused)
|
||||||
.style(context.theme.surfaces.overlay);
|
.style(theme.surfaces.overlay);
|
||||||
let popup_inner = block.inner(popup);
|
let popup_inner = block.inner(popup);
|
||||||
frame.render_widget(block, popup);
|
frame.render_widget(block, popup);
|
||||||
let dialog_rows =
|
let dialog_rows =
|
||||||
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
|
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal),
|
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(theme.text.normal),
|
||||||
dialog_rows[0],
|
dialog_rows[0],
|
||||||
);
|
);
|
||||||
let dialog_buttons =
|
let dialog_buttons =
|
||||||
|
|
@ -298,7 +285,7 @@ impl Screen for SettingsScreen {
|
||||||
focused: false,
|
focused: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
render_button(
|
render_button(
|
||||||
frame,
|
frame,
|
||||||
|
|
@ -309,16 +296,23 @@ impl Screen for SettingsScreen {
|
||||||
focused: true,
|
focused: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
|
);
|
||||||
|
hits.register(
|
||||||
|
dialog_buttons[0],
|
||||||
|
IotaMessage::Action(AppAction::CancelDialog),
|
||||||
|
);
|
||||||
|
hits.register(
|
||||||
|
dialog_buttons[1],
|
||||||
|
IotaMessage::Action(AppAction::ConfirmDialog),
|
||||||
);
|
);
|
||||||
hits.register(dialog_buttons[0], AppAction::CancelDialog);
|
|
||||||
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let event = match event {
|
let event = match event {
|
||||||
UiEvent::App(AppEvent::ThemeSaved(result)) => {
|
UiEvent::Message(IotaMessage::Event(AppEvent::ThemeSaved(result))) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.saved = self.selected_theme();
|
self.saved = self.selected_theme();
|
||||||
|
|
@ -326,36 +320,36 @@ impl Screen for SettingsScreen {
|
||||||
}
|
}
|
||||||
Err(error) => self.message = error,
|
Err(error) => self.message = error,
|
||||||
}
|
}
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
|
UiEvent::Message(IotaMessage::Event(AppEvent::KeysRegenerated(result))) => {
|
||||||
self.pending = false;
|
self.pending = false;
|
||||||
self.dialog = None;
|
self.dialog = None;
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => self.message = "Keys regenerated successfully.".into(),
|
Ok(()) => self.message = "Keys regenerated successfully.".into(),
|
||||||
Err(error) => self.message = error,
|
Err(error) => self.message = error,
|
||||||
}
|
}
|
||||||
return InteractionResult::Handled;
|
return IotaInteraction::Changed;
|
||||||
}
|
}
|
||||||
event => event,
|
event => event,
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.dialog.is_some() {
|
if self.dialog.is_some() {
|
||||||
let UiEvent::Key(key) = event else {
|
let UiEvent::Key(key) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
return match key.code {
|
return match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.dialog = None;
|
self.dialog = None;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Enter => self.activate(),
|
KeyCode::Enter => self.activate(),
|
||||||
_ => InteractionResult::Handled,
|
_ => IotaInteraction::Changed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let UiEvent::Key(key) = event else {
|
let UiEvent::Key(key) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
|
|
@ -363,7 +357,7 @@ impl Screen for SettingsScreen {
|
||||||
self.selected = self.selected.saturating_sub(1);
|
self.selected = self.selected.saturating_sub(1);
|
||||||
self.apply(false)
|
self.apply(false)
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
|
|
@ -371,30 +365,30 @@ impl Screen for SettingsScreen {
|
||||||
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
|
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
|
||||||
self.apply(false)
|
self.apply(false)
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.next_focus();
|
self.next_focus();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::BackTab => {
|
KeyCode::BackTab => {
|
||||||
self.prev_focus();
|
self.prev_focus();
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') | KeyCode::Char('C') => {
|
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||||
self.color = Self::next_policy(self.color);
|
self.color = Self::next_policy(self.color);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('u') | KeyCode::Char('U') => {
|
KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||||
self.unicode = Self::next_policy(self.unicode);
|
self.unicode = Self::next_policy(self.unicode);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') | KeyCode::Char('L') => {
|
KeyCode::Char('l') | KeyCode::Char('L') => {
|
||||||
self.cli_output = self.cli_output.next();
|
self.cli_output = self.cli_output.next();
|
||||||
self.message = format!("CLI output: {:?}", self.cli_output);
|
self.message = format!("CLI output: {:?}", self.cli_output);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
self.cli_require_confirmation = !self.cli_require_confirmation;
|
||||||
|
|
@ -406,18 +400,16 @@ impl Screen for SettingsScreen {
|
||||||
"Off"
|
"Off"
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
|
||||||
InteractionResult::CloseScreen
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
|
||||||
_ => InteractionResult::Unhandled,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
|
||||||
match action {
|
match action {
|
||||||
AppAction::Back => InteractionResult::CloseScreen,
|
AppAction::Back => IotaInteraction::Pop,
|
||||||
AppAction::RegenerateKeys => {
|
AppAction::RegenerateKeys => {
|
||||||
self.focus = Focus::RegenerateKeys;
|
self.focus = Focus::RegenerateKeys;
|
||||||
self.activate()
|
self.activate()
|
||||||
|
|
@ -425,9 +417,9 @@ impl Screen for SettingsScreen {
|
||||||
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
|
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
|
||||||
AppAction::CancelDialog if self.dialog.is_some() => {
|
AppAction::CancelDialog if self.dialog.is_some() => {
|
||||||
self.dialog = None;
|
self.dialog = None;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::{
|
screens::{
|
||||||
md_viewer::FileViewer,
|
md_viewer::FileViewer,
|
||||||
screens::{HitMap, Screen, UiEvent},
|
screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
|
||||||
},
|
},
|
||||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
};
|
};
|
||||||
|
|
@ -16,7 +15,6 @@ use ratatui::{
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::{any::Any, pin::Pin};
|
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -50,15 +48,8 @@ impl TermsCheckerScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Screen for TermsCheckerScreen {
|
impl IotaScreen for TermsCheckerScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||||
self
|
|
||||||
}
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let mut needed_height = 5;
|
let mut needed_height = 5;
|
||||||
|
|
||||||
if size.height < 6 || size.width < 27 {
|
if size.height < 6 || size.width < 27 {
|
||||||
|
|
@ -176,7 +167,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
focused: self.focus == Focus::Eula,
|
focused: self.focus == Focus::Eula,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
),
|
),
|
||||||
render_choice_line(
|
render_choice_line(
|
||||||
tos_text,
|
tos_text,
|
||||||
|
|
@ -186,7 +177,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
focused: self.focus == Focus::Tos,
|
focused: self.focus == Focus::Tos,
|
||||||
enabled: self.eula,
|
enabled: self.eula,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
),
|
),
|
||||||
render_choice_line(
|
render_choice_line(
|
||||||
pp_text,
|
pp_text,
|
||||||
|
|
@ -196,7 +187,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
focused: self.focus == Focus::Pp,
|
focused: self.focus == Focus::Pp,
|
||||||
enabled: self.eula,
|
enabled: self.eula,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
),
|
),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from("¹ Necessary– required to run the program"),
|
Line::from("¹ Necessary– required to run the program"),
|
||||||
|
|
@ -217,19 +208,19 @@ impl Screen for TermsCheckerScreen {
|
||||||
|
|
||||||
if size.width < 60 || size.height < needed_height as u16 {
|
if size.width < 60 || size.height < needed_height as u16 {
|
||||||
let width_style = if size.width > 76 {
|
let width_style = if size.width > 76 {
|
||||||
context.theme.status.success
|
theme.status.success
|
||||||
} else if size.width >= 60 {
|
} else if size.width >= 60 {
|
||||||
context.theme.status.warning
|
theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
context.theme.status.error
|
theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let height_style = if size.height > 19 {
|
let height_style = if size.height > 19 {
|
||||||
context.theme.status.success
|
theme.status.success
|
||||||
} else if size.height >= 13 {
|
} else if size.height >= 13 {
|
||||||
context.theme.status.warning
|
theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
context.theme.status.error
|
theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let warning_text = Text::from(vec![
|
let warning_text = Text::from(vec![
|
||||||
|
|
@ -271,13 +262,14 @@ impl Screen for TermsCheckerScreen {
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
|
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
|
||||||
|
|
||||||
|
|
@ -293,21 +285,21 @@ impl Screen for TermsCheckerScreen {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::Deny);
|
let _ = sender.send(UserChoice::Deny);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Left => {
|
KeyCode::Up | KeyCode::Left => {
|
||||||
self.focus.prev(&possible_states);
|
self.focus.prev(&possible_states);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||||
self.focus.next(&possible_states);
|
self.focus.next(&possible_states);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::Deny);
|
let _ = sender.send(UserChoice::Deny);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||||
let terms_type = match self.focus {
|
let terms_type = match self.focus {
|
||||||
|
|
@ -322,77 +314,75 @@ impl Screen for TermsCheckerScreen {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|document| document.kind() == terms_type)
|
.find(|document| document.kind() == terms_type)
|
||||||
.cloned();
|
.cloned();
|
||||||
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
|
let screen = match document {
|
||||||
Box::pin(async move {
|
Some(document) => {
|
||||||
if let Some(document) = document {
|
FileViewer::new(terms_type.to_string(), document.content())
|
||||||
let screen: FileViewer =
|
}
|
||||||
FileViewer::new(terms_type.to_string(), document.content());
|
None => FileViewer::new(
|
||||||
Box::new(screen) as Box<dyn Screen>
|
|
||||||
} else {
|
|
||||||
let screen: FileViewer = FileViewer::new(
|
|
||||||
"Error".to_string(),
|
"Error".to_string(),
|
||||||
"The fetched legal document is unavailable.",
|
"The fetched legal document is unavailable.",
|
||||||
);
|
),
|
||||||
Box::new(screen) as Box<dyn Screen>
|
};
|
||||||
|
IotaInteraction::Push {
|
||||||
|
screen: Box::new(screen),
|
||||||
|
presentation: ratatool::ScreenPresentation::Replace,
|
||||||
}
|
}
|
||||||
});
|
|
||||||
InteractionResult::OpenFutureScreen { screen: fut }
|
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Unhandled
|
IotaInteraction::Unhandled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
||||||
Focus::Eula => {
|
Focus::Eula => {
|
||||||
let _ = open::that(get_link(TermsType::EULA));
|
let _ = open::that(get_link(TermsType::EULA));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Tos => {
|
Focus::Tos => {
|
||||||
let _ = open::that(get_link(TermsType::TOS));
|
let _ = open::that(get_link(TermsType::TOS));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Pp => {
|
Focus::Pp => {
|
||||||
let _ = open::that(get_link(TermsType::PP));
|
let _ = open::that(get_link(TermsType::PP));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
},
|
},
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
||||||
Focus::Eula => {
|
Focus::Eula => {
|
||||||
self.eula = !self.eula;
|
self.eula = !self.eula;
|
||||||
self.tos = false;
|
self.tos = false;
|
||||||
self.pp = false;
|
self.pp = false;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Tos if self.eula => {
|
Focus::Tos if self.eula => {
|
||||||
self.tos = !self.tos;
|
self.tos = !self.tos;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Pp if self.eula => {
|
Focus::Pp if self.eula => {
|
||||||
self.pp = !self.pp;
|
self.pp = !self.pp;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Cancel => {
|
Focus::Cancel => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::Deny);
|
let _ = sender.send(UserChoice::Deny);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
Focus::Continue if self.eula => {
|
Focus::Continue if self.eula => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::AcceptEULA);
|
let _ = sender.send(UserChoice::AcceptEULA);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::AcceptAll);
|
let _ = sender.send(UserChoice::AcceptAll);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
},
|
},
|
||||||
|
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
use crate::screens::terms_checker::UserChoice;
|
use crate::screens::terms_checker::UserChoice;
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::{
|
screens::{
|
||||||
md_viewer::FileViewer,
|
md_viewer::FileViewer,
|
||||||
screens::{HitMap, Screen, UiEvent},
|
screens::{
|
||||||
|
AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, UiEvent,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
};
|
};
|
||||||
use chrono::{Local, TimeZone, Utc};
|
use chrono::{Local, TimeZone, Utc};
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
|
use iota_terms::{Doc, TermsType, get_newest_link};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::any::Any;
|
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -115,16 +115,8 @@ impl TermsUpdaterScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Screen for TermsUpdaterScreen {
|
impl IotaScreen for TermsUpdaterScreen {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let checkbox = |label, selected, focused, enabled| {
|
let checkbox = |label, selected, focused, enabled| {
|
||||||
render_choice_line(
|
render_choice_line(
|
||||||
label,
|
label,
|
||||||
|
|
@ -134,7 +126,7 @@ impl Screen for TermsUpdaterScreen {
|
||||||
focused,
|
focused,
|
||||||
enabled,
|
enabled,
|
||||||
},
|
},
|
||||||
context.theme,
|
theme,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut needed_height = 5;
|
let mut needed_height = 5;
|
||||||
|
|
@ -536,19 +528,19 @@ impl Screen for TermsUpdaterScreen {
|
||||||
};
|
};
|
||||||
if size.width < 60 || size.height < needed_height as u16 {
|
if size.width < 60 || size.height < needed_height as u16 {
|
||||||
let width_style = if size.width > 76 {
|
let width_style = if size.width > 76 {
|
||||||
context.theme.status.success
|
theme.status.success
|
||||||
} else if size.width >= 60 {
|
} else if size.width >= 60 {
|
||||||
context.theme.status.warning
|
theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
context.theme.status.error
|
theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let height_style = if size.height > 20 {
|
let height_style = if size.height > 20 {
|
||||||
context.theme.status.success
|
theme.status.success
|
||||||
} else if size.height >= (header_lines as u16 + 10) {
|
} else if size.height >= (header_lines as u16 + 10) {
|
||||||
context.theme.status.warning
|
theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
context.theme.status.error
|
theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let warning_text = Text::from(vec![
|
let warning_text = Text::from(vec![
|
||||||
|
|
@ -592,13 +584,27 @@ impl Screen for TermsUpdaterScreen {
|
||||||
self.update_needed,
|
self.update_needed,
|
||||||
downgrade_scenario,
|
downgrade_scenario,
|
||||||
self.pp_needed || self.tos_needed,
|
self.pp_needed || self.tos_needed,
|
||||||
context.theme,
|
theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
|
||||||
|
let event = event.clone();
|
||||||
|
let event = match event {
|
||||||
|
UiEvent::Message(IotaMessage::Event(AppEvent::TermsLoaded { kind, result })) => {
|
||||||
|
let screen = match result {
|
||||||
|
Ok(content) => FileViewer::new(kind.to_string(), &content),
|
||||||
|
Err(error) => FileViewer::new("Error".to_string(), &error),
|
||||||
|
};
|
||||||
|
return IotaInteraction::Push {
|
||||||
|
screen: Box::new(screen),
|
||||||
|
presentation: ratatool::ScreenPresentation::Replace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
event => event,
|
||||||
|
};
|
||||||
let UiEvent::Key(event) = event else {
|
let UiEvent::Key(event) = event else {
|
||||||
return InteractionResult::Unhandled;
|
return IotaInteraction::Unhandled;
|
||||||
};
|
};
|
||||||
let mut possible_states = Vec::new();
|
let mut possible_states = Vec::new();
|
||||||
|
|
||||||
|
|
@ -626,42 +632,42 @@ impl Screen for TermsUpdaterScreen {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::Deny);
|
let _ = sender.send(UserChoice::Deny);
|
||||||
}
|
}
|
||||||
return InteractionResult::CloseScreen;
|
return IotaInteraction::Pop;
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
|
||||||
Focus::Eula => {
|
Focus::Eula => {
|
||||||
self.eula = !self.eula;
|
self.eula = !self.eula;
|
||||||
self.tos = !self.tos_needed;
|
self.tos = !self.tos_needed;
|
||||||
self.pp = !self.pp_needed;
|
self.pp = !self.pp_needed;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Tos if self.eula => {
|
Focus::Tos if self.eula => {
|
||||||
self.tos = !self.tos;
|
self.tos = !self.tos;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Pp if self.eula => {
|
Focus::Pp if self.eula => {
|
||||||
self.pp = !self.pp;
|
self.pp = !self.pp;
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Cancel => {
|
Focus::Cancel => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::Deny);
|
let _ = sender.send(UserChoice::Deny);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
Focus::Continue if self.eula => {
|
Focus::Continue if self.eula => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::AcceptEULA);
|
let _ = sender.send(UserChoice::AcceptEULA);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
Focus::ContinueAll if self.eula && self.tos && self.pp => {
|
||||||
if let Some(sender) = self.sender.take() {
|
if let Some(sender) = self.sender.take() {
|
||||||
let _ = sender.send(UserChoice::AcceptAll);
|
let _ = sender.send(UserChoice::AcceptAll);
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen
|
IotaInteraction::Pop
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
},
|
},
|
||||||
KeyCode::Char('o') | KeyCode::Char('O') => {
|
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||||
let terms_type = match self.focus {
|
let terms_type = match self.focus {
|
||||||
|
|
@ -672,47 +678,37 @@ impl Screen for TermsUpdaterScreen {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(terms_type) = terms_type {
|
if let Some(terms_type) = terms_type {
|
||||||
let fut = Box::pin(async move {
|
return IotaInteraction::Effect(IotaEffect::LoadNewestTerms {
|
||||||
if let Some(content) = get_terms(terms_type.clone()).await {
|
kind: terms_type,
|
||||||
Box::new(FileViewer::new(terms_type.to_string(), &content))
|
|
||||||
as Box<dyn Screen>
|
|
||||||
} else {
|
|
||||||
Box::new(FileViewer::new(
|
|
||||||
"Error".to_string(),
|
|
||||||
"Could not connect to the legal endpoint to fetch the document. Please check your internet connection.",
|
|
||||||
)) as Box<dyn Screen>
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return InteractionResult::OpenFutureScreen { screen: fut };
|
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Unhandled
|
IotaInteraction::Unhandled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
|
||||||
Focus::Eula => {
|
Focus::Eula => {
|
||||||
let _ = open::that(get_newest_link(TermsType::EULA));
|
let _ = open::that(get_newest_link(TermsType::EULA));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Tos => {
|
Focus::Tos => {
|
||||||
let _ = open::that(get_newest_link(TermsType::TOS));
|
let _ = open::that(get_newest_link(TermsType::TOS));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
Focus::Pp => {
|
Focus::Pp => {
|
||||||
let _ = open::that(get_newest_link(TermsType::PP));
|
let _ = open::that(get_newest_link(TermsType::PP));
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
},
|
},
|
||||||
KeyCode::Up | KeyCode::Left => {
|
KeyCode::Up | KeyCode::Left => {
|
||||||
self.focus.prev(&possible_states);
|
self.focus.prev(&possible_states);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||||
self.focus.next(&possible_states);
|
self.focus.next(&possible_states);
|
||||||
InteractionResult::Handled
|
IotaInteraction::Changed
|
||||||
}
|
}
|
||||||
_ => InteractionResult::Unhandled,
|
_ => IotaInteraction::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::model::{UserAction, UserActionGroup};
|
use super::model::{UserAction, UserActionGroup};
|
||||||
use crate::controls::menu::{MenuItem, MenuState};
|
use ratatool::{MenuItem, MenuState};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct UserActionMenu {
|
pub struct UserActionMenu {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
use crate::controls::{
|
use ratatool::{MenuItem, MenuState, TextInputState};
|
||||||
menu::{MenuItem, MenuState},
|
|
||||||
text_input::TextInput,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AddUserMethod {
|
pub enum AddUserMethod {
|
||||||
|
|
@ -26,8 +23,8 @@ pub struct AddUserFlow {
|
||||||
pub phase: AddUserPhase,
|
pub phase: AddUserPhase,
|
||||||
pub methods: MenuState<AddUserMethod>,
|
pub methods: MenuState<AddUserMethod>,
|
||||||
pub invitation_authorities: MenuState<iota_ipc::InvitationAuthority>,
|
pub invitation_authorities: MenuState<iota_ipc::InvitationAuthority>,
|
||||||
pub username: TextInput,
|
pub username: TextInputState,
|
||||||
pub import_path: TextInput,
|
pub import_path: TextInputState,
|
||||||
pub credential: Option<iota_ipc::SecretString>,
|
pub credential: Option<iota_ipc::SecretString>,
|
||||||
pub preview: Option<iota_ipc::TuCredentialPreview>,
|
pub preview: Option<iota_ipc::TuCredentialPreview>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
|
@ -77,8 +74,8 @@ impl AddUserFlow {
|
||||||
disabled_reason: Some("Local invitations are not implemented yet".into()),
|
disabled_reason: Some("Local invitations are not implemented yet".into()),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
username: TextInput::new("Username"),
|
username: TextInputState::default(),
|
||||||
import_path: TextInput::new("TU path"),
|
import_path: TextInputState::default(),
|
||||||
credential: None,
|
credential: None,
|
||||||
preview: None,
|
preview: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::UserEntry;
|
use super::UserEntry;
|
||||||
use crate::controls::text_input::TextInput;
|
use ratatool::TextInputState;
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
io::{self, Write},
|
io::{self, Write},
|
||||||
|
|
@ -9,7 +9,7 @@ use std::{
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CredentialExportState {
|
pub struct CredentialExportState {
|
||||||
pub user: UserEntry,
|
pub user: UserEntry,
|
||||||
pub destination: TextInput,
|
pub destination: TextInputState,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ impl CredentialExportState {
|
||||||
pub fn new(user: UserEntry) -> Self {
|
pub fn new(user: UserEntry) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user,
|
user,
|
||||||
destination: TextInput::new("Destination"),
|
destination: TextInputState::default(),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,11 @@ pub fn empty_message(supported: bool) -> &'static str {
|
||||||
pub fn render(
|
pub fn render(
|
||||||
frame: &mut ratatui::Frame,
|
frame: &mut ratatui::Frame,
|
||||||
area: ratatui::layout::Rect,
|
area: ratatui::layout::Rect,
|
||||||
context: &crate::render_context::RenderContext<'_>,
|
theme: &crate::theme::ResolvedTheme,
|
||||||
supported: bool,
|
supported: bool,
|
||||||
) {
|
) {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted),
|
ratatui::widgets::Paragraph::new(empty_message(supported)).style(theme.text.muted),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,112 @@ pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig};
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use name::ThemeName;
|
pub use name::ThemeName;
|
||||||
|
|
||||||
|
pub fn ratatool_policy(policy: TerminalPolicy) -> ratatool::CapabilityPolicy {
|
||||||
|
match policy {
|
||||||
|
TerminalPolicy::Auto => ratatool::CapabilityPolicy::Auto,
|
||||||
|
TerminalPolicy::Always => ratatool::CapabilityPolicy::Force,
|
||||||
|
TerminalPolicy::Never => ratatool::CapabilityPolicy::Disable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn terminal_options(
|
||||||
|
color: TerminalPolicy,
|
||||||
|
unicode: TerminalPolicy,
|
||||||
|
) -> ratatool::TerminalOptions {
|
||||||
|
ratatool::TerminalOptions {
|
||||||
|
color: ratatool_policy(color),
|
||||||
|
unicode: ratatool_policy(unicode),
|
||||||
|
overrides: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry() -> ratatool::ThemeRegistry {
|
||||||
|
let mut registry = ratatool::ThemeRegistry::new();
|
||||||
|
let plain = presets::resolve(ThemeName::Monospace);
|
||||||
|
let styled = presets::resolve(ThemeName::Binary);
|
||||||
|
let ansi = presets::resolve(ThemeName::Ansi);
|
||||||
|
for name in ThemeName::ALL {
|
||||||
|
let selected = presets::resolve(name);
|
||||||
|
let color = if matches!(name, ThemeName::Surface) {
|
||||||
|
&ansi
|
||||||
|
} else {
|
||||||
|
&selected
|
||||||
|
};
|
||||||
|
registry.register(ratatool::ThemeFamily::new(
|
||||||
|
name.ratatool_id(),
|
||||||
|
generic_variant(&selected),
|
||||||
|
generic_variant(color),
|
||||||
|
generic_variant(&styled),
|
||||||
|
generic_variant(&plain),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generic_variant(theme: &ResolvedTheme) -> ratatool::ThemeVariant {
|
||||||
|
ratatool::ThemeVariant {
|
||||||
|
text: ratatool::TextTheme {
|
||||||
|
normal: theme.text.normal,
|
||||||
|
muted: theme.text.muted,
|
||||||
|
emphasis: theme.text.heading,
|
||||||
|
disabled: theme.borders.disabled,
|
||||||
|
heading: theme.text.heading,
|
||||||
|
link: theme.text.link,
|
||||||
|
code: theme.text.code,
|
||||||
|
},
|
||||||
|
borders: ratatool::BorderTheme {
|
||||||
|
normal: theme.borders.normal,
|
||||||
|
focused: theme.borders.focused,
|
||||||
|
disabled: theme.borders.disabled,
|
||||||
|
},
|
||||||
|
surfaces: ratatool::SurfaceTheme {
|
||||||
|
background: theme.surfaces.canvas,
|
||||||
|
panel: theme.surfaces.panel,
|
||||||
|
panel_focused: theme.surfaces.panel_focused,
|
||||||
|
panel_header: theme.surfaces.panel_alternate,
|
||||||
|
raised: theme.surfaces.panel_alternate,
|
||||||
|
toolbar: theme.surfaces.toolbar,
|
||||||
|
footer: theme.surfaces.footer,
|
||||||
|
overlay: theme.surfaces.overlay,
|
||||||
|
},
|
||||||
|
buttons: ratatool::ButtonTheme {
|
||||||
|
primary: theme.buttons.primary,
|
||||||
|
primary_focused: theme.buttons.primary_focused,
|
||||||
|
secondary: theme.buttons.neutral,
|
||||||
|
secondary_focused: theme.buttons.neutral_focused,
|
||||||
|
cancel: theme.buttons.cancel,
|
||||||
|
cancel_focused: theme.buttons.cancel_focused,
|
||||||
|
destructive: theme.buttons.destructive,
|
||||||
|
destructive_focused: theme.buttons.destructive,
|
||||||
|
disabled: theme.buttons.disabled,
|
||||||
|
},
|
||||||
|
status: ratatool::StatusTheme {
|
||||||
|
info: theme.status.info,
|
||||||
|
success: theme.status.success,
|
||||||
|
warning: theme.status.warning,
|
||||||
|
error: theme.status.error,
|
||||||
|
},
|
||||||
|
input: ratatool::InputTheme {
|
||||||
|
normal: theme.text.normal,
|
||||||
|
focused: theme.borders.focused,
|
||||||
|
placeholder: theme.text.muted,
|
||||||
|
selection: theme.surfaces.panel_selected,
|
||||||
|
},
|
||||||
|
selection: ratatool::SelectionTheme {
|
||||||
|
normal: theme.text.normal,
|
||||||
|
selected: theme.surfaces.panel_selected,
|
||||||
|
focused: theme.borders.focused,
|
||||||
|
disabled: theme.borders.disabled,
|
||||||
|
},
|
||||||
|
metrics: ratatool::ThemeMetrics {
|
||||||
|
panel_presentation: match theme.chrome {
|
||||||
|
ChromeMode::Bordered => ratatool::PanelPresentation::Bordered,
|
||||||
|
ChromeMode::Surfaces => ratatool::PanelPresentation::Surface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||||
presets::resolve(name)
|
presets::resolve(name)
|
||||||
}
|
}
|
||||||
|
|
@ -34,22 +140,6 @@ pub fn resolve_with_capabilities(
|
||||||
theme
|
theme
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a theme against the terminal's color depth. Surface uses RGB
|
|
||||||
/// colors, so a portable ANSI preset is selected when truecolor is absent.
|
|
||||||
pub fn resolve_with_terminal_profile(
|
|
||||||
name: ThemeName,
|
|
||||||
color_enabled: bool,
|
|
||||||
unicode_enabled: bool,
|
|
||||||
truecolor_enabled: bool,
|
|
||||||
) -> ResolvedTheme {
|
|
||||||
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
|
|
||||||
ThemeName::Ansi
|
|
||||||
} else {
|
|
||||||
name
|
|
||||||
};
|
|
||||||
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -75,9 +165,18 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn surface_uses_ansi_fallback_without_truecolor() {
|
fn terminal_profile_changes_preserve_surface_identity() {
|
||||||
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
|
let registry = registry();
|
||||||
assert_eq!(theme.name, ThemeName::Ansi);
|
let mut state = ratatool::ThemeState::new(
|
||||||
assert_eq!(theme.surfaces.panel.bg, None);
|
ThemeName::Surface.ratatool_id(),
|
||||||
|
ratatool::TerminalProfile::truecolor_unicode(),
|
||||||
|
®istry,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.set_profile(ratatool::TerminalProfile::ansi16_unicode(), ®istry)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.selected(), &ThemeName::Surface.ratatool_id());
|
||||||
|
assert_eq!(state.profile().color_depth, ratatool::ColorDepth::Ansi16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
iota-cli/src/theme/model.rs
Normal file → Executable file
0
iota-cli/src/theme/model.rs
Normal file → Executable file
|
|
@ -17,6 +17,10 @@ impl ThemeName {
|
||||||
pub fn supported_names() -> &'static str {
|
pub fn supported_names() -> &'static str {
|
||||||
"monospace, binary, ansi, surface"
|
"monospace, binary, ansi, surface"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ratatool_id(self) -> ratatool::ThemeId {
|
||||||
|
ratatool::ThemeId::new(self.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ThemeName {
|
impl fmt::Display for ThemeName {
|
||||||
|
|
|
||||||
1607
iota-cli/src/ui.rs
1607
iota-cli/src/ui.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
||||||
use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn width_allocation_handles_exact_spare_and_insufficient_space() {
|
|
||||||
assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4]));
|
|
||||||
assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5]));
|
|
||||||
assert_eq!(horizontal_button_widths(6, &[3, 4]), None);
|
|
||||||
assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn minimum_width_uses_terminal_columns() {
|
|
||||||
assert_eq!(button_minimum_width("é"), 3);
|
|
||||||
assert_eq!(button_minimum_width("界"), 4);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
use iota_cli::controls::{
|
|
||||||
checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem},
|
|
||||||
navigation::DisabledFocusPolicy,
|
|
||||||
radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem},
|
|
||||||
};
|
|
||||||
|
|
||||||
fn checkbox(value: u8, enabled: bool) -> CheckboxItem<u8> {
|
|
||||||
CheckboxItem {
|
|
||||||
value,
|
|
||||||
label: value.to_string(),
|
|
||||||
description: None,
|
|
||||||
enabled,
|
|
||||||
disabled_reason: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn radio(value: u8, enabled: bool) -> RadioItem<u8> {
|
|
||||||
RadioItem {
|
|
||||||
value,
|
|
||||||
label: value.to_string(),
|
|
||||||
description: None,
|
|
||||||
enabled,
|
|
||||||
disabled_reason: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_selection_and_disabled_focus_are_independent() {
|
|
||||||
let mut group = CheckboxGroup::new(
|
|
||||||
vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)],
|
|
||||||
[1, 99],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
group.selected().iter().copied().collect::<Vec<_>>(),
|
|
||||||
vec![1]
|
|
||||||
);
|
|
||||||
assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1));
|
|
||||||
group.focus_next();
|
|
||||||
assert_eq!(group.focused_item().unwrap().value, 3);
|
|
||||||
group.set_focus_policy(DisabledFocusPolicy::Include);
|
|
||||||
group.focus_previous();
|
|
||||||
assert_eq!(group.focused_item().unwrap().value, 2);
|
|
||||||
assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn checkbox_non_wrapping_navigation_stops_at_the_edge() {
|
|
||||||
let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap();
|
|
||||||
group.set_wrap_navigation(false);
|
|
||||||
group.focus_previous();
|
|
||||||
assert_eq!(group.focused_item().unwrap().value, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn radio_validates_default_and_preserves_one_selection() {
|
|
||||||
assert!(matches!(
|
|
||||||
RadioGroup::new(Vec::<RadioItem<u8>>::new(), None, 1),
|
|
||||||
Err(RadioGroupError::Empty)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
RadioGroup::new(vec![radio(1, true)], None, 2),
|
|
||||||
Err(RadioGroupError::DefaultMissing)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
RadioGroup::new(vec![radio(1, false)], None, 1),
|
|
||||||
Err(RadioGroupError::DefaultDisabled)
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
|
||||||
assert_eq!(group.selected(), &2);
|
|
||||||
group.focus_next();
|
|
||||||
assert_eq!(group.selected(), &2);
|
|
||||||
assert_eq!(group.select_focused(), RadioChange::Unchanged(2));
|
|
||||||
group.focus_previous();
|
|
||||||
assert_eq!(
|
|
||||||
group.select_focused(),
|
|
||||||
RadioChange::Changed {
|
|
||||||
previous: 2,
|
|
||||||
selected: 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(group.selected(), &1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn groups_initially_focus_the_first_enabled_item() {
|
|
||||||
let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap();
|
|
||||||
assert_eq!(checkboxes.focused_item().unwrap().value, 2);
|
|
||||||
let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap();
|
|
||||||
assert_eq!(radios.focused_item().value, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disabling_a_selected_radio_obeys_the_configured_policy() {
|
|
||||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
|
||||||
group.set_enabled(&2, false).unwrap();
|
|
||||||
assert_eq!(group.selected(), &1);
|
|
||||||
|
|
||||||
group.set_enabled(&2, true).unwrap();
|
|
||||||
group.focus_next();
|
|
||||||
group.select_focused();
|
|
||||||
group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError);
|
|
||||||
assert_eq!(
|
|
||||||
group.set_enabled(&2, false),
|
|
||||||
Err(RadioGroupError::SelectedItemDisabled)
|
|
||||||
);
|
|
||||||
assert_eq!(group.selected(), &2);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
use iota_cli::layout::fit::{
|
|
||||||
FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level,
|
|
||||||
};
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selects_fit_by_both_dimensions() {
|
|
||||||
let preferred = RequiredSize {
|
|
||||||
width: 80,
|
|
||||||
height: 20,
|
|
||||||
};
|
|
||||||
let compact = RequiredSize {
|
|
||||||
width: 50,
|
|
||||||
height: 12,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact),
|
|
||||||
FitLevel::Preferred
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact),
|
|
||||||
FitLevel::Compact
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact),
|
|
||||||
FitLevel::Fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rectangle_helpers_do_not_underflow() {
|
|
||||||
let zero = Rect::new(4, 5, 0, 0);
|
|
||||||
assert_eq!(
|
|
||||||
centered_rect(
|
|
||||||
zero,
|
|
||||||
RequiredSize {
|
|
||||||
width: 10,
|
|
||||||
height: 10
|
|
||||||
}
|
|
||||||
),
|
|
||||||
zero
|
|
||||||
);
|
|
||||||
assert_eq!(reserve_vertical(zero, 1, 0), None);
|
|
||||||
assert_eq!(inset_checked(zero, 1, 1), None);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use iota_cli::{
|
use iota_cli::{
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::{
|
screens::{
|
||||||
screens::{AppEvent, HitMap, Screen, UiEvent},
|
screens::{HitMap, IotaEffect, IotaInteraction, IotaScreen, UiEvent},
|
||||||
settings::SettingsScreen,
|
settings::SettingsScreen,
|
||||||
},
|
},
|
||||||
theme::{ThemeName, resolve},
|
theme::{ThemeName, resolve},
|
||||||
|
|
@ -20,35 +18,21 @@ fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn settings_preview_and_save_emit_typed_application_events() {
|
fn settings_preview_and_save_emit_typed_effects() {
|
||||||
let mut screen = SettingsScreen::new(ThemeName::Ansi);
|
let mut screen = SettingsScreen::new(ThemeName::Ansi);
|
||||||
let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right)));
|
let preview = screen.handle_event(&UiEvent::Key(KeyEvent::from(KeyCode::Right)));
|
||||||
let InteractionResult::AppTask { task } = preview else {
|
let IotaInteraction::Effect(IotaEffect::ApplyTheme { theme, persist }) = preview else {
|
||||||
panic!("theme preview should emit an application task");
|
panic!("theme preview should emit an effect");
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert_eq!(theme, ThemeName::Surface);
|
||||||
task.await,
|
assert!(!persist);
|
||||||
UiEvent::App(AppEvent::ApplyTheme {
|
|
||||||
theme: ThemeName::Surface,
|
|
||||||
persist: false
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter)));
|
let save = screen.handle_event(&UiEvent::Key(KeyEvent::from(KeyCode::Enter)));
|
||||||
let InteractionResult::AppTask { task } = save else {
|
let IotaInteraction::Effect(IotaEffect::SaveSettings { theme, .. }) = save else {
|
||||||
panic!("theme save should emit an application task");
|
panic!("theme save should emit an effect");
|
||||||
};
|
};
|
||||||
assert!(matches!(
|
assert_eq!(theme, ThemeName::Surface);
|
||||||
task.await,
|
|
||||||
UiEvent::App(AppEvent::SaveSettings {
|
|
||||||
theme: ThemeName::Surface,
|
|
||||||
color: _,
|
|
||||||
unicode: _,
|
|
||||||
cli_output: _,
|
|
||||||
cli_require_confirmation: _
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -60,12 +44,7 @@ fn settings_is_readable_in_every_theme_and_layout() {
|
||||||
let screen = SettingsScreen::new(theme_name);
|
let screen = SettingsScreen::new(theme_name);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
screen.render(
|
screen.render_legacy(frame, frame.area(), &theme, &mut HitMap::new());
|
||||||
frame,
|
|
||||||
frame.area(),
|
|
||||||
&RenderContext { theme: &theme },
|
|
||||||
&mut HitMap::default(),
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let rendered = buffer_text(&terminal);
|
let rendered = buffer_text(&terminal);
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,17 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
iota-auth = { path = "../iota-auth" }
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = ["crypto"] }
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1.50.0", features = ["rt"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.50.0", features = ["macros", "rt"] }
|
tokio = { version = "1.50.0", features = ["macros", "rt"] }
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,89 @@
|
||||||
use mtp::codec::CommunicationValue;
|
use async_trait::async_trait;
|
||||||
use std::future::Future;
|
use iota_identity::{AuthorityId, IotaNodeId};
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Unified interface for all connection types (Omikron, Direct, future modes).
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
///
|
pub enum ConnectionError {
|
||||||
/// Provides the common messaging API that the rest of the codebase uses,
|
Disconnected(String),
|
||||||
/// regardless of whether the connection goes through Omikron or is direct.
|
Timeout(String),
|
||||||
|
Protocol(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ConnectionError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Disconnected(message) | Self::Timeout(message) | Self::Protocol(message) => {
|
||||||
|
formatter.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConnectionError {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
pub trait ConnectionHandler: Send + Sync {
|
pub trait ConnectionHandler: Send + Sync {
|
||||||
/// Send a message to the remote end.
|
async fn send_message(&self, value: &CommunicationValue) -> Result<(), ConnectionError>;
|
||||||
fn send_message(
|
|
||||||
&self,
|
async fn await_response(
|
||||||
cv: &CommunicationValue,
|
|
||||||
) -> impl Future<Output = Result<(), String>> + Send;
|
|
||||||
|
|
||||||
/// Send a message and wait for a correlated response.
|
|
||||||
///
|
|
||||||
/// The implementation correlates requests/responses by message ID and
|
|
||||||
/// enforces the given `timeout`. Returns an error on timeout or if the
|
|
||||||
/// connection drops while waiting.
|
|
||||||
fn await_response(
|
|
||||||
&self,
|
&self,
|
||||||
cv: &CommunicationValue,
|
value: &CommunicationValue,
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
) -> impl Future<Output = Result<CommunicationValue, String>> + Send;
|
) -> Result<CommunicationValue, ConnectionError>;
|
||||||
|
|
||||||
|
async fn is_connected(&self) -> bool;
|
||||||
|
|
||||||
/// Returns `true` when the connection is alive and ready for traffic.
|
async fn is_identified(&self) -> bool;
|
||||||
fn is_connected(&self) -> impl Future<Output = bool> + Send;
|
|
||||||
|
|
||||||
/// Returns `true` when the connection has completed identification /
|
async fn stop(&self);
|
||||||
/// registration and is fully operational.
|
}
|
||||||
fn is_identified(&self) -> impl Future<Output = bool> + Send;
|
|
||||||
|
|
||||||
/// Gracefully tear down the connection.
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
fn stop(&self) -> impl Future<Output = ()> + Send;
|
pub enum RouteDestination {
|
||||||
|
Iota(IotaNodeId),
|
||||||
|
LegacyOmegaIota { omega: AuthorityId, iota_id: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RouteOutcome {
|
||||||
|
Accepted {
|
||||||
|
relay_message_id: String,
|
||||||
|
destination_accepted_at: i64,
|
||||||
|
},
|
||||||
|
Rejected {
|
||||||
|
response_type: CommunicationType,
|
||||||
|
},
|
||||||
|
Retryable {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RouteError {
|
||||||
|
NoRoute(IotaNodeId),
|
||||||
|
Delivery(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RouteError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NoRoute(destination) => {
|
||||||
|
write!(formatter, "no route to Iota {}", destination.as_str())
|
||||||
|
}
|
||||||
|
Self::Delivery(message) => formatter.write_str(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RouteError {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PeerRouter: Send + Sync {
|
||||||
|
async fn route(
|
||||||
|
&self,
|
||||||
|
destination: &RouteDestination,
|
||||||
|
frame: CommunicationValue,
|
||||||
|
) -> Result<RouteOutcome, RouteError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
438
iota-connection/src/federated_relay.rs
Normal file
438
iota-connection/src/federated_relay.rs
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use iota_identity::{
|
||||||
|
IdentityError, IdentityResolver, IotaNodeId, PrincipalId, ResolutionContext, ResolvedPrincipal,
|
||||||
|
verify_dual_signature,
|
||||||
|
};
|
||||||
|
use iota_util::mtp_compat::OptionalDataValueExt;
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
|
||||||
|
use mtp::crypto::{Keyring, SignatureScheme};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const SIGNING_DOMAIN: &[u8] = b"tensamin-federated-relay:v2";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FederatedRelayV2 {
|
||||||
|
pub signer: PrincipalId,
|
||||||
|
pub recipient: PrincipalId,
|
||||||
|
pub destination: IotaNodeId,
|
||||||
|
pub message_id: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub content: CommunicationValue,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VerifiedFederatedRelayV2 {
|
||||||
|
pub signer: ResolvedPrincipal,
|
||||||
|
pub recipient: ResolvedPrincipal,
|
||||||
|
pub destination: IotaNodeId,
|
||||||
|
pub message_id: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub content: CommunicationValue,
|
||||||
|
pub frame: CommunicationValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct FederatedRelayWireV2 {
|
||||||
|
version: u32,
|
||||||
|
signer: PrincipalId,
|
||||||
|
recipient: PrincipalId,
|
||||||
|
destination: IotaNodeId,
|
||||||
|
message_id: String,
|
||||||
|
created_at: u64,
|
||||||
|
type_map_version: String,
|
||||||
|
content: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederatedRelayV2 {
|
||||||
|
pub fn sign(
|
||||||
|
signer: PrincipalId,
|
||||||
|
recipient: PrincipalId,
|
||||||
|
destination: IotaNodeId,
|
||||||
|
message_id: String,
|
||||||
|
created_at: u64,
|
||||||
|
content: CommunicationValue,
|
||||||
|
keyring: &Keyring,
|
||||||
|
) -> Result<Self, IdentityError> {
|
||||||
|
validate_fields(&signer, &recipient, &message_id, &content)?;
|
||||||
|
let signer_impl = mtp::crypto::DualSigner::new(
|
||||||
|
&keyring.sig_cl_secret_key,
|
||||||
|
&keyring.sig_pq_secret_key,
|
||||||
|
&keyring.sig_pq_public_key,
|
||||||
|
)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
let signature = signer_impl
|
||||||
|
.sign(&canonical_bytes(
|
||||||
|
&signer,
|
||||||
|
&recipient,
|
||||||
|
&destination,
|
||||||
|
&message_id,
|
||||||
|
created_at,
|
||||||
|
&content,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
signer,
|
||||||
|
recipient,
|
||||||
|
destination,
|
||||||
|
message_id,
|
||||||
|
created_at,
|
||||||
|
content,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_frame(self, frame_id: u32) -> Result<CommunicationValue, IdentityError> {
|
||||||
|
let type_map = self.content.type_map().ok_or_else(|| {
|
||||||
|
IdentityError::InvalidDescriptor("federated relay content has no type map".into())
|
||||||
|
})?;
|
||||||
|
let content = self
|
||||||
|
.content
|
||||||
|
.to_bytes()
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
let wire = FederatedRelayWireV2 {
|
||||||
|
version: 2,
|
||||||
|
signer: self.signer,
|
||||||
|
recipient: self.recipient,
|
||||||
|
destination: self.destination,
|
||||||
|
message_id: self.message_id,
|
||||||
|
created_at: self.created_at,
|
||||||
|
type_map_version: type_map.version.to_string(),
|
||||||
|
content: STANDARD.encode(content),
|
||||||
|
signature: STANDARD.encode(self.signature),
|
||||||
|
};
|
||||||
|
let payload = serde_json::to_vec(&wire)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
Ok(CommunicationValue::new(CommunicationType::Relay)
|
||||||
|
.with_id(frame_id)
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2))
|
||||||
|
.add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decode_and_verify(
|
||||||
|
frame: CommunicationValue,
|
||||||
|
identities: &dyn IdentityResolver,
|
||||||
|
local_node: &IotaNodeId,
|
||||||
|
) -> Result<VerifiedFederatedRelayV2, IdentityError> {
|
||||||
|
decode_and_verify_for_ingress(frame, identities, Some(local_node)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decode_and_verify_for_ingress(
|
||||||
|
frame: CommunicationValue,
|
||||||
|
identities: &dyn IdentityResolver,
|
||||||
|
expected_destination: Option<&IotaNodeId>,
|
||||||
|
) -> Result<VerifiedFederatedRelayV2, IdentityError> {
|
||||||
|
if !frame.is_type(CommunicationType::Relay)
|
||||||
|
|| frame.get_data(DataType::VersionNumber).as_number() != Some(2)
|
||||||
|
{
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"frame is not Federated Relay V2".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = frame
|
||||||
|
.get_data(DataType::SecurePayload)
|
||||||
|
.and_then(|value| match value {
|
||||||
|
DataValue::Bytes(bytes) => Some(bytes.as_slice()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| IdentityError::InvalidDescriptor("Relay V2 payload is missing".into()))?;
|
||||||
|
let wire: FederatedRelayWireV2 = serde_json::from_slice(payload)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
if wire.version != 2
|
||||||
|
|| expected_destination.is_some_and(|expected| &wire.destination != expected)
|
||||||
|
{
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 destination does not match this Iota".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let type_map_version =
|
||||||
|
mtp::type_map::Version::parse(&wire.type_map_version).ok_or_else(|| {
|
||||||
|
IdentityError::InvalidDescriptor("Relay V2 type-map version is invalid".into())
|
||||||
|
})?;
|
||||||
|
let content_bytes = STANDARD
|
||||||
|
.decode(&wire.content)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
let content =
|
||||||
|
CommunicationValue::from_bytes_with(&content_bytes, &TypeMap::new(type_map_version))
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
validate_fields(&wire.signer, &wire.recipient, &wire.message_id, &content)?;
|
||||||
|
let signature = STANDARD
|
||||||
|
.decode(&wire.signature)
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
let context = ResolutionContext {
|
||||||
|
allow_network: true,
|
||||||
|
..ResolutionContext::default()
|
||||||
|
};
|
||||||
|
let signer = identities
|
||||||
|
.resolve_principal_with_context(&wire.signer, &context)
|
||||||
|
.await?;
|
||||||
|
let recipient = identities
|
||||||
|
.resolve_principal_with_context(&wire.recipient, &context)
|
||||||
|
.await?;
|
||||||
|
if !matches!(
|
||||||
|
&recipient.home,
|
||||||
|
iota_identity::PrincipalHome::Iota(home) if home == &wire.destination
|
||||||
|
) {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 destination does not match recipient home".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = canonical_bytes(
|
||||||
|
&wire.signer,
|
||||||
|
&wire.recipient,
|
||||||
|
&wire.destination,
|
||||||
|
&wire.message_id,
|
||||||
|
wire.created_at,
|
||||||
|
&content,
|
||||||
|
)?;
|
||||||
|
if !signer
|
||||||
|
.public_keys
|
||||||
|
.iter()
|
||||||
|
.any(|key| verify_dual_signature(key, &bytes, &signature).is_ok())
|
||||||
|
{
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 user signature is invalid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(VerifiedFederatedRelayV2 {
|
||||||
|
signer,
|
||||||
|
recipient,
|
||||||
|
destination: wire.destination,
|
||||||
|
message_id: wire.message_id,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
content,
|
||||||
|
frame,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_fields(
|
||||||
|
signer: &PrincipalId,
|
||||||
|
recipient: &PrincipalId,
|
||||||
|
message_id: &str,
|
||||||
|
content: &CommunicationValue,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
if signer == recipient {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 signer and recipient are identical".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if message_id.is_empty() || message_id.len() > 256 {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 message ID is invalid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if content.type_map().is_none() || content.is_type(CommunicationType::Relay) {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"Relay V2 content is invalid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_bytes(
|
||||||
|
signer: &PrincipalId,
|
||||||
|
recipient: &PrincipalId,
|
||||||
|
destination: &IotaNodeId,
|
||||||
|
message_id: &str,
|
||||||
|
created_at: u64,
|
||||||
|
content: &CommunicationValue,
|
||||||
|
) -> Result<Vec<u8>, IdentityError> {
|
||||||
|
let mut bytes = SIGNING_DOMAIN.to_vec();
|
||||||
|
push(&mut bytes, signer.authority.as_str().as_bytes())?;
|
||||||
|
bytes.extend_from_slice(&signer.user_id.to_be_bytes());
|
||||||
|
push(&mut bytes, recipient.authority.as_str().as_bytes())?;
|
||||||
|
bytes.extend_from_slice(&recipient.user_id.to_be_bytes());
|
||||||
|
push(&mut bytes, destination.as_str().as_bytes())?;
|
||||||
|
push(&mut bytes, message_id.as_bytes())?;
|
||||||
|
bytes.extend_from_slice(&created_at.to_be_bytes());
|
||||||
|
push(
|
||||||
|
&mut bytes,
|
||||||
|
&content
|
||||||
|
.to_bytes()
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?,
|
||||||
|
)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(output: &mut Vec<u8>, value: &[u8]) -> Result<(), IdentityError> {
|
||||||
|
let length = u32::try_from(value.len())
|
||||||
|
.map_err(|_| IdentityError::InvalidDescriptor("Relay V2 field is too large".into()))?;
|
||||||
|
output.extend_from_slice(&length.to_be_bytes());
|
||||||
|
output.extend_from_slice(value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityId, PrincipalHandle, PrincipalHome, PublicKeyBundle, ResolvedPrincipal,
|
||||||
|
UserAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Resolver {
|
||||||
|
principals: Vec<ResolvedPrincipal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityResolver for Resolver {
|
||||||
|
async fn resolve_address(
|
||||||
|
&self,
|
||||||
|
_: &UserAddress,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
Err(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
self.principals
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| &candidate.principal == principal)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_keys(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
self.resolve_principal(principal)
|
||||||
|
.await
|
||||||
|
.map(|principal| principal.public_keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved(
|
||||||
|
authority: &AuthorityId,
|
||||||
|
user_id: u64,
|
||||||
|
handle: i64,
|
||||||
|
key: PublicKeyBundle,
|
||||||
|
home: IotaNodeId,
|
||||||
|
) -> ResolvedPrincipal {
|
||||||
|
ResolvedPrincipal {
|
||||||
|
principal: PrincipalId {
|
||||||
|
authority: authority.clone(),
|
||||||
|
user_id,
|
||||||
|
},
|
||||||
|
handle: PrincipalHandle(handle),
|
||||||
|
username: None,
|
||||||
|
public_keys: vec![key],
|
||||||
|
home: PrincipalHome::Iota(home),
|
||||||
|
descriptor_revision: 1,
|
||||||
|
valid_until: None,
|
||||||
|
resolved_at: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay() -> (
|
||||||
|
CommunicationValue,
|
||||||
|
Resolver,
|
||||||
|
IotaNodeId,
|
||||||
|
AuthorityId,
|
||||||
|
AuthorityId,
|
||||||
|
) {
|
||||||
|
let signer_node =
|
||||||
|
iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let recipient_node =
|
||||||
|
iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let signer = Keyring::generate();
|
||||||
|
let recipient = Keyring::generate();
|
||||||
|
let signer_principal = PrincipalId {
|
||||||
|
authority: signer_node.authority_id().clone(),
|
||||||
|
user_id: 7,
|
||||||
|
};
|
||||||
|
let recipient_principal = PrincipalId {
|
||||||
|
authority: recipient_node.authority_id().clone(),
|
||||||
|
user_id: 7,
|
||||||
|
};
|
||||||
|
let content = CommunicationValue::new(CommunicationType::MessageSend)
|
||||||
|
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()))
|
||||||
|
.add_typed_default(DataType::SendTime, DataValue::SignedNumber(10))
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1));
|
||||||
|
let frame = FederatedRelayV2::sign(
|
||||||
|
signer_principal.clone(),
|
||||||
|
recipient_principal.clone(),
|
||||||
|
recipient_node.node_id().clone(),
|
||||||
|
"same-id".into(),
|
||||||
|
10,
|
||||||
|
content,
|
||||||
|
&signer,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.into_frame(5)
|
||||||
|
.unwrap();
|
||||||
|
let resolver = Resolver {
|
||||||
|
principals: vec![
|
||||||
|
resolved(
|
||||||
|
signer_node.authority_id(),
|
||||||
|
7,
|
||||||
|
1,
|
||||||
|
signer.public_key_bundle(),
|
||||||
|
signer_node.node_id().clone(),
|
||||||
|
),
|
||||||
|
resolved(
|
||||||
|
recipient_node.authority_id(),
|
||||||
|
7,
|
||||||
|
2,
|
||||||
|
recipient.public_key_bundle(),
|
||||||
|
recipient_node.node_id().clone(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
(
|
||||||
|
frame,
|
||||||
|
resolver,
|
||||||
|
recipient_node.node_id().clone(),
|
||||||
|
signer_node.authority_id().clone(),
|
||||||
|
recipient_node.authority_id().clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn equal_user_ids_from_different_iotas_verify_as_distinct_principals() {
|
||||||
|
let (frame, resolver, node, signer_authority, recipient_authority) = relay();
|
||||||
|
let verified = decode_and_verify(frame, &resolver, &node).await.unwrap();
|
||||||
|
assert_eq!(verified.signer.principal.user_id, 7);
|
||||||
|
assert_eq!(verified.recipient.principal.user_id, 7);
|
||||||
|
assert_eq!(verified.signer.principal.authority, signer_authority);
|
||||||
|
assert_eq!(verified.recipient.principal.authority, recipient_authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changing_either_authority_invalidates_relay_signature() {
|
||||||
|
for field in ["signer", "recipient"] {
|
||||||
|
let (mut frame, mut resolver, node, _, _) = relay();
|
||||||
|
let payload = frame
|
||||||
|
.get_data(DataType::SecurePayload)
|
||||||
|
.and_then(|value| match value {
|
||||||
|
DataValue::Bytes(bytes) => Some(bytes.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut wire: FederatedRelayWireV2 = serde_json::from_slice(&payload).unwrap();
|
||||||
|
let changed = AuthorityId::new("authority:iota:v1:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap();
|
||||||
|
if field == "signer" {
|
||||||
|
resolver.principals[0].principal.authority = changed.clone();
|
||||||
|
wire.signer.authority = changed;
|
||||||
|
} else {
|
||||||
|
resolver.principals[1].principal.authority = changed.clone();
|
||||||
|
wire.recipient.authority = changed;
|
||||||
|
}
|
||||||
|
frame = CommunicationValue::new(CommunicationType::Relay)
|
||||||
|
.with_id(5)
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2))
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::SecurePayload,
|
||||||
|
DataValue::Bytes(serde_json::to_vec(&wire).unwrap()),
|
||||||
|
);
|
||||||
|
assert!(decode_and_verify(frame, &resolver, &node).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
pub mod connection_handler;
|
pub mod connection_handler;
|
||||||
|
pub mod federated_relay;
|
||||||
pub mod message_common;
|
pub mod message_common;
|
||||||
pub mod message_handlers;
|
pub mod message_handlers;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod relay_service;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ fn next_notification_id() -> u32 {
|
||||||
pub struct MessageMutation {
|
pub struct MessageMutation {
|
||||||
pub sender_id: i64,
|
pub sender_id: i64,
|
||||||
pub partner_id: i64,
|
pub partner_id: i64,
|
||||||
|
pub partner_principal: iota_identity::PrincipalHandle,
|
||||||
|
pub sender_principal: iota_identity::PrincipalHandle,
|
||||||
pub send_time: i64,
|
pub send_time: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,10 +79,20 @@ pub fn message_mutation(cv: &CommunicationValue) -> Result<MessageMutation, Comm
|
||||||
let send_time = data_i64(cv, DataType::SendTime)
|
let send_time = data_i64(cv, DataType::SendTime)
|
||||||
.filter(|time| *time > 0)
|
.filter(|time| *time > 0)
|
||||||
.ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?;
|
.ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?;
|
||||||
|
let partner_principal = chats_util::get_user(sender_id, partner_id)
|
||||||
|
.map_err(|_| error_response(cv, CommunicationType::ErrorInternal))?
|
||||||
|
.and_then(|contact| contact.principal)
|
||||||
|
.ok_or_else(|| error_response(cv, CommunicationType::ErrorNotFound))?;
|
||||||
|
let sender_principal = iota_storage::identity::SqlitePrincipalStore
|
||||||
|
.principal_for_local_user(iota_identity::LocalUserId(sender_id))
|
||||||
|
.map_err(|_| error_response(cv, CommunicationType::ErrorInternal))?
|
||||||
|
.ok_or_else(|| error_response(cv, CommunicationType::ErrorNotFound))?;
|
||||||
|
|
||||||
Ok(MessageMutation {
|
Ok(MessageMutation {
|
||||||
sender_id,
|
sender_id,
|
||||||
partner_id,
|
partner_id,
|
||||||
|
partner_principal,
|
||||||
|
sender_principal,
|
||||||
send_time,
|
send_time,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +181,8 @@ fn validate_relay_identity(
|
||||||
pub fn apply_verified_relay_content(
|
pub fn apply_verified_relay_content(
|
||||||
context: &VerifiedRelayContext,
|
context: &VerifiedRelayContext,
|
||||||
content: &VerifiedRelayContent,
|
content: &VerifiedRelayContent,
|
||||||
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
recipient_principal: iota_identity::PrincipalHandle,
|
||||||
accepted_at: i64,
|
accepted_at: i64,
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
sent_by_self: bool,
|
sent_by_self: bool,
|
||||||
|
|
@ -205,6 +219,7 @@ pub fn apply_verified_relay_content(
|
||||||
return chat_files::change_message_state_by_relay_id(
|
return chat_files::change_message_state_by_relay_id(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
recipient_id,
|
recipient_id,
|
||||||
|
recipient_principal,
|
||||||
relay_message_id,
|
relay_message_id,
|
||||||
state,
|
state,
|
||||||
)
|
)
|
||||||
|
|
@ -213,8 +228,10 @@ pub fn apply_verified_relay_content(
|
||||||
chat_files::record_message_receipt(
|
chat_files::record_message_receipt(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
recipient_id,
|
recipient_id,
|
||||||
|
recipient_principal,
|
||||||
relay_message_id,
|
relay_message_id,
|
||||||
sender_id,
|
sender_id,
|
||||||
|
signer_principal,
|
||||||
&context.message_id,
|
&context.message_id,
|
||||||
state,
|
state,
|
||||||
event_at,
|
event_at,
|
||||||
|
|
@ -251,15 +268,25 @@ pub fn apply_verified_relay_content(
|
||||||
"Relay MessageSend identity does not match its protected message ID".into(),
|
"Relay MessageSend identity does not match its protected message ID".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let chat_id = if sender_id < recipient_id {
|
let chat_id = e2ee_storage::principal_chat_id(signer_principal, recipient_principal)
|
||||||
|
.ok_or_else(|| "Relay MessageSend has an invalid principal pair".to_string())?;
|
||||||
|
let legacy_chat_id = if sender_id < recipient_id {
|
||||||
format!("{sender_id}:{recipient_id}")
|
format!("{sender_id}:{recipient_id}")
|
||||||
} else {
|
} else {
|
||||||
format!("{recipient_id}:{sender_id}")
|
format!("{recipient_id}:{sender_id}")
|
||||||
};
|
};
|
||||||
|
e2ee_storage::migrate_chat_secret_namespace(
|
||||||
|
&storage_owner.to_string(),
|
||||||
|
&legacy_chat_id,
|
||||||
|
&format!("chat:{legacy_chat_id}:main"),
|
||||||
|
&chat_id,
|
||||||
|
&e2ee_storage::principal_secret_id(&chat_id),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
let latest_secret = e2ee_storage::get_chat_secret(ChatSecretQuery {
|
let latest_secret = e2ee_storage::get_chat_secret(ChatSecretQuery {
|
||||||
user_id: storage_owner.to_string(),
|
user_id: storage_owner.to_string(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
secret_id: Some(format!("chat:{chat_id}:main")),
|
secret_id: Some(e2ee_storage::principal_secret_id(&chat_id)),
|
||||||
version: None,
|
version: None,
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
|
|
@ -269,6 +296,7 @@ pub fn apply_verified_relay_content(
|
||||||
}
|
}
|
||||||
chat_files::add_message(chat_files::NewMessage {
|
chat_files::add_message(chat_files::NewMessage {
|
||||||
relay_signer_id: sender_id,
|
relay_signer_id: sender_id,
|
||||||
|
relay_signer_principal: signer_principal,
|
||||||
relay_message_id: &context.message_id,
|
relay_message_id: &context.message_id,
|
||||||
authored_at: created_at,
|
authored_at: created_at,
|
||||||
send_time,
|
send_time,
|
||||||
|
|
@ -278,6 +306,11 @@ pub fn apply_verified_relay_content(
|
||||||
} else {
|
} else {
|
||||||
sender_id
|
sender_id
|
||||||
},
|
},
|
||||||
|
external_principal: if sent_by_self {
|
||||||
|
recipient_principal
|
||||||
|
} else {
|
||||||
|
signer_principal
|
||||||
|
},
|
||||||
sent_by_self,
|
sent_by_self,
|
||||||
content: message,
|
content: message,
|
||||||
height,
|
height,
|
||||||
|
|
@ -305,18 +338,25 @@ pub fn apply_verified_relay_content(
|
||||||
} else {
|
} else {
|
||||||
sender_id
|
sender_id
|
||||||
};
|
};
|
||||||
|
let external_principal = if sent_by_self {
|
||||||
|
recipient_principal
|
||||||
|
} else {
|
||||||
|
signer_principal
|
||||||
|
};
|
||||||
let result = if sent_by_self {
|
let result = if sent_by_self {
|
||||||
chat_files::edit_message(
|
chat_files::edit_message_for_principal(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal,
|
||||||
send_time,
|
send_time,
|
||||||
sender_id,
|
sender_id,
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chat_files::apply_remote_edit(
|
chat_files::apply_remote_edit_for_principal(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal,
|
||||||
send_time,
|
send_time,
|
||||||
sender_id,
|
sender_id,
|
||||||
message,
|
message,
|
||||||
|
|
@ -336,20 +376,29 @@ pub fn apply_verified_relay_content(
|
||||||
} else {
|
} else {
|
||||||
sender_id
|
sender_id
|
||||||
};
|
};
|
||||||
|
let external_principal = if sent_by_self {
|
||||||
|
recipient_principal
|
||||||
|
} else {
|
||||||
|
signer_principal
|
||||||
|
};
|
||||||
let result = if content.message_type == CommunicationType::MessageReactionAdd {
|
let result = if content.message_type == CommunicationType::MessageReactionAdd {
|
||||||
chat_files::add_reaction(
|
chat_files::add_reaction_for_principal(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal,
|
||||||
send_time,
|
send_time,
|
||||||
sender_id,
|
sender_id,
|
||||||
|
signer_principal,
|
||||||
reaction,
|
reaction,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chat_files::remove_reaction(
|
chat_files::remove_reaction_for_principal(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal,
|
||||||
send_time,
|
send_time,
|
||||||
sender_id,
|
sender_id,
|
||||||
|
signer_principal,
|
||||||
reaction,
|
reaction,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -364,10 +413,26 @@ pub fn apply_verified_relay_content(
|
||||||
} else {
|
} else {
|
||||||
sender_id
|
sender_id
|
||||||
};
|
};
|
||||||
let result = if sent_by_self {
|
let external_principal = if sent_by_self {
|
||||||
chat_files::delete_message(storage_owner, external_user, send_time)
|
recipient_principal
|
||||||
} else {
|
} else {
|
||||||
chat_files::apply_remote_delete(storage_owner, external_user, send_time, sender_id)
|
signer_principal
|
||||||
|
};
|
||||||
|
let result = if sent_by_self {
|
||||||
|
chat_files::delete_message_for_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal,
|
||||||
|
send_time,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chat_files::apply_remote_delete_for_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal,
|
||||||
|
send_time,
|
||||||
|
sender_id,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
result.map_err(|error| error.to_string())
|
result.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -434,10 +499,23 @@ pub fn apply_verified_relay_content(
|
||||||
if recipient_ids != expected_recipient_ids {
|
if recipient_ids != expected_recipient_ids {
|
||||||
return Err("Relay SetChatSecret recipients do not match relay identities".into());
|
return Err("Relay SetChatSecret recipients do not match relay identities".into());
|
||||||
}
|
}
|
||||||
|
let principal_chat_id =
|
||||||
|
e2ee_storage::principal_chat_id(signer_principal, recipient_principal).ok_or_else(
|
||||||
|
|| "Relay SetChatSecret has an invalid principal pair".to_string(),
|
||||||
|
)?;
|
||||||
|
let principal_secret_id = e2ee_storage::principal_secret_id(&principal_chat_id);
|
||||||
|
e2ee_storage::migrate_chat_secret_namespace(
|
||||||
|
&storage_owner.to_string(),
|
||||||
|
&chat_id,
|
||||||
|
&secret_id,
|
||||||
|
&principal_chat_id,
|
||||||
|
&principal_secret_id,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret {
|
e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret {
|
||||||
user_id: storage_owner.to_string(),
|
user_id: storage_owner.to_string(),
|
||||||
chat_id,
|
chat_id: principal_chat_id,
|
||||||
secret_id,
|
secret_id: principal_secret_id,
|
||||||
version,
|
version,
|
||||||
encrypted_secret: recipient.encrypted_secret.clone(),
|
encrypted_secret: recipient.encrypted_secret.clone(),
|
||||||
kem_ciphertext: recipient.kem_ciphertext.clone(),
|
kem_ciphertext: recipient.kem_ciphertext.clone(),
|
||||||
|
|
@ -520,9 +598,10 @@ pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
return error_response(cv, CommunicationType::ErrorInvalidData);
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
};
|
};
|
||||||
|
|
||||||
match chat_files::edit_message(
|
match chat_files::edit_message_for_principal(
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
mutation.partner_id,
|
mutation.partner_id,
|
||||||
|
mutation.partner_principal,
|
||||||
mutation.send_time,
|
mutation.send_time,
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
content,
|
content,
|
||||||
|
|
@ -545,19 +624,23 @@ pub fn handle_message_reaction(cv: &CommunicationValue, add: bool) -> Communicat
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = if add {
|
let result = if add {
|
||||||
chat_files::add_reaction(
|
chat_files::add_reaction_for_principal(
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
mutation.partner_id,
|
mutation.partner_id,
|
||||||
|
mutation.partner_principal,
|
||||||
mutation.send_time,
|
mutation.send_time,
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
|
mutation.sender_principal,
|
||||||
reaction,
|
reaction,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chat_files::remove_reaction(
|
chat_files::remove_reaction_for_principal(
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
mutation.partner_id,
|
mutation.partner_id,
|
||||||
|
mutation.partner_principal,
|
||||||
mutation.send_time,
|
mutation.send_time,
|
||||||
mutation.sender_id,
|
mutation.sender_id,
|
||||||
|
mutation.sender_principal,
|
||||||
reaction,
|
reaction,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -577,7 +660,12 @@ pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
Err(response) => return response,
|
Err(response) => return response,
|
||||||
};
|
};
|
||||||
|
|
||||||
match chat_files::delete_message(mutation.sender_id, mutation.partner_id, mutation.send_time) {
|
match chat_files::delete_message_for_principal(
|
||||||
|
mutation.sender_id,
|
||||||
|
mutation.partner_id,
|
||||||
|
mutation.partner_principal,
|
||||||
|
mutation.send_time,
|
||||||
|
) {
|
||||||
Ok(()) => success_response(cv),
|
Ok(()) => success_response(cv),
|
||||||
Err(_) => error_response(cv, CommunicationType::ErrorNotFound),
|
Err(_) => error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
}
|
}
|
||||||
|
|
@ -738,18 +826,92 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
return error_response(cv, CommunicationType::ErrorInvalidData);
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut participants = match chat_id
|
||||||
|
.split(':')
|
||||||
|
.map(str::parse::<i64>)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
{
|
||||||
|
Ok(participants) => participants,
|
||||||
|
Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData),
|
||||||
|
};
|
||||||
|
if participants.len() != 2
|
||||||
|
|| participants.iter().any(|participant| *participant <= 0)
|
||||||
|
|| participants[0] == participants[1]
|
||||||
|
|| !participants.contains(&sender_id)
|
||||||
|
{
|
||||||
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
|
}
|
||||||
|
participants.sort_unstable();
|
||||||
|
if chat_id != format!("{}:{}", participants[0], participants[1]) {
|
||||||
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
|
}
|
||||||
|
let partner_id = if participants[0] == sender_id {
|
||||||
|
participants[1]
|
||||||
|
} else {
|
||||||
|
participants[0]
|
||||||
|
};
|
||||||
|
let partner_principal = match chats_util::get_user(sender_id, partner_id) {
|
||||||
|
Ok(Some(contact)) => match contact.principal {
|
||||||
|
Some(principal) => principal,
|
||||||
|
None => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
},
|
||||||
|
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
};
|
||||||
|
let owner_principal = match iota_storage::identity::SqlitePrincipalStore
|
||||||
|
.principal_for_local_user(iota_identity::LocalUserId(sender_id))
|
||||||
|
{
|
||||||
|
Ok(Some(principal)) => principal,
|
||||||
|
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
};
|
||||||
|
let Some(principal_chat_id) =
|
||||||
|
e2ee_storage::principal_chat_id(owner_principal, partner_principal)
|
||||||
|
else {
|
||||||
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
|
};
|
||||||
|
let requested_secret_id = data_string(cv, DataType::SecretId);
|
||||||
|
let legacy_main_secret_id = format!("chat:{chat_id}:main");
|
||||||
|
if e2ee_storage::migrate_chat_secret_namespace(
|
||||||
|
&user_id,
|
||||||
|
&chat_id,
|
||||||
|
&legacy_main_secret_id,
|
||||||
|
&principal_chat_id,
|
||||||
|
&e2ee_storage::principal_secret_id(&principal_chat_id),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return error_response(cv, CommunicationType::ErrorInternal);
|
||||||
|
}
|
||||||
|
let principal_secret_id = requested_secret_id.as_ref().map(|secret_id| {
|
||||||
|
if secret_id == &legacy_main_secret_id {
|
||||||
|
e2ee_storage::principal_secret_id(&principal_chat_id)
|
||||||
|
} else {
|
||||||
|
format!("{principal_chat_id}:{secret_id}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
match e2ee_storage::get_chat_secret(ChatSecretQuery {
|
match e2ee_storage::get_chat_secret(ChatSecretQuery {
|
||||||
user_id,
|
user_id,
|
||||||
chat_id,
|
chat_id: principal_chat_id.clone(),
|
||||||
secret_id: data_string(cv, DataType::SecretId),
|
secret_id: principal_secret_id,
|
||||||
version: data_i64(cv, DataType::VersionNumber),
|
version: data_i64(cv, DataType::VersionNumber),
|
||||||
}) {
|
}) {
|
||||||
Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse)
|
Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse)
|
||||||
.with_request_id(cv)
|
.with_request_id(cv)
|
||||||
.with_receiver(sender_wire_id(sender_id))
|
.with_receiver(sender_wire_id(sender_id))
|
||||||
.add_typed_default(DataType::UserId, DataValue::Str(record.user_id))
|
.add_typed_default(DataType::UserId, DataValue::Str(record.user_id))
|
||||||
.add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id))
|
.add_typed_default(DataType::ChatId, DataValue::Str(chat_id))
|
||||||
.add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id))
|
.add_typed_default(
|
||||||
|
DataType::SecretId,
|
||||||
|
DataValue::Str(requested_secret_id.unwrap_or_else(|| {
|
||||||
|
if record.secret_id == e2ee_storage::principal_secret_id(&principal_chat_id) {
|
||||||
|
legacy_main_secret_id
|
||||||
|
} else {
|
||||||
|
record.secret_id
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::VersionNumber,
|
DataType::VersionNumber,
|
||||||
DataValue::SignedNumber(record.version as i128),
|
DataValue::SignedNumber(record.version as i128),
|
||||||
|
|
@ -1030,7 +1192,7 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal
|
||||||
DataValue::Array(
|
DataValue::Array(
|
||||||
blocked_users
|
blocked_users
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| DataValue::SignedNumber(id.into()))
|
.map(|blocked| DataValue::SignedNumber(blocked.blocked_user_id.into()))
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1153,7 +1315,7 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
||||||
},
|
},
|
||||||
None => match chat_files::get_message(owner, send_time, None) {
|
None => match chat_files::get_message(owner, send_time, None, None) {
|
||||||
Ok(Some(message)) => (message, None),
|
Ok(Some(message)) => (message, None),
|
||||||
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
|
@ -1262,7 +1424,20 @@ pub fn handle_read_notification(cv: &CommunicationValue) -> NotificationMutation
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
match chats_util::read_notifications(owner, partner_id, through) {
|
let Ok(Some(contact)) = chats_util::get_user(owner, partner_id) else {
|
||||||
|
return NotificationMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(principal) = contact.principal else {
|
||||||
|
return NotificationMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
match chats_util::read_notifications(owner, principal, through) {
|
||||||
Ok(Some(contact)) => NotificationMutation {
|
Ok(Some(contact)) => NotificationMutation {
|
||||||
response: notification_response(
|
response: notification_response(
|
||||||
CommunicationType::ReadNotification,
|
CommunicationType::ReadNotification,
|
||||||
|
|
@ -2161,7 +2336,19 @@ pub fn handle_user_block(cv: &CommunicationValue) -> PolicyMutation {
|
||||||
changed: None,
|
changed: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
match blocked_users::block(user_id, blocked_user_id) {
|
let Ok(Some(contact)) = chats_util::get_user(user_id, blocked_user_id) else {
|
||||||
|
return PolicyMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(blocked_principal) = contact.principal else {
|
||||||
|
return PolicyMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
match blocked_users::block(user_id, blocked_principal) {
|
||||||
Ok(record) => PolicyMutation {
|
Ok(record) => PolicyMutation {
|
||||||
response: CommunicationValue::new(CommunicationType::UserBlock)
|
response: CommunicationValue::new(CommunicationType::UserBlock)
|
||||||
.with_request_id(cv)
|
.with_request_id(cv)
|
||||||
|
|
@ -2209,7 +2396,19 @@ pub fn handle_user_unblock(cv: &CommunicationValue) -> PolicyMutation {
|
||||||
changed: None,
|
changed: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
match blocked_users::unblock(user_id, blocked_user_id) {
|
let Ok(Some(contact)) = chats_util::get_user(user_id, blocked_user_id) else {
|
||||||
|
return PolicyMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let Some(blocked_principal) = contact.principal else {
|
||||||
|
return PolicyMutation {
|
||||||
|
response: error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
changed: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
match blocked_users::unblock(user_id, blocked_principal) {
|
||||||
Ok(mutation) => PolicyMutation {
|
Ok(mutation) => PolicyMutation {
|
||||||
response: CommunicationValue::new(CommunicationType::UserUnblock)
|
response: CommunicationValue::new(CommunicationType::UserUnblock)
|
||||||
.with_request_id(cv)
|
.with_request_id(cv)
|
||||||
|
|
@ -2253,7 +2452,7 @@ pub fn handle_blocked_users_get(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
DataValue::Array(
|
DataValue::Array(
|
||||||
users
|
users
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|id| DataValue::SignedNumber(id.into()))
|
.map(|blocked| DataValue::SignedNumber(blocked.blocked_user_id.into()))
|
||||||
.collect(),
|
.collect(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -2422,7 +2621,15 @@ pub fn handle_user_block_check(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
let Some(receiver_id) = data_i64(cv, DataType::ReceiverId).filter(|id| *id > 0) else {
|
let Some(receiver_id) = data_i64(cv, DataType::ReceiverId).filter(|id| *id > 0) else {
|
||||||
return error_response(cv, CommunicationType::ErrorInvalidData);
|
return error_response(cv, CommunicationType::ErrorInvalidData);
|
||||||
};
|
};
|
||||||
match blocked_users::is_blocked(receiver_id, sender_id) {
|
let sender_principal = match chats_util::get_user(receiver_id, sender_id) {
|
||||||
|
Ok(Some(contact)) => match contact.principal {
|
||||||
|
Some(principal) => principal,
|
||||||
|
None => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
},
|
||||||
|
Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound),
|
||||||
|
Err(_) => return error_response(cv, CommunicationType::ErrorInternal),
|
||||||
|
};
|
||||||
|
match blocked_users::is_principal_blocked(receiver_id, sender_principal) {
|
||||||
Ok(blocked) => CommunicationValue::new(CommunicationType::UserBlockCheck)
|
Ok(blocked) => CommunicationValue::new(CommunicationType::UserBlockCheck)
|
||||||
.with_request_id(cv)
|
.with_request_id(cv)
|
||||||
.add_typed_default(DataType::IsBlocked, DataValue::Bool(blocked)),
|
.add_typed_default(DataType::IsBlocked, DataValue::Bool(blocked)),
|
||||||
|
|
@ -2439,9 +2646,11 @@ mod stored_message_tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn relay_message_value_has_a_single_sender_id() {
|
fn relay_message_value_has_a_single_sender_id() {
|
||||||
let message = StoredMessage {
|
let message = StoredMessage {
|
||||||
|
external_principal: None,
|
||||||
id: 1,
|
id: 1,
|
||||||
external_user: 9,
|
external_user: 9,
|
||||||
relay_signer_id: Some(9),
|
relay_signer_id: Some(9),
|
||||||
|
relay_signer_principal: None,
|
||||||
relay_message_id: Some("relay-1".to_string()),
|
relay_message_id: Some("relay-1".to_string()),
|
||||||
message_time: 2,
|
message_time: 2,
|
||||||
authored_at: None,
|
authored_at: None,
|
||||||
|
|
@ -2549,3 +2758,47 @@ mod synced_settings_tests {
|
||||||
assert!(response.is_err());
|
assert!(response.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RelayApplicationContext {
|
||||||
|
pub signer: iota_identity::PrincipalHandle,
|
||||||
|
pub recipient: iota_identity::PrincipalHandle,
|
||||||
|
pub signer_principal: iota_identity::PrincipalId,
|
||||||
|
pub recipient_principal: iota_identity::PrincipalId,
|
||||||
|
pub hosted_sender: Option<iota_identity::LocalUserId>,
|
||||||
|
pub hosted_recipient: Option<iota_identity::LocalUserId>,
|
||||||
|
pub legacy: Option<LegacyRelayApplicationData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LegacyRelayApplicationData {
|
||||||
|
pub signer_id: u64,
|
||||||
|
pub recipient_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_relay_application_content(
|
||||||
|
application: &RelayApplicationContext,
|
||||||
|
context: &VerifiedRelayContext,
|
||||||
|
content: &VerifiedRelayContent,
|
||||||
|
accepted_at: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if application.signer_principal.user_id != context.signer_id
|
||||||
|
|| application.recipient_principal.user_id != context.final_recipient_id
|
||||||
|
{
|
||||||
|
return Err("Relay application identity does not match verified content".into());
|
||||||
|
}
|
||||||
|
let (storage_owner, sent_by_self) =
|
||||||
|
match (application.hosted_sender, application.hosted_recipient) {
|
||||||
|
(Some(sender), _) => (sender.0, true),
|
||||||
|
(None, Some(recipient)) => (recipient.0, false),
|
||||||
|
(None, None) => return Err("Relay application has no hosted principal".into()),
|
||||||
|
};
|
||||||
|
apply_verified_relay_content(
|
||||||
|
context,
|
||||||
|
content,
|
||||||
|
application.signer,
|
||||||
|
application.recipient,
|
||||||
|
accepted_at,
|
||||||
|
storage_owner,
|
||||||
|
sent_by_self,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
1875
iota-connection/src/relay_service.rs
Normal file
1875
iota-connection/src/relay_service.rs
Normal file
File diff suppressed because it is too large
Load diff
116
iota-connection/tests/pending_restart.rs
Normal file
116
iota-connection/tests/pending_restart.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome};
|
||||||
|
use iota_connection::relay_service::PendingRelayDispatcher;
|
||||||
|
use iota_identity::{IotaNodeId, PrincipalHandle};
|
||||||
|
use iota_storage::util::relay_queue::{self, RelayIdentity, RelayTarget};
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
|
use mtp::crypto::Keyring;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
struct AcceptingRouter;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PeerRouter for AcceptingRouter {
|
||||||
|
async fn route(
|
||||||
|
&self,
|
||||||
|
destination: &RouteDestination,
|
||||||
|
_: CommunicationValue,
|
||||||
|
) -> Result<RouteOutcome, RouteError> {
|
||||||
|
assert!(matches!(destination, RouteDestination::Iota(_)));
|
||||||
|
Ok(RouteOutcome::Accepted {
|
||||||
|
relay_message_id: "restart-relay".into(),
|
||||||
|
destination_accepted_at: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize(storage: &Path) {
|
||||||
|
iota_util::file_util::configure_storage_directory(storage.to_owned());
|
||||||
|
iota_storage::util::db::initialize_database().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed(storage: &Path) {
|
||||||
|
initialize(storage);
|
||||||
|
let (signer, recipient) = iota_storage::util::db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:first', 1, 1, 1)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
let signer = connection.last_insert_rowid();
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:second', 1, 1, 1)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok((signer, connection.last_insert_rowid()))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let frame = CommunicationValue::new(CommunicationType::Relay).with_id(7);
|
||||||
|
let version = frame.type_map().unwrap().version.to_string();
|
||||||
|
let bytes = frame.to_bytes().unwrap();
|
||||||
|
relay_queue::enqueue(
|
||||||
|
RelayTarget::Iota(
|
||||||
|
IotaNodeId::from_public_keys(&Keyring::generate().public_key_bundle()).unwrap(),
|
||||||
|
),
|
||||||
|
&RelayIdentity {
|
||||||
|
signer: PrincipalHandle(signer),
|
||||||
|
recipient: PrincipalHandle(recipient),
|
||||||
|
message_id: "restart-relay".into(),
|
||||||
|
legacy_signer_id: None,
|
||||||
|
legacy_recipient_id: None,
|
||||||
|
},
|
||||||
|
&bytes,
|
||||||
|
0,
|
||||||
|
7,
|
||||||
|
&version,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(relay_queue::list_active(10).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver(storage: &Path) {
|
||||||
|
initialize(storage);
|
||||||
|
let dispatcher = PendingRelayDispatcher::new(std::sync::Arc::new(AcceptingRouter));
|
||||||
|
assert_eq!(dispatcher.dispatch_ready(10).await.unwrap(), 1);
|
||||||
|
assert_eq!(dispatcher.dispatch_ready(10).await.unwrap(), 0);
|
||||||
|
assert!(relay_queue::list(10).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_relay_survives_process_restart_and_delivers_once() {
|
||||||
|
let storage =
|
||||||
|
std::env::var_os("TENSAMIN_PENDING_RESTART_STORAGE").map(std::path::PathBuf::from);
|
||||||
|
match std::env::var("TENSAMIN_PENDING_RESTART_PHASE").as_deref() {
|
||||||
|
Ok("seed") => {
|
||||||
|
seed(storage.as_deref().unwrap());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok("deliver") => {
|
||||||
|
deliver(storage.as_deref().unwrap()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage = std::env::temp_dir().join(format!(
|
||||||
|
"tensamin-pending-restart-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
iota_storage::util::sync::now_millis()
|
||||||
|
));
|
||||||
|
std::fs::create_dir(&storage).unwrap();
|
||||||
|
let executable = std::env::current_exe().unwrap();
|
||||||
|
for phase in ["seed", "deliver"] {
|
||||||
|
let status = Command::new(&executable)
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"pending_relay_survives_process_restart_and_delivers_once",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("TENSAMIN_PENDING_RESTART_PHASE", phase)
|
||||||
|
.env("TENSAMIN_PENDING_RESTART_STORAGE", &storage)
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success(), "{phase} child process failed");
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&storage).unwrap();
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
|
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
|
||||||
use iota_cli::ui::UI;
|
use iota_cli::ui::UiHandle;
|
||||||
use iota_terms::{Doc, LegalDocument, TermsType as Type, get_current_docs};
|
use iota_terms::{Doc, LegalDocument, TermsType as Type, get_current_docs};
|
||||||
use iota_util::file_util::{load_file, save_file};
|
use iota_util::file_util::{load_file, save_file};
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
@ -13,7 +13,7 @@ pub struct ConsentCheck {
|
||||||
pub documents: [LegalDocument; 3],
|
pub documents: [LegalDocument; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check(ui: Arc<UI>) -> Result<ConsentCheck, String> {
|
pub async fn check(ui: Arc<UiHandle>) -> Result<ConsentCheck, String> {
|
||||||
let mut state = ConsentState::load_state();
|
let mut state = ConsentState::load_state();
|
||||||
|
|
||||||
let documents = ensure_initial_consent(ui.clone(), &mut state).await?;
|
let documents = ensure_initial_consent(ui.clone(), &mut state).await?;
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn non_interactive_consent() -> NonInteractiveConsent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_initial_consent(
|
async fn ensure_initial_consent(
|
||||||
ui: Arc<UI>,
|
ui: Arc<UiHandle>,
|
||||||
state: &mut ConsentState,
|
state: &mut ConsentState,
|
||||||
) -> Result<[LegalDocument; 3], String> {
|
) -> Result<[LegalDocument; 3], String> {
|
||||||
let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| {
|
let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| {
|
||||||
|
|
@ -99,7 +99,7 @@ async fn ensure_initial_consent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
async fn ensure_updates(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
|
async fn ensure_updates(ui: Arc<UiHandle>, state: &mut ConsentState) -> Result<(), String> {
|
||||||
let Some((eula_update, tos_update, privacy_update)) = get_updates().await else {
|
let Some((eula_update, tos_update, privacy_update)) = get_updates().await else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ async fn main() {
|
||||||
let ipc = IpcClient::connect(socket)
|
let ipc = IpcClient::connect(socket)
|
||||||
.await
|
.await
|
||||||
.expect("iota-daemon must be running before starting iota-core");
|
.expect("iota-daemon must be running before starting iota-core");
|
||||||
let session = start_tui(ipc).expect("interactive terminal initialization failed");
|
let session = start_tui(ipc.clone()).expect("interactive terminal initialization failed");
|
||||||
let ui = session.ui();
|
let ui = session.ui();
|
||||||
|
|
||||||
let consent = match consent_state::check(ui.clone()).await {
|
let consent = match consent_state::check(ui.clone()).await {
|
||||||
|
|
@ -83,7 +83,7 @@ async fn main() {
|
||||||
check_update();
|
check_update();
|
||||||
iota_state::setup(&state);
|
iota_state::setup(&state);
|
||||||
|
|
||||||
let main_screen = MainScreen::new(ui.clone()).await;
|
let main_screen = MainScreen::new(ipc);
|
||||||
ui.set_screen(Box::new(main_screen)).await;
|
ui.set_screen(Box::new(main_screen)).await;
|
||||||
|
|
||||||
// LANGUAGE PACK
|
// LANGUAGE PACK
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,17 @@ edition = "2024"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
iota-ipc = { path = "../iota-ipc" }
|
iota-ipc = { path = "../iota-ipc" }
|
||||||
|
iota-auth = { path = "../iota-auth" }
|
||||||
|
iota-connection = { path = "../iota-connection" }
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-state = { path = "../iota-state" }
|
iota-state = { path = "../iota-state" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
iota-updater = { path = "../iota-updater" }
|
iota-updater = { path = "../iota-updater" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
omikron-connector = { path = "../omikron-connector" }
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c" }
|
other-iota = { path = "../other-iota" }
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
sysinfo = "0.38.0"
|
sysinfo = "0.38.0"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
|
||||||
451
iota-daemon-lib/src/accounts.rs
Normal file
451
iota-daemon-lib/src/accounts.rs
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityKind, LocalNodeIdentity, LocalUserDescriptor, LocalUserId, PrincipalHome,
|
||||||
|
};
|
||||||
|
use iota_storage::users::pending_operations::{
|
||||||
|
self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase,
|
||||||
|
};
|
||||||
|
use iota_storage::users::user_manager;
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use omikron_connector::{OmikronClient, OmikronError};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LocalAccount {
|
||||||
|
pub user: LocalUserId,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct CredentialPreview {
|
||||||
|
pub user: LocalUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub assigned_iota_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ReconcileResult {
|
||||||
|
pub remote_iota_id: Option<i64>,
|
||||||
|
pub released_locally: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AccountError {
|
||||||
|
InvalidRequest(String),
|
||||||
|
Unauthorized(String),
|
||||||
|
NotFound,
|
||||||
|
Conflict(String),
|
||||||
|
Timeout(String),
|
||||||
|
Unavailable(String),
|
||||||
|
Storage(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AccountAuthority: Send + Sync {
|
||||||
|
async fn is_available(&self) -> bool;
|
||||||
|
|
||||||
|
async fn create_user(&self, request: CreateUserRequest) -> Result<LocalAccount, AccountError>;
|
||||||
|
|
||||||
|
async fn inspect_credential(
|
||||||
|
&self,
|
||||||
|
credential: &[u8],
|
||||||
|
) -> Result<CredentialPreview, AccountError>;
|
||||||
|
|
||||||
|
async fn attach_user(&self, credential: &[u8]) -> Result<LocalAccount, AccountError>;
|
||||||
|
|
||||||
|
async fn reconcile_user(&self, user: LocalUserId) -> Result<ReconcileResult, AccountError>;
|
||||||
|
|
||||||
|
async fn release_user(&self, user: LocalUserId) -> Result<(), AccountError>;
|
||||||
|
|
||||||
|
async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError>;
|
||||||
|
|
||||||
|
async fn reconcile_managed_users(&self) -> Result<(), AccountError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OmegaAccountAuthority {
|
||||||
|
client: Arc<dyn OmikronClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalIotaAccountAuthority {
|
||||||
|
identity: LocalNodeIdentity,
|
||||||
|
creation_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalIotaAccountAuthority {
|
||||||
|
pub fn new(identity: LocalNodeIdentity) -> Self {
|
||||||
|
Self {
|
||||||
|
identity,
|
||||||
|
creation_lock: Mutex::new(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AccountAuthority for LocalIotaAccountAuthority {
|
||||||
|
async fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(&self, request: CreateUserRequest) -> Result<LocalAccount, AccountError> {
|
||||||
|
if !valid_username(&request.username) {
|
||||||
|
return Err(AccountError::InvalidRequest("invalid username".into()));
|
||||||
|
}
|
||||||
|
let _creation = self
|
||||||
|
.creation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| AccountError::Internal("local account lock is poisoned".into()))?;
|
||||||
|
if user_manager::get_user_by_username(&request.username)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AccountError::Conflict("username is unavailable".into()));
|
||||||
|
}
|
||||||
|
let user_id = user_manager::allocate_local_user_id(iota_util::tu::MAX_PROTOCOL_ID)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
let keyring = iota_util::crypto_helper::generate_keyring();
|
||||||
|
let public_key =
|
||||||
|
iota_util::crypto_helper::public_key_bundle_to_base64(&keyring.public_key_bundle());
|
||||||
|
if public_key.is_empty() {
|
||||||
|
return Err(AccountError::Internal(
|
||||||
|
"generated user public key could not be encoded".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let credential = iota_util::tu::TuCredential {
|
||||||
|
user_id,
|
||||||
|
authority: iota_util::tu::TuAuthority::Iota(self.identity.node_id().clone()),
|
||||||
|
keyring,
|
||||||
|
};
|
||||||
|
iota_util::file_util::write_user_credential(
|
||||||
|
&request.username,
|
||||||
|
&credential.to_canonical_string(),
|
||||||
|
)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
let profile = iota_storage::users::user_profile::UserProfile::new(
|
||||||
|
user_id,
|
||||||
|
request.username.clone(),
|
||||||
|
None,
|
||||||
|
public_key.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
if let Err(error) = user_manager::try_add_user(profile) {
|
||||||
|
let _ = iota_util::file_util::remove_user_credential(user_id, Some(&request.username));
|
||||||
|
return Err(AccountError::Storage(error.to_string()));
|
||||||
|
}
|
||||||
|
let principal = iota_storage::identity::SqlitePrincipalStore.ensure_local_principal(
|
||||||
|
self.identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&LocalUserDescriptor {
|
||||||
|
id: LocalUserId(user_id),
|
||||||
|
username: request.username.clone(),
|
||||||
|
display_name: None,
|
||||||
|
public_key,
|
||||||
|
},
|
||||||
|
PrincipalHome::Iota(self.identity.node_id().clone()),
|
||||||
|
now_millis(),
|
||||||
|
);
|
||||||
|
if let Err(error) = principal {
|
||||||
|
let _ = user_manager::remove_user(user_id);
|
||||||
|
let _ = iota_util::file_util::remove_user_credential(user_id, Some(&request.username));
|
||||||
|
return Err(AccountError::Storage(error.to_string()));
|
||||||
|
}
|
||||||
|
Ok(LocalAccount {
|
||||||
|
user: LocalUserId(user_id),
|
||||||
|
username: request.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect_credential(
|
||||||
|
&self,
|
||||||
|
credential: &[u8],
|
||||||
|
) -> Result<CredentialPreview, AccountError> {
|
||||||
|
let credential = parse_local_credential(credential, &self.identity)?;
|
||||||
|
let user = user_manager::get_user(credential.user_id)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?
|
||||||
|
.ok_or(AccountError::NotFound)?;
|
||||||
|
if user.public_key
|
||||||
|
!= iota_util::crypto_helper::public_key_bundle_to_base64(
|
||||||
|
&credential.public_key_bundle(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(AccountError::Unauthorized(
|
||||||
|
"credential key does not match local account".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(CredentialPreview {
|
||||||
|
user: LocalUserId(user.user_id),
|
||||||
|
username: user.username,
|
||||||
|
assigned_iota_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_user(&self, credential: &[u8]) -> Result<LocalAccount, AccountError> {
|
||||||
|
let preview = self.inspect_credential(credential).await?;
|
||||||
|
Ok(LocalAccount {
|
||||||
|
user: preview.user,
|
||||||
|
username: preview.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_user(&self, user: LocalUserId) -> Result<ReconcileResult, AccountError> {
|
||||||
|
if user_manager::get_user(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AccountError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(ReconcileResult {
|
||||||
|
remote_iota_id: None,
|
||||||
|
released_locally: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_user(&self, _: LocalUserId) -> Result<(), AccountError> {
|
||||||
|
Err(AccountError::InvalidRequest(
|
||||||
|
"local Iota accounts cannot be released to another authority".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError> {
|
||||||
|
let parsed = parse_local_credential(credential, &self.identity)?;
|
||||||
|
if parsed.user_id != user.0 {
|
||||||
|
return Err(AccountError::InvalidRequest(
|
||||||
|
"credential user ID does not match deletion target".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inspect_credential(credential).await?;
|
||||||
|
user_manager::purge_user_data(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
iota_storage::identity::SqlitePrincipalStore
|
||||||
|
.retire_local_principal(user, now_millis())
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
user_manager::remove_user(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
iota_util::file_util::remove_user_credential(user.0, None)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_managed_users(&self) -> Result<(), AccountError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_local_credential(
|
||||||
|
credential: &[u8],
|
||||||
|
identity: &LocalNodeIdentity,
|
||||||
|
) -> Result<iota_util::tu::TuCredential, AccountError> {
|
||||||
|
let credential = std::str::from_utf8(credential)
|
||||||
|
.map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?;
|
||||||
|
let credential = iota_util::tu::TuCredential::parse(credential)
|
||||||
|
.map_err(|error| AccountError::InvalidRequest(error.to_string()))?;
|
||||||
|
if credential
|
||||||
|
.principal()
|
||||||
|
.map_err(|error| AccountError::InvalidRequest(error.to_string()))?
|
||||||
|
.authority
|
||||||
|
!= *identity.authority_id()
|
||||||
|
{
|
||||||
|
return Err(AccountError::Unauthorized(
|
||||||
|
"credential belongs to another authority".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_username(username: &str) -> bool {
|
||||||
|
!username.is_empty()
|
||||||
|
&& username.len() <= 15
|
||||||
|
&& username
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OmegaAccountAuthority {
|
||||||
|
pub fn new(client: Arc<dyn OmikronClient>) -> Self {
|
||||||
|
Self { client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AccountAuthority for OmegaAccountAuthority {
|
||||||
|
async fn is_available(&self) -> bool {
|
||||||
|
self.client.is_connected().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(&self, request: CreateUserRequest) -> Result<LocalAccount, AccountError> {
|
||||||
|
omikron_connector::user_ops::create_user(self.client.as_ref(), &request.username)
|
||||||
|
.await
|
||||||
|
.map(|user| LocalAccount {
|
||||||
|
user: LocalUserId(user.user_id),
|
||||||
|
username: user.username,
|
||||||
|
})
|
||||||
|
.map_err(map_create_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect_credential(
|
||||||
|
&self,
|
||||||
|
credential: &[u8],
|
||||||
|
) -> Result<CredentialPreview, AccountError> {
|
||||||
|
let credential = std::str::from_utf8(credential)
|
||||||
|
.map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?;
|
||||||
|
omikron_connector::user_ops::inspect_tu_credential(self.client.as_ref(), credential)
|
||||||
|
.await
|
||||||
|
.map(|preview| CredentialPreview {
|
||||||
|
user: LocalUserId(preview.user_id),
|
||||||
|
username: preview.username,
|
||||||
|
assigned_iota_id: preview.assigned_iota_id,
|
||||||
|
})
|
||||||
|
.map_err(map_lifecycle_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_user(&self, credential: &[u8]) -> Result<LocalAccount, AccountError> {
|
||||||
|
let credential = std::str::from_utf8(credential)
|
||||||
|
.map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?;
|
||||||
|
omikron_connector::user_ops::attach_user_from_tu(self.client.as_ref(), credential)
|
||||||
|
.await
|
||||||
|
.map(|user| LocalAccount {
|
||||||
|
user: LocalUserId(user.user_id),
|
||||||
|
username: user.username,
|
||||||
|
})
|
||||||
|
.map_err(map_lifecycle_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_user(&self, user: LocalUserId) -> Result<ReconcileResult, AccountError> {
|
||||||
|
let residency = user_manager::get_residency_by_id(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?
|
||||||
|
.ok_or(AccountError::NotFound)?;
|
||||||
|
let remote_iota_id =
|
||||||
|
omikron_connector::user_ops::get_remote_user_assignment(self.client.as_ref(), user.0)
|
||||||
|
.await
|
||||||
|
.map_err(map_lifecycle_error)?;
|
||||||
|
let local_iota_id = iota_storage::util::config_util::CONFIG
|
||||||
|
.load()
|
||||||
|
.iota_id
|
||||||
|
.and_then(|id| i64::try_from(id).ok());
|
||||||
|
let released_locally = residency.state == user_manager::LocalUserState::Managed
|
||||||
|
&& remote_iota_id != local_iota_id;
|
||||||
|
if released_locally {
|
||||||
|
user_manager::finalize_local_release(user.0, Some(&residency.username))
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(ReconcileResult {
|
||||||
|
remote_iota_id,
|
||||||
|
released_locally,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_user(&self, user: LocalUserId) -> Result<(), AccountError> {
|
||||||
|
let profile = user_manager::get_user(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?
|
||||||
|
.ok_or(AccountError::NotFound)?;
|
||||||
|
pending_operations::upsert(&PendingUserOperation {
|
||||||
|
user_id: user.0,
|
||||||
|
operation: PendingUserOperationKind::Release,
|
||||||
|
username: profile.username,
|
||||||
|
public_key: None,
|
||||||
|
private_key_hash: None,
|
||||||
|
reset_token: None,
|
||||||
|
registration_token: None,
|
||||||
|
phase: PendingUserOperationPhase::Prepared,
|
||||||
|
created_at: now_millis(),
|
||||||
|
})
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota)
|
||||||
|
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user.0.into()));
|
||||||
|
match self
|
||||||
|
.client
|
||||||
|
.await_response(&request, Duration::from_secs(20))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) if response.is_type(CommunicationType::Success) => {
|
||||||
|
user_manager::release_user(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))?;
|
||||||
|
pending_operations::remove(user.0)
|
||||||
|
.map_err(|error| AccountError::Storage(error.to_string()))
|
||||||
|
}
|
||||||
|
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => {
|
||||||
|
let _ = pending_operations::remove(user.0);
|
||||||
|
Err(AccountError::Unauthorized(
|
||||||
|
"release was not authorized".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = pending_operations::remove(user.0);
|
||||||
|
Err(AccountError::Conflict("release was rejected".into()))
|
||||||
|
}
|
||||||
|
Err(OmikronError::Timeout(message)) => Err(AccountError::Timeout(message)),
|
||||||
|
Err(error) => Err(AccountError::Unavailable(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError> {
|
||||||
|
let credential = std::str::from_utf8(credential)
|
||||||
|
.map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?;
|
||||||
|
omikron_connector::user_ops::complete_delete_user_with_tu(
|
||||||
|
self.client.as_ref(),
|
||||||
|
credential,
|
||||||
|
user.0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_lifecycle_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_managed_users(&self) -> Result<(), AccountError> {
|
||||||
|
omikron_connector::user_ops::reconcile_managed_users(self.client.as_ref()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_create_error(error: omikron_connector::user_ops::CreateUserError) -> AccountError {
|
||||||
|
use omikron_connector::user_ops::CreateUserError;
|
||||||
|
match error {
|
||||||
|
CreateUserError::InvalidUsername => AccountError::InvalidRequest("invalid username".into()),
|
||||||
|
CreateUserError::Transport(OmikronError::Timeout(message)) => {
|
||||||
|
AccountError::Timeout(message)
|
||||||
|
}
|
||||||
|
CreateUserError::Transport(error) => AccountError::Unavailable(error.to_string()),
|
||||||
|
CreateUserError::RemoteRejected => AccountError::Conflict("username is unavailable".into()),
|
||||||
|
CreateUserError::LocalFinalizationPending { user_id } => {
|
||||||
|
AccountError::Storage(format!("local finalization is pending for user {user_id}"))
|
||||||
|
}
|
||||||
|
CreateUserError::LocalPersistence(message) => AccountError::Storage(message),
|
||||||
|
CreateUserError::InvalidResponse => {
|
||||||
|
AccountError::Internal("Omega returned an invalid create-user response".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_lifecycle_error(error: omikron_connector::user_ops::LifecycleUserError) -> AccountError {
|
||||||
|
use omikron_connector::user_ops::LifecycleUserError;
|
||||||
|
match error {
|
||||||
|
LifecycleUserError::InvalidCredential(message) => AccountError::Unauthorized(message),
|
||||||
|
LifecycleUserError::OmegaHostMismatch => {
|
||||||
|
AccountError::Unauthorized("credential belongs to another Omega".into())
|
||||||
|
}
|
||||||
|
LifecycleUserError::RemoteRejected => {
|
||||||
|
AccountError::Unauthorized("credential was rejected".into())
|
||||||
|
}
|
||||||
|
LifecycleUserError::Transport(OmikronError::Timeout(message)) => {
|
||||||
|
AccountError::Timeout(message)
|
||||||
|
}
|
||||||
|
LifecycleUserError::Transport(error) => AccountError::Unavailable(error.to_string()),
|
||||||
|
LifecycleUserError::LocalPersistence(message) => AccountError::Storage(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,7 @@ use iota_ipc::{
|
||||||
UserOperationSummary, UserReconcileResult, UserSummary,
|
UserOperationSummary, UserReconcileResult, UserSummary,
|
||||||
};
|
};
|
||||||
use iota_logger::{log, log_command};
|
use iota_logger::{log, log_command};
|
||||||
use iota_storage::users::pending_operations::{
|
use iota_storage::users::pending_operations::{self, PendingUserOperationKind};
|
||||||
self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase,
|
|
||||||
};
|
|
||||||
use iota_storage::users::user_manager;
|
use iota_storage::users::user_manager;
|
||||||
use iota_storage::util::config_util::{self};
|
use iota_storage::util::config_util::{self};
|
||||||
use iota_util::mtp_compat::OptionalDataValueExt;
|
use iota_util::mtp_compat::OptionalDataValueExt;
|
||||||
|
|
@ -19,7 +17,9 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::accounts::{AccountError, CreateUserRequest};
|
||||||
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||||
|
use iota_identity::LocalUserId;
|
||||||
|
|
||||||
pub use iota_ipc::IpcRole;
|
pub use iota_ipc::IpcRole;
|
||||||
|
|
||||||
|
|
@ -172,7 +172,7 @@ impl CommandRouter {
|
||||||
{
|
{
|
||||||
return ResponseResult::Error(IpcErrorCode::Unauthorized);
|
return ResponseResult::Error(IpcErrorCode::Unauthorized);
|
||||||
}
|
}
|
||||||
let needs_omikron = matches!(
|
let needs_account_authority = matches!(
|
||||||
request,
|
request,
|
||||||
LocalRequest::CreateUser { .. }
|
LocalRequest::CreateUser { .. }
|
||||||
| LocalRequest::InspectTuCredential { .. }
|
| LocalRequest::InspectTuCredential { .. }
|
||||||
|
|
@ -180,12 +180,17 @@ impl CommandRouter {
|
||||||
| LocalRequest::ReconcileUser { .. }
|
| LocalRequest::ReconcileUser { .. }
|
||||||
| LocalRequest::ReleaseUser { .. }
|
| LocalRequest::ReleaseUser { .. }
|
||||||
| LocalRequest::CompleteDeleteUser { .. }
|
| LocalRequest::CompleteDeleteUser { .. }
|
||||||
| LocalRequest::CreateInvitation {
|
);
|
||||||
|
let needs_centralized_provider = matches!(
|
||||||
|
request,
|
||||||
|
LocalRequest::CreateInvitation {
|
||||||
authority: InvitationAuthority::Omega,
|
authority: InvitationAuthority::Omega,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if needs_omikron && !self.services.omikron.is_connected().await {
|
if (needs_account_authority && !self.services.accounts.is_available().await)
|
||||||
|
|| (needs_centralized_provider && self.services.centralized.is_none())
|
||||||
|
{
|
||||||
return ResponseResult::Error(
|
return ResponseResult::Error(
|
||||||
if self.runtime.current_startup_phase() != StartupPhase::Ready {
|
if self.runtime.current_startup_phase() != StartupPhase::Ready {
|
||||||
IpcErrorCode::NotReady
|
IpcErrorCode::NotReady
|
||||||
|
|
@ -287,6 +292,9 @@ impl CommandRouter {
|
||||||
if lifetime_seconds == 0 || lifetime_seconds > 7 * 24 * 60 * 60 {
|
if lifetime_seconds == 0 || lifetime_seconds > 7 * 24 * 60 * 60 {
|
||||||
return ResponseResult::Error(IpcErrorCode::InvalidRequest);
|
return ResponseResult::Error(IpcErrorCode::InvalidRequest);
|
||||||
}
|
}
|
||||||
|
let Some(omikron) = self.services.omikron() else {
|
||||||
|
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||||
|
};
|
||||||
let password_protected = password.is_some();
|
let password_protected = password.is_some();
|
||||||
let mut request = CommunicationValue::new(CommunicationType::CreateUserInvitation)
|
let mut request = CommunicationValue::new(CommunicationType::CreateUserInvitation)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
|
|
@ -309,9 +317,7 @@ impl CommandRouter {
|
||||||
DataValue::Str(label.clone()),
|
DataValue::Str(label.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let response = match self
|
let response = match omikron
|
||||||
.services
|
|
||||||
.omikron
|
|
||||||
.await_response(&request, Duration::from_secs(20))
|
.await_response(&request, Duration::from_secs(20))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -387,9 +393,10 @@ impl CommandRouter {
|
||||||
}
|
}
|
||||||
LocalRequest::ListInvitations { authority } => {
|
LocalRequest::ListInvitations { authority } => {
|
||||||
if authority != Some(InvitationAuthority::Iota)
|
if authority != Some(InvitationAuthority::Iota)
|
||||||
&& self.services.omikron.is_connected().await
|
&& let Some(omikron) = self.services.omikron()
|
||||||
|
&& omikron.is_connected().await
|
||||||
{
|
{
|
||||||
match self.services.omikron.sync_omega_invitations().await {
|
match omikron.sync_omega_invitations().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(omikron_connector::OmikronError::Storage(_)) => {
|
Err(omikron_connector::OmikronError::Storage(_)) => {
|
||||||
return ResponseResult::Error(IpcErrorCode::StorageFailure);
|
return ResponseResult::Error(IpcErrorCode::StorageFailure);
|
||||||
|
|
@ -487,11 +494,10 @@ impl CommandRouter {
|
||||||
if !changed {
|
if !changed {
|
||||||
return ResponseResult::Error(IpcErrorCode::Conflict);
|
return ResponseResult::Error(IpcErrorCode::Conflict);
|
||||||
}
|
}
|
||||||
if self.services.omikron.is_connected().await {
|
if let Some(omikron) = self.services.omikron()
|
||||||
self.services
|
&& omikron.is_connected().await
|
||||||
.omikron
|
{
|
||||||
.flush_pending_invitation_actions()
|
omikron.flush_pending_invitation_actions().await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
let invitation =
|
let invitation =
|
||||||
match iota_storage::users::invitations::list()
|
match iota_storage::users::invitations::list()
|
||||||
|
|
@ -541,40 +547,41 @@ impl CommandRouter {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
LocalRequest::CreateUser { username } => {
|
LocalRequest::CreateUser { username } => {
|
||||||
match omikron_connector::user_ops::create_user(
|
match self
|
||||||
self.services.omikron.as_ref(),
|
.services
|
||||||
&username,
|
.accounts
|
||||||
)
|
.create_user(CreateUserRequest { username })
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated {
|
Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated {
|
||||||
user_id: user.user_id,
|
user_id: user.user.0,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
}),
|
}),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log!("User creation failed: {error:?}");
|
log!("User creation failed: {error:?}");
|
||||||
match error {
|
match error {
|
||||||
omikron_connector::user_ops::CreateUserError::InvalidUsername => {
|
AccountError::InvalidRequest(_) => {
|
||||||
ResponseResult::Error(IpcErrorCode::InvalidRequest)
|
ResponseResult::Error(IpcErrorCode::InvalidRequest)
|
||||||
}
|
}
|
||||||
omikron_connector::user_ops::CreateUserError::Transport(
|
AccountError::Timeout(_) => {
|
||||||
omikron_connector::OmikronError::Timeout(_),
|
ResponseResult::Error(IpcErrorCode::Timeout)
|
||||||
) => ResponseResult::Error(IpcErrorCode::Timeout),
|
}
|
||||||
omikron_connector::user_ops::CreateUserError::Transport(_) => {
|
AccountError::Unavailable(_) => {
|
||||||
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
|
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
|
||||||
}
|
}
|
||||||
omikron_connector::user_ops::CreateUserError::RemoteRejected => {
|
AccountError::Conflict(_) => {
|
||||||
ResponseResult::Error(IpcErrorCode::Conflict)
|
ResponseResult::Error(IpcErrorCode::Conflict)
|
||||||
}
|
}
|
||||||
omikron_connector::user_ops::CreateUserError::LocalFinalizationPending { .. } => {
|
AccountError::Storage(_) => {
|
||||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
||||||
}
|
}
|
||||||
omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => {
|
AccountError::Internal(_) => {
|
||||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
|
||||||
}
|
|
||||||
omikron_connector::user_ops::CreateUserError::InvalidResponse => {
|
|
||||||
ResponseResult::Error(IpcErrorCode::InternalFailure)
|
ResponseResult::Error(IpcErrorCode::InternalFailure)
|
||||||
}
|
}
|
||||||
|
AccountError::Unauthorized(_) => {
|
||||||
|
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
||||||
|
}
|
||||||
|
AccountError::NotFound => ResponseResult::Error(IpcErrorCode::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -594,14 +601,14 @@ impl CommandRouter {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
LocalRequest::AttachUserFromTu { credential } => {
|
LocalRequest::AttachUserFromTu { credential } => {
|
||||||
match omikron_connector::user_ops::attach_user_from_tu(
|
match self
|
||||||
self.services.omikron.as_ref(),
|
.services
|
||||||
&credential.0,
|
.accounts
|
||||||
)
|
.attach_user(credential.0.as_bytes())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
message: format!("Added {} ({}) to this Iota", user.username, user.user_id),
|
message: format!("Added {} ({}) to this Iota", user.username, user.user.0),
|
||||||
}),
|
}),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log!("Credential attach failed: {error:?}");
|
log!("Credential attach failed: {error:?}");
|
||||||
|
|
@ -610,15 +617,15 @@ impl CommandRouter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LocalRequest::InspectTuCredential { credential } => {
|
LocalRequest::InspectTuCredential { credential } => {
|
||||||
match omikron_connector::user_ops::inspect_tu_credential(
|
match self
|
||||||
self.services.omikron.as_ref(),
|
.services
|
||||||
&credential.0,
|
.accounts
|
||||||
)
|
.inspect_credential(credential.0.as_bytes())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(preview) => ResponseResult::Ok(ResponsePayload::TuCredentialPreview(
|
Ok(preview) => ResponseResult::Ok(ResponsePayload::TuCredentialPreview(
|
||||||
iota_ipc::TuCredentialPreview {
|
iota_ipc::TuCredentialPreview {
|
||||||
user_id: preview.user_id,
|
user_id: preview.user.0,
|
||||||
username: preview.username,
|
username: preview.username,
|
||||||
assigned_iota_id: preview.assigned_iota_id,
|
assigned_iota_id: preview.assigned_iota_id,
|
||||||
},
|
},
|
||||||
|
|
@ -635,32 +642,20 @@ impl CommandRouter {
|
||||||
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
|
Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound),
|
||||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||||
};
|
};
|
||||||
let omega_iota_id = match omikron_connector::user_ops::get_remote_user_assignment(
|
let reconciliation = match self
|
||||||
self.services.omikron.as_ref(),
|
.services
|
||||||
user_id,
|
.accounts
|
||||||
)
|
.reconcile_user(LocalUserId(user_id))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(assignment) => assignment,
|
Ok(result) => result,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log!("User reconciliation failed for {user_id}: {error:?}");
|
log!("User reconciliation failed for {user_id}: {error:?}");
|
||||||
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let local_iota_id = config_util::CONFIG
|
let action = if reconciliation.released_locally {
|
||||||
.load()
|
ReconcileAction::ReleasedLocally
|
||||||
.iota_id
|
|
||||||
.and_then(|id| i64::try_from(id).ok());
|
|
||||||
let action = if residency.state == user_manager::LocalUserState::Managed
|
|
||||||
&& omega_iota_id != local_iota_id
|
|
||||||
{
|
|
||||||
match user_manager::finalize_local_release(user_id, Some(&residency.username)) {
|
|
||||||
Ok(()) => ReconcileAction::ReleasedLocally,
|
|
||||||
Err(error) => {
|
|
||||||
log!("User reconciliation cleanup failed for {user_id}: {error}");
|
|
||||||
return ResponseResult::Error(IpcErrorCode::StorageFailure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ReconcileAction::None
|
ReconcileAction::None
|
||||||
};
|
};
|
||||||
|
|
@ -672,7 +667,7 @@ impl CommandRouter {
|
||||||
iota_ipc::LocalUserState::Released
|
iota_ipc::LocalUserState::Released
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
omega_iota_id,
|
omega_iota_id: reconciliation.remote_iota_id,
|
||||||
action,
|
action,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -787,11 +782,10 @@ impl CommandRouter {
|
||||||
let Ok(contents) = contents else {
|
let Ok(contents) = contents else {
|
||||||
return ResponseResult::Error(IpcErrorCode::Unauthorized);
|
return ResponseResult::Error(IpcErrorCode::Unauthorized);
|
||||||
};
|
};
|
||||||
match omikron_connector::user_ops::complete_delete_user_with_tu(
|
match self
|
||||||
self.services.omikron.as_ref(),
|
.services
|
||||||
&contents,
|
.accounts
|
||||||
user_id,
|
.delete_user(LocalUserId(user_id), contents.as_bytes())
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
|
@ -805,76 +799,41 @@ impl CommandRouter {
|
||||||
}
|
}
|
||||||
LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||||
LocalRequest::ReleaseUser { user_id } => {
|
LocalRequest::ReleaseUser { user_id } => {
|
||||||
let user = match user_manager::get_user(user_id) {
|
|
||||||
Ok(user) => user,
|
|
||||||
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
|
|
||||||
};
|
|
||||||
let Some(user) = user else {
|
|
||||||
return ResponseResult::Error(IpcErrorCode::NotFound);
|
|
||||||
};
|
|
||||||
if pending_operations::upsert(&PendingUserOperation {
|
|
||||||
user_id,
|
|
||||||
operation: PendingUserOperationKind::Release,
|
|
||||||
username: user.username,
|
|
||||||
public_key: None,
|
|
||||||
private_key_hash: None,
|
|
||||||
reset_token: None,
|
|
||||||
registration_token: None,
|
|
||||||
phase: PendingUserOperationPhase::Prepared,
|
|
||||||
created_at: now_millis(),
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return ResponseResult::Error(IpcErrorCode::StorageFailure);
|
|
||||||
}
|
|
||||||
let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota)
|
|
||||||
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()));
|
|
||||||
match self
|
match self
|
||||||
.services
|
.services
|
||||||
.omikron
|
.accounts
|
||||||
.await_response(&request, Duration::from_secs(20))
|
.release_user(LocalUserId(user_id))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) if response.is_type(CommunicationType::Success) => {
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
match user_manager::release_user(user_id) {
|
message: format!("Released user {user_id}; hosted data was retained"),
|
||||||
Ok(()) if pending_operations::remove(user_id).is_ok() => {
|
}),
|
||||||
ResponseResult::Ok(ResponsePayload::Acknowledged {
|
Err(AccountError::NotFound) => ResponseResult::Error(IpcErrorCode::NotFound),
|
||||||
message: format!(
|
Err(AccountError::Unauthorized(_)) => {
|
||||||
"Released user {user_id}; hosted data was retained"
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Ok(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
|
||||||
Err(error) => {
|
|
||||||
log!(
|
|
||||||
"Remote release succeeded but local cleanup failed for {user_id}: {error}"
|
|
||||||
);
|
|
||||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => {
|
|
||||||
let _ = pending_operations::remove(user_id);
|
|
||||||
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Err(AccountError::Conflict(_)) => ResponseResult::Error(IpcErrorCode::Conflict),
|
||||||
let _ = pending_operations::remove(user_id);
|
Err(AccountError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout),
|
||||||
ResponseResult::Error(IpcErrorCode::Conflict)
|
Err(AccountError::Unavailable(_)) => {
|
||||||
|
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
|
||||||
}
|
}
|
||||||
Err(omikron_connector::OmikronError::Timeout(_)) => {
|
Err(AccountError::Storage(_)) => {
|
||||||
ResponseResult::Error(IpcErrorCode::Timeout)
|
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
||||||
}
|
}
|
||||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
Err(_) => ResponseResult::Error(IpcErrorCode::InternalFailure),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
LocalRequest::ReconnectOmikron => match self.services.omikron() {
|
||||||
|
Some(omikron) => match omikron.reconnect().await {
|
||||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
message: "Reconnected to Omikron server".into(),
|
message: "Reconnected to Omikron server".into(),
|
||||||
}),
|
}),
|
||||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
},
|
},
|
||||||
LocalRequest::RotateIotaIdentity => {
|
None => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
match self.services.omikron.rotate_identity().await {
|
},
|
||||||
|
LocalRequest::RotateIotaIdentity => match self.services.omikron() {
|
||||||
|
Some(omikron) => match omikron.rotate_identity().await {
|
||||||
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
message: "New identity registered with Omikron".into(),
|
message: "New identity registered with Omikron".into(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -882,8 +841,9 @@ impl CommandRouter {
|
||||||
log!("Iota identity rotation failed: {}", error);
|
log!("Iota identity rotation failed: {}", error);
|
||||||
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
|
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
None => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
|
},
|
||||||
LocalRequest::RequestProcessExit { intent } => {
|
LocalRequest::RequestProcessExit { intent } => {
|
||||||
if matches!(intent, ExitIntent::Restart)
|
if matches!(intent, ExitIntent::Restart)
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
|
|
@ -938,7 +898,10 @@ impl CommandRouter {
|
||||||
Err(_) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
Err(_) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||||
},
|
},
|
||||||
LocalRequest::GetOmikronStatus => {
|
LocalRequest::GetOmikronStatus => {
|
||||||
let connected = self.services.omikron.is_connected().await;
|
let connected = match self.services.omikron() {
|
||||||
|
Some(omikron) => omikron.is_connected().await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
let iota_id = config_util::CONFIG.load().iota_id;
|
let iota_id = config_util::CONFIG.load().iota_id;
|
||||||
ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
|
ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
|
||||||
connected,
|
connected,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod accounts;
|
||||||
pub mod command_router;
|
pub mod command_router;
|
||||||
pub mod daemon_state;
|
pub mod daemon_state;
|
||||||
pub mod deployment;
|
pub mod deployment;
|
||||||
|
|
@ -7,8 +8,9 @@ pub mod log_buffer;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod task_registry;
|
pub mod task_registry;
|
||||||
|
|
||||||
|
pub use accounts::{AccountAuthority, LocalIotaAccountAuthority, OmegaAccountAuthority};
|
||||||
pub use command_router::{CommandRouter, IpcRole, PeerContext};
|
pub use command_router::{CommandRouter, IpcRole, PeerContext};
|
||||||
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
|
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
|
||||||
pub use ipc_server::IpcServer;
|
pub use ipc_server::IpcServer;
|
||||||
pub use services::DaemonServices;
|
pub use services::{DaemonServiceComponents, DaemonServices};
|
||||||
pub use task_registry::TaskRegistry;
|
pub use task_registry::TaskRegistry;
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,524 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use iota_auth::{
|
||||||
|
ForeignPrincipalAuthenticator, HostedSessionRegistrar, IotaPeerAuthenticator, SessionManager,
|
||||||
|
};
|
||||||
|
use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome};
|
||||||
|
use iota_connection::relay_service::{
|
||||||
|
LegacyRelayDecoder, LegacyRelayIdentity, RelayNodeIdentity, RelayService,
|
||||||
|
};
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityId, AuthorityKind, AuthorityLocator, CompositeIdentityResolver, IdentityError,
|
||||||
|
IdentityResolver, IotaNodeId, LocalUserStore, NodeDirectory, NodeIdentityResolver,
|
||||||
|
PrincipalHome, PrincipalId, PrincipalStore, ResolutionContext, ResolvedNodeIdentity,
|
||||||
|
ResolvedPrincipal, UserAddress,
|
||||||
|
};
|
||||||
use mtp::codec::CommunicationValue;
|
use mtp::codec::CommunicationValue;
|
||||||
use omikron_connector::{OmikronClient, OmikronConnection, OmikronError};
|
use omikron_connector::{OmikronClient, OmikronConnection};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
use crate::accounts::{
|
||||||
|
AccountAuthority, AccountError, CreateUserRequest, CredentialPreview, LocalAccount,
|
||||||
|
LocalIotaAccountAuthority, OmegaAccountAuthority, ReconcileResult,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct UserService;
|
pub struct UserService;
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ConfigService;
|
pub struct ConfigService;
|
||||||
|
|
||||||
pub struct DaemonServices {
|
pub struct CentralizedServices {
|
||||||
pub omikron: Arc<dyn OmikronClient>,
|
pub omikron: Arc<dyn OmikronClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuthService {
|
||||||
|
pub hosted_accounts: Arc<HostedSessionRegistrar>,
|
||||||
|
pub foreign_principals: Arc<ForeignPrincipalAuthenticator>,
|
||||||
|
pub iota_peers: Arc<IotaPeerAuthenticator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DaemonServiceComponents {
|
||||||
|
pub accounts: Arc<dyn AccountAuthority>,
|
||||||
|
pub identities: Arc<dyn IdentityResolver>,
|
||||||
|
pub principals: Arc<dyn PrincipalStore>,
|
||||||
|
pub local_users: Arc<dyn LocalUserStore>,
|
||||||
|
pub node_identities: Arc<dyn NodeIdentityResolver>,
|
||||||
|
pub relay: Arc<RelayService>,
|
||||||
|
pub router: Arc<dyn PeerRouter>,
|
||||||
|
pub direct_router: Option<Arc<other_iota::DirectPeerRouter>>,
|
||||||
|
pub relay_routers: Option<Arc<other_iota::relay_router::RelayRouterSet>>,
|
||||||
|
pub centralized: Option<Arc<CentralizedServices>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DaemonServices {
|
||||||
|
pub accounts: Arc<dyn AccountAuthority>,
|
||||||
|
pub identities: Arc<dyn IdentityResolver>,
|
||||||
|
pub principals: Arc<dyn PrincipalStore>,
|
||||||
|
pub local_users: Arc<dyn LocalUserStore>,
|
||||||
|
pub relay: Arc<RelayService>,
|
||||||
|
pub router: Arc<dyn PeerRouter>,
|
||||||
|
pub direct_router: Option<Arc<other_iota::DirectPeerRouter>>,
|
||||||
|
pub relay_routers: Option<Arc<other_iota::relay_router::RelayRouterSet>>,
|
||||||
|
pub sessions: Arc<SessionManager>,
|
||||||
|
pub auth: Arc<AuthService>,
|
||||||
|
pub centralized: Option<Arc<CentralizedServices>>,
|
||||||
pub users: Arc<UserService>,
|
pub users: Arc<UserService>,
|
||||||
pub config: Arc<ConfigService>,
|
pub config: Arc<ConfigService>,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonServices {
|
impl DaemonServices {
|
||||||
pub fn new(omikron: Arc<OmikronConnection>) -> Arc<Self> {
|
pub fn standalone(
|
||||||
|
identity: iota_identity::LocalNodeIdentity,
|
||||||
|
) -> Result<Arc<Self>, IdentityError> {
|
||||||
|
let principals = Arc::new(iota_storage::identity::SqlitePrincipalStore);
|
||||||
|
let local_users: Arc<dyn LocalUserStore> =
|
||||||
|
Arc::new(iota_storage::identity::SqliteLocalUserStore);
|
||||||
|
let local_identity: Arc<dyn IdentityResolver> =
|
||||||
|
Arc::new(iota_storage::identity::LocalIdentityResolver::new(
|
||||||
|
identity.authority_id().clone(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
PrincipalHome::Iota(identity.node_id().clone()),
|
||||||
|
local_users.clone(),
|
||||||
|
principals.clone(),
|
||||||
|
));
|
||||||
|
let nodes = Arc::new(iota_storage::node_directory::SqliteNodeDirectory);
|
||||||
|
let node_directory: Arc<dyn NodeDirectory> = nodes.clone();
|
||||||
|
let federation_client: Arc<dyn other_iota::FederationIdentityClient> =
|
||||||
|
Arc::new(other_iota::HttpFederationIdentityClient::default());
|
||||||
|
let remote_iota: Arc<dyn IdentityResolver> = Arc::new(other_iota::RemoteIotaResolver::new(
|
||||||
|
federation_client.clone(),
|
||||||
|
principals.clone(),
|
||||||
|
node_directory.clone(),
|
||||||
|
));
|
||||||
|
let remote_omega: Arc<dyn IdentityResolver> =
|
||||||
|
Arc::new(other_iota::RemoteOmegaResolver::new(
|
||||||
|
federation_client,
|
||||||
|
principals.clone(),
|
||||||
|
node_directory,
|
||||||
|
));
|
||||||
|
let identities: Arc<dyn IdentityResolver> =
|
||||||
|
Arc::new(CompositeIdentityResolver::new(vec![
|
||||||
|
local_identity,
|
||||||
|
remote_iota,
|
||||||
|
remote_omega,
|
||||||
|
])?);
|
||||||
|
let direct_router = Arc::new(other_iota::DirectPeerRouter::default());
|
||||||
|
let relay_routers = Arc::new(other_iota::relay_router::RelayRouterSet::default());
|
||||||
|
let router: Arc<dyn PeerRouter> =
|
||||||
|
Arc::new(other_iota::relay_router::DirectThenRelayRouter::new(
|
||||||
|
direct_router.clone(),
|
||||||
|
relay_routers.clone(),
|
||||||
|
));
|
||||||
|
let relay_identity: Arc<dyn RelayNodeIdentity> =
|
||||||
|
Arc::new(StandaloneRelayIdentity(identity.clone()));
|
||||||
|
let relay = Arc::new(
|
||||||
|
RelayService::new(local_users.clone(), router.clone(), None)
|
||||||
|
.with_federation(identities.clone(), relay_identity),
|
||||||
|
);
|
||||||
|
Ok(Self::compose(DaemonServiceComponents {
|
||||||
|
accounts: Arc::new(LocalIotaAccountAuthority::new(identity.clone())),
|
||||||
|
identities,
|
||||||
|
principals,
|
||||||
|
local_users,
|
||||||
|
node_identities: nodes,
|
||||||
|
relay,
|
||||||
|
router,
|
||||||
|
direct_router: Some(direct_router),
|
||||||
|
relay_routers: Some(relay_routers),
|
||||||
|
centralized: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(omikron: Arc<OmikronConnection>) -> Result<Arc<Self>, IdentityError> {
|
||||||
|
let client: Arc<dyn OmikronClient> = omikron.clone();
|
||||||
|
let authority =
|
||||||
|
AuthorityId::omega_legacy(&omikron_connector::omega_discovery::omega_host())
|
||||||
|
.map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?;
|
||||||
|
iota_storage::identity::SqlitePrincipalStore.migrate_legacy_omega_authority(&authority)?;
|
||||||
|
let router: Arc<dyn PeerRouter> = Arc::new(
|
||||||
|
omikron_connector::router::OmikronPeerRouter::new(client.clone(), authority.clone()),
|
||||||
|
);
|
||||||
|
let services = Self::centralized(
|
||||||
|
client,
|
||||||
|
Some(router),
|
||||||
|
Some(omikron.session_manager()),
|
||||||
|
Some(omikron.clone()),
|
||||||
|
);
|
||||||
|
let _ = omikron.install_relay_service(services.relay.clone());
|
||||||
|
Ok(services)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_centralized_client(client: Arc<dyn OmikronClient>) -> Arc<Self> {
|
||||||
|
let inactive = Arc::new(InactiveServices);
|
||||||
|
Self::centralized(client, None, None, Some(inactive))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centralized(
|
||||||
|
client: Arc<dyn OmikronClient>,
|
||||||
|
router: Option<Arc<dyn PeerRouter>>,
|
||||||
|
sessions: Option<Arc<SessionManager>>,
|
||||||
|
node_identity: Option<Arc<dyn LegacyRelayIdentity>>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
let principals: Arc<dyn PrincipalStore> =
|
||||||
|
Arc::new(iota_storage::identity::SqlitePrincipalStore);
|
||||||
|
let local_users: Arc<dyn LocalUserStore> =
|
||||||
|
Arc::new(iota_storage::identity::SqliteLocalUserStore);
|
||||||
|
let omega_host = omikron_connector::omega_discovery::omega_host();
|
||||||
|
let authority = AuthorityId::omega_legacy(&omega_host)
|
||||||
|
.expect("the configured Omega authority ID is valid");
|
||||||
|
let locator = AuthorityLocator::new(omega_host)
|
||||||
|
.expect("the configured Omega host is a valid authority locator");
|
||||||
|
let local_identity: Arc<dyn IdentityResolver> =
|
||||||
|
Arc::new(iota_storage::identity::LocalIdentityResolver::new(
|
||||||
|
authority.clone(),
|
||||||
|
AuthorityKind::Omega,
|
||||||
|
PrincipalHome::Omega(locator.clone()),
|
||||||
|
local_users.clone(),
|
||||||
|
Arc::new(iota_storage::identity::SqlitePrincipalStore),
|
||||||
|
));
|
||||||
|
let omega_identity: Arc<dyn IdentityResolver> =
|
||||||
|
Arc::new(omikron_connector::identity::OmegaIdentityResolver::new(
|
||||||
|
client.clone(),
|
||||||
|
authority.clone(),
|
||||||
|
locator,
|
||||||
|
principals.clone(),
|
||||||
|
));
|
||||||
|
let identities: Arc<dyn IdentityResolver> = Arc::new(
|
||||||
|
CompositeIdentityResolver::new(vec![local_identity, omega_identity])
|
||||||
|
.expect("the identity resolver chain is not empty"),
|
||||||
|
);
|
||||||
|
let router: Arc<dyn PeerRouter> = router.unwrap_or_else(|| {
|
||||||
|
Arc::new(omikron_connector::router::OmikronPeerRouter::new(
|
||||||
|
client.clone(),
|
||||||
|
authority.clone(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let legacy_decoder = node_identity.map(|node_identity| {
|
||||||
|
Arc::new(LegacyRelayDecoder::new(
|
||||||
|
identities.clone(),
|
||||||
|
node_identity,
|
||||||
|
authority,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let relay = Arc::new(RelayService::new(
|
||||||
|
local_users.clone(),
|
||||||
|
router.clone(),
|
||||||
|
legacy_decoder,
|
||||||
|
));
|
||||||
|
Self::compose_with_sessions(
|
||||||
|
DaemonServiceComponents {
|
||||||
|
accounts: Arc::new(OmegaAccountAuthority::new(client.clone())),
|
||||||
|
identities,
|
||||||
|
principals,
|
||||||
|
local_users,
|
||||||
|
node_identities: Arc::new(InactiveNodeIdentityResolver),
|
||||||
|
relay,
|
||||||
|
router,
|
||||||
|
direct_router: None,
|
||||||
|
relay_routers: None,
|
||||||
|
centralized: Some(Arc::new(CentralizedServices { omikron: client })),
|
||||||
|
},
|
||||||
|
sessions.unwrap_or_else(|| Arc::new(SessionManager::default())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compose(components: DaemonServiceComponents) -> Arc<Self> {
|
||||||
|
Self::compose_with_sessions(components, Arc::new(SessionManager::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compose_with_sessions(
|
||||||
|
components: DaemonServiceComponents,
|
||||||
|
sessions: Arc<SessionManager>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
let auth = Arc::new(AuthService {
|
||||||
|
hosted_accounts: Arc::new(HostedSessionRegistrar::new(sessions.clone())),
|
||||||
|
foreign_principals: Arc::new(ForeignPrincipalAuthenticator::new(
|
||||||
|
components.identities.clone(),
|
||||||
|
sessions.clone(),
|
||||||
|
)),
|
||||||
|
iota_peers: Arc::new(IotaPeerAuthenticator::new(
|
||||||
|
components.node_identities.clone(),
|
||||||
|
)),
|
||||||
|
});
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
omikron,
|
accounts: components.accounts,
|
||||||
|
identities: components.identities,
|
||||||
|
principals: components.principals,
|
||||||
|
local_users: components.local_users,
|
||||||
|
relay: components.relay,
|
||||||
|
router: components.router,
|
||||||
|
direct_router: components.direct_router,
|
||||||
|
relay_routers: components.relay_routers,
|
||||||
|
sessions,
|
||||||
|
auth,
|
||||||
|
centralized: components.centralized,
|
||||||
users: Arc::new(UserService),
|
users: Arc::new(UserService),
|
||||||
config: Arc::new(ConfigService),
|
config: Arc::new(ConfigService),
|
||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Services used while the daemon is awaiting terms acceptance. They can
|
|
||||||
/// never initiate a connection; the command router exposes status only.
|
|
||||||
pub fn inactive() -> Arc<Self> {
|
pub fn inactive() -> Arc<Self> {
|
||||||
|
let identities: Arc<dyn IdentityResolver> = Arc::new(InactiveIdentityResolver);
|
||||||
|
let sessions = Arc::new(SessionManager::default());
|
||||||
|
let auth = Arc::new(AuthService {
|
||||||
|
hosted_accounts: Arc::new(HostedSessionRegistrar::new(sessions.clone())),
|
||||||
|
foreign_principals: Arc::new(ForeignPrincipalAuthenticator::new(
|
||||||
|
identities.clone(),
|
||||||
|
sessions.clone(),
|
||||||
|
)),
|
||||||
|
iota_peers: Arc::new(IotaPeerAuthenticator::new(Arc::new(
|
||||||
|
InactiveNodeIdentityResolver,
|
||||||
|
))),
|
||||||
|
});
|
||||||
|
let inactive = Arc::new(InactiveServices);
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
omikron: Arc::new(InactiveOmikron),
|
accounts: inactive.clone(),
|
||||||
|
identities: identities.clone(),
|
||||||
|
principals: inactive.clone(),
|
||||||
|
local_users: inactive.clone(),
|
||||||
|
relay: Arc::new(RelayService::new(inactive.clone(), inactive.clone(), None)),
|
||||||
|
router: inactive,
|
||||||
|
direct_router: None,
|
||||||
|
relay_routers: None,
|
||||||
|
sessions,
|
||||||
|
auth,
|
||||||
|
centralized: None,
|
||||||
users: Arc::new(UserService),
|
users: Arc::new(UserService),
|
||||||
config: Arc::new(ConfigService),
|
config: Arc::new(ConfigService),
|
||||||
active: false,
|
active: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn omikron(&self) -> Option<&Arc<dyn OmikronClient>> {
|
||||||
|
self.centralized.as_ref().map(|services| &services.omikron)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InactiveOmikron;
|
struct InactiveIdentityResolver;
|
||||||
|
|
||||||
|
struct InactiveNodeIdentityResolver;
|
||||||
|
|
||||||
|
struct StandaloneRelayIdentity(iota_identity::LocalNodeIdentity);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OmikronClient for InactiveOmikron {
|
impl RelayNodeIdentity for StandaloneRelayIdentity {
|
||||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
async fn keyring(&self) -> Option<Arc<mtp::crypto::Keyring>> {
|
||||||
Err(OmikronError::Disconnected(
|
Some(self.0.keyring())
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
async fn await_response(
|
|
||||||
|
fn node_id(&self) -> Option<IotaNodeId> {
|
||||||
|
Some(self.0.node_id().clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NodeIdentityResolver for InactiveNodeIdentityResolver {
|
||||||
|
async fn resolve_node(&self, _: &IotaNodeId) -> Result<ResolvedNodeIdentity, IdentityError> {
|
||||||
|
Err(IdentityError::Unavailable(
|
||||||
|
"node identity resolution is unavailable".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IdentityResolver for InactiveIdentityResolver {
|
||||||
|
async fn resolve_address(
|
||||||
&self,
|
&self,
|
||||||
_: &CommunicationValue,
|
_: &UserAddress,
|
||||||
_: Duration,
|
_: &ResolutionContext,
|
||||||
) -> Result<CommunicationValue, OmikronError> {
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
Err(OmikronError::Disconnected(
|
Err(IdentityError::Unavailable("daemon is inactive".into()))
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
async fn resolve_principal(&self, _: &PrincipalId) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
"terms have not been accepted".into(),
|
Err(IdentityError::Unavailable("daemon is inactive".into()))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
async fn rotate_identity(&self) -> Result<(), OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
async fn signing_keys(
|
||||||
"terms have not been accepted".into(),
|
&self,
|
||||||
))
|
_: &PrincipalId,
|
||||||
|
_: &ResolutionContext,
|
||||||
|
) -> Result<Vec<iota_identity::PublicKeyBundle>, IdentityError> {
|
||||||
|
Err(IdentityError::Unavailable("daemon is inactive".into()))
|
||||||
}
|
}
|
||||||
async fn is_connected(&self) -> bool {
|
}
|
||||||
|
|
||||||
|
struct InactiveServices;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AccountAuthority for InactiveServices {
|
||||||
|
async fn is_available(&self) -> bool {
|
||||||
false
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(&self, _: CreateUserRequest) -> Result<LocalAccount, AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect_credential(&self, _: &[u8]) -> Result<CredentialPreview, AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_user(&self, _: &[u8]) -> Result<LocalAccount, AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_user(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::LocalUserId,
|
||||||
|
) -> Result<ReconcileResult, AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_user(&self, _: iota_identity::LocalUserId) -> Result<(), AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::LocalUserId,
|
||||||
|
_: &[u8],
|
||||||
|
) -> Result<(), AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconcile_managed_users(&self) -> Result<(), AccountError> {
|
||||||
|
Err(inactive_account())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inactive_account() -> AccountError {
|
||||||
|
AccountError::Unavailable("daemon is inactive".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalUserStore for InactiveServices {
|
||||||
|
fn get_local_user(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::LocalUserId,
|
||||||
|
) -> Result<Option<iota_identity::LocalUserDescriptor>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_local_user_by_username(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<Option<iota_identity::LocalUserDescriptor>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hosted_here(&self, _: iota_identity::LocalUserId) -> Result<bool, IdentityError> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_user_for_principal(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<Option<iota_identity::LocalUserId>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal_for_local_user(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::LocalUserId,
|
||||||
|
) -> Result<Option<iota_identity::PrincipalHandle>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrincipalStore for InactiveServices {
|
||||||
|
fn get_principal(
|
||||||
|
&self,
|
||||||
|
_: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_by_canonical_id(
|
||||||
|
&self,
|
||||||
|
_: &PrincipalId,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_by_username(
|
||||||
|
&self,
|
||||||
|
_: &AuthorityId,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_remote_descriptor(
|
||||||
|
&self,
|
||||||
|
_: &iota_identity::VerifiedPrincipalDescriptor,
|
||||||
|
) -> Result<iota_identity::PrincipalHandle, IdentityError> {
|
||||||
|
Err(IdentityError::Unavailable("daemon is inactive".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_keys(
|
||||||
|
&self,
|
||||||
|
_: &PrincipalId,
|
||||||
|
) -> Result<Vec<iota_identity::PublicKeyBundle>, IdentityError> {
|
||||||
|
Err(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PeerRouter for InactiveServices {
|
||||||
|
async fn route(
|
||||||
|
&self,
|
||||||
|
destination: &RouteDestination,
|
||||||
|
_: CommunicationValue,
|
||||||
|
) -> Result<RouteOutcome, RouteError> {
|
||||||
|
match destination {
|
||||||
|
RouteDestination::Iota(node) => Err(RouteError::NoRoute(node.clone())),
|
||||||
|
RouteDestination::LegacyOmegaIota { .. } => Ok(RouteOutcome::Retryable {
|
||||||
|
reason: "daemon is inactive".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RelayNodeIdentity for InactiveServices {
|
||||||
|
async fn keyring(&self) -> Option<Arc<mtp::crypto::Keyring>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_id(&self) -> Option<IotaNodeId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegacyRelayIdentity for InactiveServices {
|
||||||
|
fn legacy_iota_id(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_services_can_be_composed_without_omikron() {
|
||||||
|
let adapters = Arc::new(InactiveServices);
|
||||||
|
let identities: Arc<dyn IdentityResolver> = Arc::new(InactiveIdentityResolver);
|
||||||
|
let services = DaemonServices::compose(DaemonServiceComponents {
|
||||||
|
accounts: adapters.clone(),
|
||||||
|
identities,
|
||||||
|
principals: adapters.clone(),
|
||||||
|
local_users: adapters.clone(),
|
||||||
|
node_identities: Arc::new(InactiveNodeIdentityResolver),
|
||||||
|
relay: Arc::new(RelayService::new(adapters.clone(), adapters.clone(), None)),
|
||||||
|
router: adapters,
|
||||||
|
direct_router: None,
|
||||||
|
relay_routers: None,
|
||||||
|
centralized: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(services.active);
|
||||||
|
assert!(services.centralized.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,7 @@ async fn reconnect_uses_the_injected_client() {
|
||||||
let fake = Arc::new(FakeOmikron {
|
let fake = Arc::new(FakeOmikron {
|
||||||
reconnects: AtomicUsize::new(0),
|
reconnects: AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
let services = Arc::new(DaemonServices {
|
let services = DaemonServices::with_centralized_client(fake.clone());
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
let router = CommandRouter::new(
|
||||||
Arc::new(DaemonRuntime::new()),
|
Arc::new(DaemonRuntime::new()),
|
||||||
services,
|
services,
|
||||||
|
|
@ -85,12 +80,7 @@ async fn identity_rotation_is_available_while_omikron_is_offline() {
|
||||||
let fake = Arc::new(FakeOmikron {
|
let fake = Arc::new(FakeOmikron {
|
||||||
reconnects: AtomicUsize::new(0),
|
reconnects: AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
let services = Arc::new(DaemonServices {
|
let services = DaemonServices::with_centralized_client(fake.clone());
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
let router = CommandRouter::new(
|
||||||
Arc::new(DaemonRuntime::new()),
|
Arc::new(DaemonRuntime::new()),
|
||||||
services,
|
services,
|
||||||
|
|
@ -111,12 +101,7 @@ async fn read_role_cannot_execute_an_administrative_request() {
|
||||||
let fake = Arc::new(FakeOmikron {
|
let fake = Arc::new(FakeOmikron {
|
||||||
reconnects: AtomicUsize::new(0),
|
reconnects: AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
let services = Arc::new(DaemonServices {
|
let services = DaemonServices::with_centralized_client(fake.clone());
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
let router = CommandRouter::new(
|
||||||
Arc::new(DaemonRuntime::new()),
|
Arc::new(DaemonRuntime::new()),
|
||||||
services,
|
services,
|
||||||
|
|
|
||||||
|
|
@ -182,12 +182,7 @@ impl OmikronClient for TestOmikron {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_services() -> Arc<DaemonServices> {
|
fn active_services() -> Arc<DaemonServices> {
|
||||||
Arc::new(DaemonServices {
|
DaemonServices::with_centralized_client(Arc::new(TestOmikron))
|
||||||
omikron: Arc::new(TestOmikron),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
57
iota-daemon-lib/tests/local_authority.rs
Normal file
57
iota-daemon-lib/tests/local_authority.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use iota_daemon_lib::accounts::{AccountAuthority, CreateUserRequest, LocalIotaAccountAuthority};
|
||||||
|
use iota_identity::{LocalNodeIdentity, LocalUserId, PrincipalStore};
|
||||||
|
use iota_storage::identity::SqlitePrincipalStore;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn standalone_accounts_survive_identity_reload_and_do_not_reuse_deleted_ids() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let storage = root.path().join("storage");
|
||||||
|
iota_util::file_util::configure_storage_directory(storage);
|
||||||
|
iota_storage::util::db::initialize_database().unwrap();
|
||||||
|
let identity_path = root.path().join("identity/iota.mk");
|
||||||
|
let identity = LocalNodeIdentity::load_or_create(&identity_path, None).unwrap();
|
||||||
|
let authority = LocalIotaAccountAuthority::new(identity.clone());
|
||||||
|
|
||||||
|
let alice = authority
|
||||||
|
.create_user(CreateUserRequest {
|
||||||
|
username: "alice".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let alice_principal = SqlitePrincipalStore
|
||||||
|
.principal_for_local_user(alice.user)
|
||||||
|
.unwrap()
|
||||||
|
.and_then(|handle| SqlitePrincipalStore.get_principal(handle).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let credential = iota_util::file_util::read_user_credential("alice")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let restarted_identity = LocalNodeIdentity::load_or_create(&identity_path, None).unwrap();
|
||||||
|
assert_eq!(restarted_identity.node_id(), identity.node_id());
|
||||||
|
let restarted = LocalIotaAccountAuthority::new(restarted_identity);
|
||||||
|
let restored_principal = SqlitePrincipalStore
|
||||||
|
.principal_for_local_user(LocalUserId(alice.user.0))
|
||||||
|
.unwrap()
|
||||||
|
.and_then(|handle| SqlitePrincipalStore.get_principal(handle).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(restored_principal.principal, alice_principal.principal);
|
||||||
|
|
||||||
|
restarted
|
||||||
|
.delete_user(alice.user, credential.as_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let bob = restarted
|
||||||
|
.create_user(CreateUserRequest {
|
||||||
|
username: "bob".into(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(bob.user.0 > alice.user.0);
|
||||||
|
assert!(
|
||||||
|
SqlitePrincipalStore
|
||||||
|
.get_by_canonical_id(&alice_principal.principal)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,17 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
client = { path = "../client" }
|
||||||
iota-daemon-lib = { path = "../iota-daemon-lib" }
|
iota-daemon-lib = { path = "../iota-daemon-lib" }
|
||||||
|
iota-connection = { path = "../iota-connection" }
|
||||||
iota-ipc = { path = "../iota-ipc" }
|
iota-ipc = { path = "../iota-ipc" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
iota-paths = { path = "../iota-paths" }
|
iota-paths = { path = "../iota-paths" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
iota-terms = { path = "../iota-terms" }
|
iota-terms = { path = "../iota-terms" }
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
omikron-connector = { path = "../omikron-connector" }
|
||||||
|
other-iota = { path = "../other-iota" }
|
||||||
web-server = { path = "../web-server" }
|
web-server = { path = "../web-server" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ async fn main() -> ExitCode {
|
||||||
eprintln!("Cannot load Iota configuration: {error}");
|
eprintln!("Cannot load Iota configuration: {error}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
let node_identity =
|
||||||
|
match iota_identity::LocalNodeIdentity::load_or_create(&paths.keyring_file(), None) {
|
||||||
|
Ok(identity) => identity,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load Iota node identity: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file());
|
omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file());
|
||||||
match paths.scope {
|
match paths.scope {
|
||||||
iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())),
|
iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())),
|
||||||
|
|
@ -136,38 +144,75 @@ async fn main() -> ExitCode {
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let omikron = match omikron_connector::omikron_connection::connect_initial(
|
let (services, omikron) = if CONFIG.load().omikron_host.is_some() {
|
||||||
|
let omikron = Arc::new(omikron_connector::OmikronConnection::with_cancellation(
|
||||||
runtime.cancellation.clone(),
|
runtime.cancellation.clone(),
|
||||||
runtime.state.active_tasks.clone(),
|
runtime.state.active_tasks.clone(),
|
||||||
runtime.state.app.clone(),
|
runtime.state.app.clone(),
|
||||||
)
|
));
|
||||||
|
let services = match DaemonServices::new(omikron.clone()) {
|
||||||
|
Ok(services) => services,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot initialize daemon identity services: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
omikron.connect().await;
|
||||||
|
if omikron
|
||||||
|
.await_connection(Some(Duration::from_secs(45)))
|
||||||
.await
|
.await
|
||||||
|
.is_err()
|
||||||
{
|
{
|
||||||
Ok(connection) => connection,
|
if omikron.has_auth_failure().await {
|
||||||
Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => {
|
runtime.set_component_failed(
|
||||||
|
iota_ipc::ComponentId::Omikron,
|
||||||
|
"Omikron authentication failed; inspect authenticated relay and Omega status before rotating Iota identity".into(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
runtime.set_component_degraded(
|
runtime.set_component_degraded(
|
||||||
iota_ipc::ComponentId::Omikron,
|
iota_ipc::ComponentId::Omikron,
|
||||||
"Omikron connection unavailable; retrying".into(),
|
"Omikron connection unavailable; retrying".into(),
|
||||||
);
|
);
|
||||||
connection
|
|
||||||
}
|
}
|
||||||
Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
|
|
||||||
runtime.set_component_failed(
|
|
||||||
iota_ipc::ComponentId::Omikron,
|
|
||||||
"Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(),
|
|
||||||
);
|
|
||||||
// Keep IPC alive: identity rotation is the supported recovery
|
|
||||||
// action and must remain available after authentication fails.
|
|
||||||
connection
|
|
||||||
}
|
}
|
||||||
Err(omikron_connector::OmikronStartupError::Construction(error)) => {
|
(services, Some(omikron))
|
||||||
eprintln!("Cannot construct Omikron connection: {error}");
|
} else {
|
||||||
|
let services = match DaemonServices::standalone(node_identity.clone()) {
|
||||||
|
Ok(services) => services,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot initialize standalone daemon services: {error}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let omikron_health = omikron.clone();
|
runtime.set_component_healthy(
|
||||||
let omikron_reconcile = omikron.clone();
|
iota_ipc::ComponentId::Omikron,
|
||||||
let services = DaemonServices::new(omikron);
|
Some("disabled in standalone mode".into()),
|
||||||
|
);
|
||||||
|
(services, None)
|
||||||
|
};
|
||||||
|
let lifecycle_services = services.clone();
|
||||||
|
let lifecycle_runtime = runtime.clone();
|
||||||
|
if omikron.is_none() {
|
||||||
|
let dispatcher =
|
||||||
|
iota_connection::relay_service::PendingRelayDispatcher::new(services.router.clone());
|
||||||
|
let dispatcher_runtime = runtime.clone();
|
||||||
|
runtime
|
||||||
|
.tasks
|
||||||
|
.spawn_tracked("pending-relay-dispatch", async move {
|
||||||
|
loop {
|
||||||
|
if let Err(error) = dispatcher.dispatch_ready(100).await {
|
||||||
|
log!("Pending Relay dispatch failed: {error:?}");
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(2)) => {},
|
||||||
|
_ = dispatcher_runtime.cancellation.cancelled() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(omikron_health) = omikron {
|
||||||
let health_runtime = runtime.clone();
|
let health_runtime = runtime.clone();
|
||||||
runtime
|
runtime
|
||||||
.tasks
|
.tasks
|
||||||
|
|
@ -185,8 +230,10 @@ async fn main() -> ExitCode {
|
||||||
} else {
|
} else {
|
||||||
"connected (waiting for RTT sample)".into()
|
"connected (waiting for RTT sample)".into()
|
||||||
};
|
};
|
||||||
health_runtime
|
health_runtime.set_component_healthy(
|
||||||
.set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message));
|
iota_ipc::ComponentId::Omikron,
|
||||||
|
Some(message),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
omikron_connector::omikron_connection::ConnectionState::Connecting => {
|
omikron_connector::omikron_connection::ConnectionState::Connecting => {
|
||||||
health_runtime.set_component_degraded(
|
health_runtime.set_component_degraded(
|
||||||
|
|
@ -203,8 +250,10 @@ async fn main() -> ExitCode {
|
||||||
health_runtime
|
health_runtime
|
||||||
.set_component_failed(iota_ipc::ComponentId::Omikron, message);
|
.set_component_failed(iota_ipc::ComponentId::Omikron, message);
|
||||||
} else {
|
} else {
|
||||||
health_runtime
|
health_runtime.set_component_degraded(
|
||||||
.set_component_degraded(iota_ipc::ComponentId::Omikron, message);
|
iota_ipc::ComponentId::Omikron,
|
||||||
|
message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,10 +269,10 @@ async fn main() -> ExitCode {
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
runtime
|
runtime
|
||||||
.tasks
|
.tasks
|
||||||
.spawn_tracked("user-lifecycle-reconciliation", async move {
|
.spawn_tracked("user-lifecycle-reconciliation", async move {
|
||||||
let mut states = omikron_reconcile.connection_state();
|
|
||||||
loop {
|
loop {
|
||||||
match iota_storage::users::pending_operations::get_all() {
|
match iota_storage::users::pending_operations::get_all() {
|
||||||
Ok(operations) => {
|
Ok(operations) => {
|
||||||
|
|
@ -242,18 +291,14 @@ async fn main() -> ExitCode {
|
||||||
}
|
}
|
||||||
Err(error) => log!("Pending purge reconciliation could not read storage: {error}"),
|
Err(error) => log!("Pending purge reconciliation could not read storage: {error}"),
|
||||||
}
|
}
|
||||||
if matches!(
|
if lifecycle_services.accounts.is_available().await
|
||||||
*states.borrow(),
|
&& let Err(error) = lifecycle_services.accounts.reconcile_managed_users().await
|
||||||
omikron_connector::omikron_connection::ConnectionState::Connected { .. }
|
{
|
||||||
) {
|
log!("Managed user reconciliation failed: {error:?}");
|
||||||
omikron_connector::user_ops::reconcile_managed_users(
|
|
||||||
omikron_reconcile.as_ref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
changed = states.changed() => if changed.is_err() { break },
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
||||||
|
_ = lifecycle_runtime.cancellation.cancelled() => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -262,7 +307,7 @@ async fn main() -> ExitCode {
|
||||||
let ipc_server = match IpcServer::bind(
|
let ipc_server = match IpcServer::bind(
|
||||||
socket.clone(),
|
socket.clone(),
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
services,
|
services.clone(),
|
||||||
log_tx.clone(),
|
log_tx.clone(),
|
||||||
log_buffer.clone(),
|
log_buffer.clone(),
|
||||||
state_rx,
|
state_rx,
|
||||||
|
|
@ -330,7 +375,186 @@ async fn main() -> ExitCode {
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// --- Web server ---
|
// --- Web server ---
|
||||||
let web = CONFIG.load().web.clone();
|
let config = CONFIG.load();
|
||||||
|
let web = config.web.clone();
|
||||||
|
let relay_router_settings = config.relay_routers.clone();
|
||||||
|
drop(config);
|
||||||
|
let direct_endpoints = match web
|
||||||
|
.direct_endpoints
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(iota_identity::AuthorityLocator::new)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
{
|
||||||
|
Ok(endpoints) => endpoints,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load federation direct endpoints: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let relay_hints = match web
|
||||||
|
.relay_hints
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(
|
||||||
|
relay_router_settings
|
||||||
|
.iter()
|
||||||
|
.map(|router| router.endpoint.clone()),
|
||||||
|
)
|
||||||
|
.map(iota_identity::AuthorityLocator::new)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
{
|
||||||
|
Ok(endpoints) => endpoints,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load federation relay hints: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut relay_hints = relay_hints;
|
||||||
|
relay_hints.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
relay_hints.dedup();
|
||||||
|
let node_descriptor = match iota_storage::node_directory::SqliteNodeDirectory
|
||||||
|
.ensure_local_descriptor(
|
||||||
|
&node_identity,
|
||||||
|
direct_endpoints,
|
||||||
|
relay_hints,
|
||||||
|
iota_storage::util::sync::now_millis(),
|
||||||
|
) {
|
||||||
|
Ok(descriptor) => descriptor,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot publish Iota node descriptor: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if services.centralized.is_some()
|
||||||
|
&& let Some(iota_id) = CONFIG.load().iota_id
|
||||||
|
{
|
||||||
|
let omega = match iota_identity::AuthorityLocator::new(
|
||||||
|
omikron_connector::omega_discovery::omega_host(),
|
||||||
|
) {
|
||||||
|
Ok(omega) => omega,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load Omega federation endpoint: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wire = match node_descriptor.to_wire_v1() {
|
||||||
|
Ok(wire) => wire,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot encode node descriptor for Omega: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(error) = other_iota::publish_omega_node_descriptor(&omega, iota_id, &wire).await
|
||||||
|
{
|
||||||
|
log!("Omega node descriptor publication failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let client_connections = Arc::new(client::ClientConnectionManager::new(
|
||||||
|
services.local_users.clone(),
|
||||||
|
services.principals.clone(),
|
||||||
|
services.auth.hosted_accounts.clone(),
|
||||||
|
services.sessions.clone(),
|
||||||
|
services.relay.clone(),
|
||||||
|
));
|
||||||
|
if let Some(relay_routers) = &services.relay_routers {
|
||||||
|
for router in &relay_router_settings {
|
||||||
|
let endpoint = match iota_identity::AuthorityLocator::new(router.endpoint.clone()) {
|
||||||
|
Ok(endpoint) => endpoint,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load relay router endpoint: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let public_key = match iota_identity::PublicKeyBundle::from_base64(&router.public_key) {
|
||||||
|
Ok(public_key) => public_key,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot load relay router public key: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let certificate_path =
|
||||||
|
resolve_config_path(&paths.config_file, &router.certificate, &paths.config_dir);
|
||||||
|
let certificate = match std::fs::read(&certificate_path) {
|
||||||
|
Ok(certificate) if !certificate.is_empty() => certificate,
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"Cannot load relay router certificate {}: file is empty",
|
||||||
|
certificate_path.display()
|
||||||
|
);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!(
|
||||||
|
"Cannot load relay router certificate {}: {error}",
|
||||||
|
certificate_path.display()
|
||||||
|
);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let router_runtime = runtime.clone();
|
||||||
|
let relay_routers = relay_routers.clone();
|
||||||
|
let relay = services.relay.clone();
|
||||||
|
let identity = node_identity.clone();
|
||||||
|
let deliveries: Arc<dyn other_iota::LocalDeliverySink> = client_connections.clone();
|
||||||
|
runtime
|
||||||
|
.tasks
|
||||||
|
.spawn_tracked("relay-router", async move {
|
||||||
|
loop {
|
||||||
|
match other_iota::relay_router::RelayRouterClient::connect(
|
||||||
|
identity.clone(),
|
||||||
|
&endpoint,
|
||||||
|
certificate.clone(),
|
||||||
|
public_key.clone(),
|
||||||
|
relay.clone(),
|
||||||
|
deliveries.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(connected) => {
|
||||||
|
relay_routers.add(connected.clone());
|
||||||
|
tokio::select! {
|
||||||
|
_ = connected.wait_disconnected() => {},
|
||||||
|
_ = router_runtime.cancellation.cancelled() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log!(
|
||||||
|
"Relay router {} connection failed: {error}",
|
||||||
|
endpoint.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(5)) => {},
|
||||||
|
_ = router_runtime.cancellation.cancelled() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mtp_handler: Arc<dyn web_server::MtpConnectionHandler> =
|
||||||
|
if let Some(router) = services.direct_router.clone() {
|
||||||
|
let nodes: Arc<dyn iota_identity::NodeDirectory> =
|
||||||
|
Arc::new(iota_storage::node_directory::SqliteNodeDirectory);
|
||||||
|
let deliveries: Arc<dyn other_iota::LocalDeliverySink> = client_connections.clone();
|
||||||
|
let peers = Arc::new(other_iota::PeerManager::new(
|
||||||
|
node_identity.clone(),
|
||||||
|
nodes,
|
||||||
|
router.clone(),
|
||||||
|
services.relay.clone(),
|
||||||
|
deliveries,
|
||||||
|
));
|
||||||
|
router.attach_manager(&peers);
|
||||||
|
Arc::new(client::ConnectionGateway::new(
|
||||||
|
client_connections.clone(),
|
||||||
|
peers,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
client_connections.clone()
|
||||||
|
};
|
||||||
let web_config = web_server::WebConfig {
|
let web_config = web_server::WebConfig {
|
||||||
mode: match web.mode {
|
mode: match web.mode {
|
||||||
iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled,
|
iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled,
|
||||||
|
|
@ -352,6 +576,33 @@ async fn main() -> ExitCode {
|
||||||
key: resolve_config_path(&paths.config_file, &key, &paths.config_dir),
|
key: resolve_config_path(&paths.config_file, &key, &paths.config_dir),
|
||||||
}),
|
}),
|
||||||
required: web.required,
|
required: web.required,
|
||||||
|
authority_discovery: iota_identity::AuthorityDiscoveryDocument {
|
||||||
|
version: 1,
|
||||||
|
service: iota_identity::AuthorityKind::Iota,
|
||||||
|
authority_id: node_identity.authority_id().clone(),
|
||||||
|
node_id: Some(node_identity.node_id().clone()),
|
||||||
|
public_key: match node_identity.public_keys().try_to_base64() {
|
||||||
|
Ok(public_key) => public_key,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot encode Iota discovery identity: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
protocols: vec!["identity-http-v1".into(), "mtp-relay-v2".into()],
|
||||||
|
node_descriptor: Some("/federation/v1/node".into()),
|
||||||
|
direct_endpoints: Vec::new(),
|
||||||
|
relay_hints: Vec::new(),
|
||||||
|
},
|
||||||
|
local_users: services.local_users.clone(),
|
||||||
|
descriptor_publisher: Arc::new(
|
||||||
|
iota_storage::identity::SqliteLocalDescriptorPublisher::new(node_identity.clone()),
|
||||||
|
),
|
||||||
|
node_descriptor,
|
||||||
|
node_identity: node_identity.clone(),
|
||||||
|
client_keys: Arc::new(client::HostedAndPeerKeyResolver::new(
|
||||||
|
services.local_users.clone(),
|
||||||
|
)),
|
||||||
|
mtp_handler,
|
||||||
};
|
};
|
||||||
match web_server::start(web_config, runtime.cancellation.clone()).await {
|
match web_server::start(web_config, runtime.cancellation.clone()).await {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
|
|
||||||
13
iota-identity/Cargo.toml
Normal file
13
iota-identity/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "iota-identity"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto", "files", "raw"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
1812
iota-identity/src/lib.rs
Normal file
1812
iota-identity/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ iota-paths = { path = "../iota-paths" }
|
||||||
iota-state = { path = "../iota-state" }
|
iota-state = { path = "../iota-state" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
|
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c" }
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318" }
|
||||||
|
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
json = "0.12.4"
|
json = "0.12.4"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
iota-identity = { path = "../iota-identity" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
iota-paths = { path = "../iota-paths" }
|
iota-paths = { path = "../iota-paths" }
|
||||||
|
|
@ -13,8 +15,13 @@ arc-swap = "1"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
r2d2 = "0.8"
|
r2d2 = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
rusqlite = "0.40.0"
|
rusqlite = "0.40.0"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] }
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
877
iota-storage/src/identity.rs
Normal file
877
iota-storage/src/identity.rs
Normal file
|
|
@ -0,0 +1,877 @@
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityId, AuthorityKind, IdentityError, LocalDescriptorPublisher, LocalUserDescriptor,
|
||||||
|
LocalUserId, LocalUserStore, PrincipalDescriptor, PrincipalHandle, PrincipalHome, PrincipalId,
|
||||||
|
PrincipalStore, PublicKeyBundle, ResolvedPrincipal, SignedPrincipalDescriptor,
|
||||||
|
};
|
||||||
|
use iota_util::crypto_helper::{public_key_bundle_from_base64, public_key_bundle_to_base64};
|
||||||
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::users::user_manager;
|
||||||
|
use crate::util::db;
|
||||||
|
|
||||||
|
const LOCAL_DESCRIPTOR_LIFETIME_MILLIS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||||
|
const LOCAL_DESCRIPTOR_RENEWAL_MILLIS: i64 = 7 * 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SqliteLocalUserStore;
|
||||||
|
|
||||||
|
impl LocalUserStore for SqliteLocalUserStore {
|
||||||
|
fn get_local_user(
|
||||||
|
&self,
|
||||||
|
id: LocalUserId,
|
||||||
|
) -> Result<Option<LocalUserDescriptor>, IdentityError> {
|
||||||
|
user_manager::get_user(id.0)
|
||||||
|
.map_err(storage_error)
|
||||||
|
.map(|user| user.map(local_descriptor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_local_user_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<LocalUserDescriptor>, IdentityError> {
|
||||||
|
user_manager::get_user_by_username(username)
|
||||||
|
.map_err(storage_error)
|
||||||
|
.map(|user| user.map(local_descriptor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hosted_here(&self, id: LocalUserId) -> Result<bool, IdentityError> {
|
||||||
|
self.get_local_user(id).map(|user| user.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_user_for_principal(
|
||||||
|
&self,
|
||||||
|
principal: PrincipalHandle,
|
||||||
|
) -> Result<Option<LocalUserId>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT local_user_id FROM hosted_principals WHERE principal_handle = ?1",
|
||||||
|
[principal.0],
|
||||||
|
|row| row.get::<_, i64>(0).map(LocalUserId),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal_for_local_user(
|
||||||
|
&self,
|
||||||
|
user: LocalUserId,
|
||||||
|
) -> Result<Option<PrincipalHandle>, IdentityError> {
|
||||||
|
SqlitePrincipalStore.principal_for_local_user(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_descriptor(user: crate::users::user_profile::UserProfile) -> LocalUserDescriptor {
|
||||||
|
LocalUserDescriptor {
|
||||||
|
id: LocalUserId(user.user_id),
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
public_key: user.public_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SqlitePrincipalStore;
|
||||||
|
|
||||||
|
impl SqlitePrincipalStore {
|
||||||
|
pub fn principal_for_local_user(
|
||||||
|
&self,
|
||||||
|
user: LocalUserId,
|
||||||
|
) -> Result<Option<PrincipalHandle>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1",
|
||||||
|
[user.0],
|
||||||
|
|row| row.get::<_, i64>(0).map(PrincipalHandle),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate_legacy_omega_authority(
|
||||||
|
&self,
|
||||||
|
authority: &AuthorityId,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
db::with_immediate_transaction(|transaction| {
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO identity_configuration (singleton, omega_authority_id) VALUES (1, ?1) ON CONFLICT(singleton) DO UPDATE SET omega_authority_id = excluded.omega_authority_id",
|
||||||
|
[authority.as_str()],
|
||||||
|
)?;
|
||||||
|
let mut statement = transaction.prepare(
|
||||||
|
"SELECT principal_pk, remote_user_id FROM principals WHERE authority_id = 'omega:central'",
|
||||||
|
)?;
|
||||||
|
let legacy = statement
|
||||||
|
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
drop(statement);
|
||||||
|
for (legacy_handle, user_id) in legacy {
|
||||||
|
let current_handle = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2",
|
||||||
|
params![authority.as_str(), user_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(current_handle) = current_handle {
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT OR IGNORE INTO principal_keys (principal_pk, public_key, valid_from, valid_until, source_revision) SELECT ?1, public_key, valid_from, valid_until, source_revision FROM principal_keys WHERE principal_pk = ?2",
|
||||||
|
params![current_handle, legacy_handle],
|
||||||
|
)?;
|
||||||
|
for (table, column) in [
|
||||||
|
("contacts", "principal_handle"),
|
||||||
|
("messages", "external_principal"),
|
||||||
|
("relay_replay", "signer_principal"),
|
||||||
|
("relay_inbox", "signer_principal"),
|
||||||
|
("pending_relays", "destination_principal"),
|
||||||
|
("blocked_users", "blocked_principal"),
|
||||||
|
] {
|
||||||
|
transaction.execute(
|
||||||
|
&format!("UPDATE {table} SET {column} = ?1 WHERE {column} = ?2"),
|
||||||
|
params![current_handle, legacy_handle],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
"DELETE FROM principals WHERE principal_pk = ?1",
|
||||||
|
[legacy_handle],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE principals SET authority_id = ?1 WHERE principal_pk = ?2",
|
||||||
|
params![authority.as_str(), legacy_handle],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transaction.execute_batch(
|
||||||
|
"CREATE TEMP TABLE IF NOT EXISTS observed_omega_users (user_id INTEGER PRIMARY KEY); DELETE FROM observed_omega_users; INSERT OR IGNORE INTO observed_omega_users SELECT user_id FROM users WHERE user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT user_id FROM contacts WHERE user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT external_user FROM messages WHERE external_user > 0; INSERT OR IGNORE INTO observed_omega_users SELECT signer_id FROM relay_replay WHERE signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT signer_id FROM relay_inbox WHERE signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT destination_id FROM relay_inbox WHERE destination_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT relay_signer_id FROM pending_relays WHERE relay_signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT relay_destination_user_id FROM pending_relays WHERE relay_destination_user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT blocked_user_id FROM blocked_users WHERE blocked_user_id > 0;"
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', ?1, user_id, 0, 0 FROM observed_omega_users",
|
||||||
|
[authority.as_str()],
|
||||||
|
)?;
|
||||||
|
for (table, principal_column, user_column) in [
|
||||||
|
("contacts", "principal_handle", "user_id"),
|
||||||
|
("messages", "external_principal", "external_user"),
|
||||||
|
("relay_replay", "signer_principal", "signer_id"),
|
||||||
|
("relay_inbox", "signer_principal", "signer_id"),
|
||||||
|
("pending_relays", "destination_principal", "relay_destination_user_id"),
|
||||||
|
("blocked_users", "blocked_principal", "blocked_user_id"),
|
||||||
|
] {
|
||||||
|
transaction.execute(
|
||||||
|
&format!("UPDATE {table} SET {principal_column} = (SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = {table}.{user_column}) WHERE {principal_column} IS NULL AND {user_column} IS NOT NULL"),
|
||||||
|
[authority.as_str()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_local_principal(
|
||||||
|
&self,
|
||||||
|
authority: &AuthorityId,
|
||||||
|
authority_kind: AuthorityKind,
|
||||||
|
user: &LocalUserDescriptor,
|
||||||
|
home: PrincipalHome,
|
||||||
|
resolved_at: i64,
|
||||||
|
) -> Result<PrincipalHandle, IdentityError> {
|
||||||
|
let user_id = u64::try_from(user.id.0)
|
||||||
|
.map_err(|_| IdentityError::InvalidDescriptor("negative local user ID".into()))?;
|
||||||
|
let key = public_key_bundle_from_base64(&user.public_key)
|
||||||
|
.ok_or_else(|| IdentityError::InvalidDescriptor("stored user key is invalid".into()))?;
|
||||||
|
let encoded_home = encode_home(&home);
|
||||||
|
let current = db::with_db(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
r#"SELECT p.authority_kind, p.username, p.display_name, p.home,
|
||||||
|
p.descriptor_revision, p.descriptor_valid_until,
|
||||||
|
COALESCE(p.descriptor_issued_at, p.last_resolved_at), p.retired_at,
|
||||||
|
(SELECT public_key FROM principal_keys
|
||||||
|
WHERE principal_pk = p.principal_pk
|
||||||
|
AND (valid_until IS NULL OR valid_until > ?3)
|
||||||
|
ORDER BY source_revision DESC LIMIT 1)
|
||||||
|
FROM principals p
|
||||||
|
WHERE p.authority_id = ?1 AND p.remote_user_id = ?2"#,
|
||||||
|
params![authority.as_str(), user.id.0, resolved_at],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, Option<String>>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, i64>(4)?,
|
||||||
|
row.get::<_, Option<i64>>(5)?,
|
||||||
|
row.get::<_, i64>(6)?,
|
||||||
|
row.get::<_, Option<i64>>(7)?,
|
||||||
|
row.get::<_, Option<String>>(8)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?;
|
||||||
|
if current.as_ref().is_some_and(|current| current.7.is_some()) {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"retired local principal cannot be published".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let signed_fields_match = current.as_ref().is_some_and(|current| {
|
||||||
|
current.0 == authority_kind.as_str()
|
||||||
|
&& current.1.as_deref() == Some(user.username.as_str())
|
||||||
|
&& current.2.as_ref() == user.display_name.as_ref()
|
||||||
|
&& current.3.as_ref() == encoded_home.as_ref()
|
||||||
|
&& current.8.as_deref() == Some(user.public_key.as_str())
|
||||||
|
});
|
||||||
|
let current_valid_until = current.as_ref().and_then(|current| current.5);
|
||||||
|
let renew_validity = current_valid_until
|
||||||
|
.is_none_or(|valid_until| valid_until <= resolved_at + LOCAL_DESCRIPTOR_RENEWAL_MILLIS);
|
||||||
|
let revision = current.as_ref().map_or(0, |current| {
|
||||||
|
if signed_fields_match && !renew_validity {
|
||||||
|
current.4
|
||||||
|
} else {
|
||||||
|
current.4.saturating_add(1)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let issued_at = current.as_ref().map_or(resolved_at, |current| {
|
||||||
|
if signed_fields_match && !renew_validity {
|
||||||
|
current.6
|
||||||
|
} else {
|
||||||
|
resolved_at
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let valid_until = if signed_fields_match && !renew_validity {
|
||||||
|
current_valid_until
|
||||||
|
} else {
|
||||||
|
Some(resolved_at.saturating_add(LOCAL_DESCRIPTOR_LIFETIME_MILLIS))
|
||||||
|
};
|
||||||
|
let descriptor = iota_identity::VerifiedPrincipalDescriptor::from_trusted_authority(
|
||||||
|
PrincipalDescriptor {
|
||||||
|
principal: PrincipalId {
|
||||||
|
authority: authority.clone(),
|
||||||
|
user_id,
|
||||||
|
},
|
||||||
|
authority_kind,
|
||||||
|
username: Some(user.username.clone()),
|
||||||
|
display_name: user.display_name.clone(),
|
||||||
|
public_keys: vec![key],
|
||||||
|
home,
|
||||||
|
revision,
|
||||||
|
valid_until,
|
||||||
|
issued_at,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let handle = self.upsert_remote_descriptor(&descriptor)?;
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO hosted_principals (local_user_id, principal_handle) VALUES (?1, ?2) ON CONFLICT(local_user_id) DO UPDATE SET principal_handle = excluded.principal_handle",
|
||||||
|
params![user.id.0, handle.0],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_principal_descriptor(
|
||||||
|
&self,
|
||||||
|
user: LocalUserId,
|
||||||
|
) -> Result<PrincipalDescriptor, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let row = connection
|
||||||
|
.query_row(
|
||||||
|
r#"SELECT p.authority_kind, p.authority_id, p.remote_user_id,
|
||||||
|
p.username, p.display_name, p.home,
|
||||||
|
p.descriptor_revision, p.descriptor_valid_until,
|
||||||
|
COALESCE(p.descriptor_issued_at, p.last_resolved_at)
|
||||||
|
FROM hosted_principals h
|
||||||
|
JOIN principals p ON p.principal_pk = h.principal_handle
|
||||||
|
WHERE h.local_user_id = ?1 AND p.retired_at IS NULL"#,
|
||||||
|
[user.0],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, Option<String>>(4)?,
|
||||||
|
row.get::<_, Option<String>>(5)?,
|
||||||
|
row.get::<_, i64>(6)?,
|
||||||
|
row.get::<_, Option<i64>>(7)?,
|
||||||
|
row.get::<_, i64>(8)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some((kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at)) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let principal_pk = connection.query_row(
|
||||||
|
"SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1",
|
||||||
|
[user.0],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC",
|
||||||
|
)?;
|
||||||
|
let encoded = statement
|
||||||
|
.query_map(params![principal_pk, issued_at], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(Some((kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at, encoded)))
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?
|
||||||
|
.ok_or(IdentityError::NotFound)
|
||||||
|
.and_then(|(kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at, encoded)| {
|
||||||
|
let authority_kind = match kind.as_str() {
|
||||||
|
"iota" => AuthorityKind::Iota,
|
||||||
|
"omega" => AuthorityKind::Omega,
|
||||||
|
_ => return Err(IdentityError::InvalidDescriptor("stored authority kind is invalid".into())),
|
||||||
|
};
|
||||||
|
let public_keys = encoded
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| public_key_bundle_from_base64(&key).ok_or_else(|| IdentityError::InvalidDescriptor("stored principal key is invalid".into())))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(PrincipalDescriptor {
|
||||||
|
principal: PrincipalId {
|
||||||
|
authority: AuthorityId::new(authority)?,
|
||||||
|
user_id: u64::try_from(remote_user_id).map_err(|_| IdentityError::InvalidDescriptor("stored principal user ID is negative".into()))?,
|
||||||
|
},
|
||||||
|
authority_kind,
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
public_keys,
|
||||||
|
home: decode_home(home.as_deref()).map_err(storage_error)?,
|
||||||
|
revision,
|
||||||
|
valid_until,
|
||||||
|
issued_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Retire hosted identity after its data is purged. Keeping descriptor
|
||||||
|
* revision state rejects descriptors issued before account removal. */
|
||||||
|
pub fn retire_local_principal(
|
||||||
|
&self,
|
||||||
|
user: LocalUserId,
|
||||||
|
retired_at: i64,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
db::with_immediate_transaction(|transaction| {
|
||||||
|
let principal = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1",
|
||||||
|
[user.0],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::storage_error::StorageError::Other(
|
||||||
|
"hosted principal was not found".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE principals SET descriptor_revision = descriptor_revision + 1, descriptor_valid_until = ?2, last_resolved_at = ?2, retired_at = ?2 WHERE principal_pk = ?1",
|
||||||
|
params![principal, retired_at],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE principal_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE principal_pk = ?1",
|
||||||
|
params![principal, retired_at],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"DELETE FROM hosted_principals WHERE local_user_id = ?1",
|
||||||
|
[user.0],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteLocalDescriptorPublisher {
|
||||||
|
identity: iota_identity::LocalNodeIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteLocalDescriptorPublisher {
|
||||||
|
pub fn new(identity: iota_identity::LocalNodeIdentity) -> Self {
|
||||||
|
Self { identity }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalDescriptorPublisher for SqliteLocalDescriptorPublisher {
|
||||||
|
fn principal_descriptor(
|
||||||
|
&self,
|
||||||
|
user: LocalUserId,
|
||||||
|
) -> Result<SignedPrincipalDescriptor, IdentityError> {
|
||||||
|
let local_user = SqliteLocalUserStore
|
||||||
|
.get_local_user(user)?
|
||||||
|
.ok_or(IdentityError::NotFound)?;
|
||||||
|
SqlitePrincipalStore.ensure_local_principal(
|
||||||
|
self.identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&local_user,
|
||||||
|
PrincipalHome::Iota(self.identity.node_id().clone()),
|
||||||
|
now_millis(),
|
||||||
|
)?;
|
||||||
|
SignedPrincipalDescriptor::sign(
|
||||||
|
SqlitePrincipalStore.local_principal_descriptor(user)?,
|
||||||
|
&self.identity.keyring(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalIdentityResolver {
|
||||||
|
authority: AuthorityId,
|
||||||
|
authority_kind: AuthorityKind,
|
||||||
|
home: PrincipalHome,
|
||||||
|
local_users: Arc<dyn LocalUserStore>,
|
||||||
|
principals: Arc<SqlitePrincipalStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalIdentityResolver {
|
||||||
|
pub fn new(
|
||||||
|
authority: AuthorityId,
|
||||||
|
authority_kind: AuthorityKind,
|
||||||
|
home: PrincipalHome,
|
||||||
|
local_users: Arc<dyn LocalUserStore>,
|
||||||
|
principals: Arc<SqlitePrincipalStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
authority,
|
||||||
|
authority_kind,
|
||||||
|
home,
|
||||||
|
local_users,
|
||||||
|
principals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_local(&self, user: LocalUserDescriptor) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
let handle = self.principals.ensure_local_principal(
|
||||||
|
&self.authority,
|
||||||
|
self.authority_kind,
|
||||||
|
&user,
|
||||||
|
self.home.clone(),
|
||||||
|
now_millis(),
|
||||||
|
)?;
|
||||||
|
self.principals
|
||||||
|
.get_principal(handle)?
|
||||||
|
.ok_or(IdentityError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl iota_identity::IdentityResolver for LocalIdentityResolver {
|
||||||
|
async fn resolve_address(
|
||||||
|
&self,
|
||||||
|
address: &iota_identity::UserAddress,
|
||||||
|
_: &iota_identity::ResolutionContext,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
if let Some(address_authority) = &address.authority {
|
||||||
|
let matches_home = matches!(
|
||||||
|
&self.home,
|
||||||
|
PrincipalHome::Omega(locator) if locator == address_authority
|
||||||
|
);
|
||||||
|
if address_authority.as_str() != self.authority.as_str() && !matches_home {
|
||||||
|
return Err(IdentityError::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let user = match &address.selector {
|
||||||
|
iota_identity::UserSelector::UserId(user_id) => {
|
||||||
|
let user_id = i64::try_from(*user_id).map_err(|_| {
|
||||||
|
IdentityError::InvalidIdentifier("user ID exceeds local storage range".into())
|
||||||
|
})?;
|
||||||
|
self.local_users.get_local_user(LocalUserId(user_id))?
|
||||||
|
}
|
||||||
|
iota_identity::UserSelector::Username(username) => {
|
||||||
|
self.local_users.get_local_user_by_username(username)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ok_or(IdentityError::NotFound)?;
|
||||||
|
let resolved = self.resolve_local(user)?;
|
||||||
|
verify_pin(address.public_key_pin.as_ref(), &resolved.public_keys)?;
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_principal(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
) -> Result<ResolvedPrincipal, IdentityError> {
|
||||||
|
if principal.authority != self.authority {
|
||||||
|
return Err(IdentityError::NotFound);
|
||||||
|
}
|
||||||
|
let user_id = i64::try_from(principal.user_id).map_err(|_| {
|
||||||
|
IdentityError::InvalidIdentifier("user ID exceeds local storage range".into())
|
||||||
|
})?;
|
||||||
|
let user = self
|
||||||
|
.local_users
|
||||||
|
.get_local_user(LocalUserId(user_id))?
|
||||||
|
.ok_or(IdentityError::NotFound)?;
|
||||||
|
self.resolve_local(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signing_keys(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
_: &iota_identity::ResolutionContext,
|
||||||
|
) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
self.resolve_principal(principal)
|
||||||
|
.await
|
||||||
|
.map(|resolved| resolved.public_keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_pin(
|
||||||
|
pin: Option<&PublicKeyBundle>,
|
||||||
|
keys: &[PublicKeyBundle],
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
let Some(pin) = pin else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let pin = pin
|
||||||
|
.try_as_bytes()
|
||||||
|
.map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?;
|
||||||
|
let matches = keys.iter().any(|key| {
|
||||||
|
key.try_as_bytes()
|
||||||
|
.map(|candidate| candidate == pin)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
if matches {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(IdentityError::KeyPinMismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrincipalStore for SqlitePrincipalStore {
|
||||||
|
fn get_principal(
|
||||||
|
&self,
|
||||||
|
handle: PrincipalHandle,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
db::with_db(|connection| load_principal(connection, "p.principal_pk = ?1", handle.0))
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_by_canonical_id(
|
||||||
|
&self,
|
||||||
|
principal: &PrincipalId,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
let remote_user_id = i64::try_from(principal.user_id).map_err(|_| {
|
||||||
|
IdentityError::InvalidIdentifier("principal user ID exceeds storage range".into())
|
||||||
|
})?;
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let handle = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2",
|
||||||
|
params![principal.authority.as_str(), remote_user_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
match handle {
|
||||||
|
Some(handle) => load_principal(connection, "p.principal_pk = ?1", handle),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_by_username(
|
||||||
|
&self,
|
||||||
|
authority: &AuthorityId,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let handle = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_pk FROM principals WHERE authority_id = ?1 AND username = ?2 AND retired_at IS NULL",
|
||||||
|
params![authority.as_str(), username],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
match handle {
|
||||||
|
Some(handle) => load_principal(connection, "p.principal_pk = ?1", handle),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_remote_descriptor(
|
||||||
|
&self,
|
||||||
|
descriptor: &iota_identity::VerifiedPrincipalDescriptor,
|
||||||
|
) -> Result<PrincipalHandle, IdentityError> {
|
||||||
|
let resolved_at = descriptor.resolved_at();
|
||||||
|
let descriptor = descriptor.descriptor();
|
||||||
|
if descriptor.public_keys.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"principal descriptor contains no signing keys".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if descriptor.revision < 0 {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"principal descriptor revision is negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if descriptor
|
||||||
|
.valid_until
|
||||||
|
.is_some_and(|valid_until| valid_until <= resolved_at)
|
||||||
|
{
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"principal descriptor is already expired".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let remote_user_id = i64::try_from(descriptor.principal.user_id).map_err(|_| {
|
||||||
|
IdentityError::InvalidIdentifier("principal user ID exceeds storage range".into())
|
||||||
|
})?;
|
||||||
|
let home = encode_home(&descriptor.home);
|
||||||
|
db::with_immediate_transaction(|transaction| {
|
||||||
|
let current = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT principal_pk, descriptor_revision, last_resolved_at, retired_at, authority_kind, username, display_name, home, descriptor_valid_until, descriptor_issued_at FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2",
|
||||||
|
params![descriptor.principal.authority.as_str(), remote_user_id],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option<i64>>(3)?, row.get::<_, String>(4)?, row.get::<_, Option<String>>(5)?, row.get::<_, Option<String>>(6)?, row.get::<_, Option<String>>(7)?, row.get::<_, Option<i64>>(8)?, row.get::<_, Option<i64>>(9)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((handle, revision, current_resolved_at, retired_at, authority_kind, username, display_name, current_home, valid_until, issued_at)) = ¤t {
|
||||||
|
if retired_at.is_some()
|
||||||
|
|| *revision > descriptor.revision
|
||||||
|
|| (*revision == descriptor.revision && *current_resolved_at > resolved_at)
|
||||||
|
{
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"stale principal descriptor update was rejected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if *revision == descriptor.revision {
|
||||||
|
let mut stored_keys = transaction
|
||||||
|
.prepare("SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND source_revision = ?2 ORDER BY public_key")?
|
||||||
|
.query_map(params![handle, revision], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let mut descriptor_keys = descriptor
|
||||||
|
.public_keys
|
||||||
|
.iter()
|
||||||
|
.map(public_key_bundle_to_base64)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
stored_keys.sort();
|
||||||
|
descriptor_keys.sort();
|
||||||
|
if authority_kind != descriptor.authority_kind.as_str()
|
||||||
|
|| username != &descriptor.username
|
||||||
|
|| display_name != &descriptor.display_name
|
||||||
|
|| current_home != &home
|
||||||
|
|| valid_until != &descriptor.valid_until
|
||||||
|
|| issued_at.is_some_and(|issued_at| issued_at != descriptor.issued_at)
|
||||||
|
|| stored_keys != descriptor_keys
|
||||||
|
{
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"conflicting principal descriptor revision was rejected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
r#"INSERT INTO principals (
|
||||||
|
authority_kind, authority_id, remote_user_id, username, display_name,
|
||||||
|
home, descriptor_revision, descriptor_valid_until, descriptor_issued_at,
|
||||||
|
last_resolved_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||||
|
ON CONFLICT(authority_id, remote_user_id) DO UPDATE SET
|
||||||
|
authority_kind = excluded.authority_kind,
|
||||||
|
username = excluded.username,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
home = excluded.home,
|
||||||
|
descriptor_revision = excluded.descriptor_revision,
|
||||||
|
descriptor_valid_until = excluded.descriptor_valid_until,
|
||||||
|
descriptor_issued_at = excluded.descriptor_issued_at,
|
||||||
|
last_resolved_at = excluded.last_resolved_at"#,
|
||||||
|
params![
|
||||||
|
descriptor.authority_kind.as_str(),
|
||||||
|
descriptor.principal.authority.as_str(),
|
||||||
|
remote_user_id,
|
||||||
|
descriptor.username,
|
||||||
|
descriptor.display_name,
|
||||||
|
home,
|
||||||
|
descriptor.revision,
|
||||||
|
descriptor.valid_until,
|
||||||
|
descriptor.issued_at,
|
||||||
|
resolved_at,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let handle = transaction.query_row(
|
||||||
|
"SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2",
|
||||||
|
params![descriptor.principal.authority.as_str(), remote_user_id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE principal_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE principal_pk = ?1",
|
||||||
|
params![handle, resolved_at],
|
||||||
|
)?;
|
||||||
|
for key in &descriptor.public_keys {
|
||||||
|
let encoded = public_key_bundle_to_base64(key);
|
||||||
|
if encoded.is_empty() {
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"principal signing key could not be encoded".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
r#"INSERT INTO principal_keys (
|
||||||
|
principal_pk, public_key, valid_from, valid_until, source_revision
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(principal_pk, public_key) DO UPDATE SET
|
||||||
|
valid_until = excluded.valid_until,
|
||||||
|
source_revision = excluded.source_revision"#,
|
||||||
|
params![
|
||||||
|
handle,
|
||||||
|
encoded,
|
||||||
|
resolved_at,
|
||||||
|
descriptor.valid_until,
|
||||||
|
descriptor.revision,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(PrincipalHandle(handle))
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_keys(&self, principal: &PrincipalId) -> Result<Vec<PublicKeyBundle>, IdentityError> {
|
||||||
|
let resolved = self
|
||||||
|
.get_by_canonical_id(principal)?
|
||||||
|
.ok_or(IdentityError::NotFound)?;
|
||||||
|
if !resolved.is_valid_at(now_millis()) {
|
||||||
|
return Err(IdentityError::Unavailable(
|
||||||
|
"cached principal descriptor is expired".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if resolved.public_keys.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"principal has no current signing keys".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(resolved.public_keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_principal(
|
||||||
|
connection: &rusqlite::Connection,
|
||||||
|
predicate: &str,
|
||||||
|
value: i64,
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, crate::storage_error::StorageError> {
|
||||||
|
let query = format!(
|
||||||
|
"SELECT p.principal_pk, p.authority_id, p.remote_user_id, p.username, p.home, p.descriptor_revision, p.descriptor_valid_until, p.last_resolved_at FROM principals p WHERE {predicate}"
|
||||||
|
);
|
||||||
|
let row = connection
|
||||||
|
.query_row(&query, [value], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, Option<String>>(4)?,
|
||||||
|
row.get::<_, i64>(5)?,
|
||||||
|
row.get::<_, Option<i64>>(6)?,
|
||||||
|
row.get::<_, i64>(7)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
let Some((
|
||||||
|
handle,
|
||||||
|
authority,
|
||||||
|
remote_user_id,
|
||||||
|
username,
|
||||||
|
home,
|
||||||
|
descriptor_revision,
|
||||||
|
valid_until,
|
||||||
|
resolved_at,
|
||||||
|
)) = row
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC, valid_from DESC",
|
||||||
|
)?;
|
||||||
|
let encoded = statement
|
||||||
|
.query_map(params![handle, now_millis()], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let mut public_keys = Vec::with_capacity(encoded.len());
|
||||||
|
for value in encoded {
|
||||||
|
let key = public_key_bundle_from_base64(&value).ok_or_else(|| {
|
||||||
|
crate::storage_error::StorageError::Other("stored principal key is invalid".into())
|
||||||
|
})?;
|
||||||
|
public_keys.push(key);
|
||||||
|
}
|
||||||
|
let authority = AuthorityId::new(authority)
|
||||||
|
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?;
|
||||||
|
let user_id = u64::try_from(remote_user_id).map_err(|_| {
|
||||||
|
crate::storage_error::StorageError::Other("stored principal user ID is negative".into())
|
||||||
|
})?;
|
||||||
|
Ok(Some(ResolvedPrincipal {
|
||||||
|
principal: PrincipalId { authority, user_id },
|
||||||
|
handle: PrincipalHandle(handle),
|
||||||
|
username,
|
||||||
|
public_keys,
|
||||||
|
home: decode_home(home.as_deref())?,
|
||||||
|
descriptor_revision,
|
||||||
|
valid_until,
|
||||||
|
resolved_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_home(home: &PrincipalHome) -> Option<String> {
|
||||||
|
match home {
|
||||||
|
PrincipalHome::Iota(id) => Some(format!("iota:{}", id.as_str())),
|
||||||
|
PrincipalHome::LegacyOmegaIota { omega, iota_id } => {
|
||||||
|
Some(format!("legacy_omega_iota:{}:{iota_id}", omega.as_str()))
|
||||||
|
}
|
||||||
|
PrincipalHome::Omega(locator) => Some(format!("omega:{}", locator.as_str())),
|
||||||
|
PrincipalHome::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_home(home: Option<&str>) -> Result<PrincipalHome, crate::storage_error::StorageError> {
|
||||||
|
let Some(home) = home else {
|
||||||
|
return Ok(PrincipalHome::Unknown);
|
||||||
|
};
|
||||||
|
if let Some(id) = home.strip_prefix("iota:") {
|
||||||
|
return iota_identity::IotaNodeId::new(id)
|
||||||
|
.map(PrincipalHome::Iota)
|
||||||
|
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(value) = home.strip_prefix("legacy_omega_iota:") {
|
||||||
|
let (omega, iota_id) = value.rsplit_once(':').ok_or_else(|| {
|
||||||
|
crate::storage_error::StorageError::Other(
|
||||||
|
"stored legacy Omega Iota home is invalid".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let omega = AuthorityId::new(omega)
|
||||||
|
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?;
|
||||||
|
let iota_id = iota_id.parse::<u64>().map_err(|_| {
|
||||||
|
crate::storage_error::StorageError::Other(
|
||||||
|
"stored legacy Omega Iota ID is invalid".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Ok(PrincipalHome::LegacyOmegaIota { omega, iota_id });
|
||||||
|
}
|
||||||
|
if let Some(locator) = home.strip_prefix("omega:") {
|
||||||
|
return iota_identity::AuthorityLocator::new(locator)
|
||||||
|
.map(PrincipalHome::Omega)
|
||||||
|
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()));
|
||||||
|
}
|
||||||
|
Err(crate::storage_error::StorageError::Other(
|
||||||
|
"stored principal home is invalid".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_error(error: impl std::fmt::Display) -> IdentityError {
|
||||||
|
IdentityError::Storage(error.to_string())
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
pub mod identity;
|
||||||
|
pub mod node_directory;
|
||||||
pub mod storage_error;
|
pub mod storage_error;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
|
||||||
420
iota-storage/src/node_directory.rs
Normal file
420
iota-storage/src/node_directory.rs
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityLocator, IdentityError, IotaNodeId, LocalNodeIdentity, NodeDescriptor,
|
||||||
|
NodeIdentityResolver, PublicKeyBundle, ResolvedNodeIdentity, SignedNodeDescriptor,
|
||||||
|
VerifiedNodeDescriptor,
|
||||||
|
};
|
||||||
|
use iota_util::crypto_helper::{public_key_bundle_from_base64, public_key_bundle_to_base64};
|
||||||
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
|
use crate::util::db;
|
||||||
|
|
||||||
|
const DESCRIPTOR_LIFETIME_MILLIS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||||
|
const DESCRIPTOR_RENEWAL_MILLIS: i64 = 7 * 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SqliteNodeDirectory;
|
||||||
|
|
||||||
|
impl SqliteNodeDirectory {
|
||||||
|
pub fn key_for_authentication_hint(
|
||||||
|
&self,
|
||||||
|
hint: u64,
|
||||||
|
) -> Result<Option<PublicKeyBundle>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let mut statement = connection.prepare("SELECT node_id FROM node_directory")?;
|
||||||
|
let nodes = statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
for node in nodes {
|
||||||
|
let node = IotaNodeId::new(node).map_err(|error| {
|
||||||
|
crate::storage_error::StorageError::Other(error.to_string())
|
||||||
|
})?;
|
||||||
|
if node.mtp_authentication_hint() != hint {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT public_key FROM node_keys WHERE node_id = ?1 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC LIMIT 1",
|
||||||
|
params![node.as_str(), now_millis()],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?
|
||||||
|
.map(|key| {
|
||||||
|
public_key_bundle_from_base64(&key).ok_or_else(|| {
|
||||||
|
IdentityError::InvalidDescriptor("stored node key is invalid".into())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_local_descriptor(
|
||||||
|
&self,
|
||||||
|
identity: &LocalNodeIdentity,
|
||||||
|
direct_endpoints: Vec<AuthorityLocator>,
|
||||||
|
relay_hints: Vec<AuthorityLocator>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<SignedNodeDescriptor, IdentityError> {
|
||||||
|
let current = self.load(identity.node_id())?;
|
||||||
|
let fields_match = current.as_ref().is_some_and(|current| {
|
||||||
|
current.direct_endpoints == direct_endpoints && current.relay_hints == relay_hints
|
||||||
|
});
|
||||||
|
let renew = current
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|current| current.valid_until)
|
||||||
|
.is_none_or(|valid_until| valid_until <= now + DESCRIPTOR_RENEWAL_MILLIS);
|
||||||
|
let revision = current.as_ref().map_or(0, |current| {
|
||||||
|
if fields_match && !renew {
|
||||||
|
current.descriptor_revision
|
||||||
|
} else {
|
||||||
|
current.descriptor_revision.saturating_add(1)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let valid_until = if fields_match && !renew {
|
||||||
|
current.and_then(|current| current.valid_until)
|
||||||
|
} else {
|
||||||
|
Some(now.saturating_add(DESCRIPTOR_LIFETIME_MILLIS))
|
||||||
|
};
|
||||||
|
let descriptor = NodeDescriptor {
|
||||||
|
node: identity.node_id().clone(),
|
||||||
|
public_keys: identity.public_keys(),
|
||||||
|
direct_endpoints,
|
||||||
|
relay_hints,
|
||||||
|
revision,
|
||||||
|
valid_until,
|
||||||
|
};
|
||||||
|
let signed = SignedNodeDescriptor::sign(descriptor, identity)?;
|
||||||
|
let verified = signed
|
||||||
|
.clone()
|
||||||
|
.verify(current_revision(&self.load(identity.node_id())?), now)?;
|
||||||
|
self.upsert(&verified)?;
|
||||||
|
Ok(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(&self, verified: &VerifiedNodeDescriptor) -> Result<(), IdentityError> {
|
||||||
|
let descriptor = verified.descriptor();
|
||||||
|
let direct_endpoints = serde_json::to_string(&descriptor.direct_endpoints)
|
||||||
|
.map_err(|error| IdentityError::Storage(error.to_string()))?;
|
||||||
|
let relay_hints = serde_json::to_string(&descriptor.relay_hints)
|
||||||
|
.map_err(|error| IdentityError::Storage(error.to_string()))?;
|
||||||
|
let public_key = public_key_bundle_to_base64(&descriptor.public_keys);
|
||||||
|
if public_key.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"node public key could not be encoded".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
db::with_immediate_transaction(|transaction| {
|
||||||
|
let current = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT n.descriptor_revision, n.last_resolved_at, n.descriptor_valid_until, n.direct_endpoints, n.relay_hints, (SELECT public_key FROM node_keys WHERE node_id = n.node_id AND source_revision = n.descriptor_revision LIMIT 1) FROM node_directory n WHERE n.node_id = ?1",
|
||||||
|
[descriptor.node.as_str()],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<i64>>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, Option<String>>(5)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((revision, resolved_at, valid_until, current_endpoints, current_relays, current_key)) = current {
|
||||||
|
if revision > descriptor.revision
|
||||||
|
|| (revision == descriptor.revision && resolved_at > verified.resolved_at())
|
||||||
|
{
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"stale node descriptor update was rejected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if revision == descriptor.revision
|
||||||
|
&& (valid_until != descriptor.valid_until
|
||||||
|
|| current_endpoints != direct_endpoints
|
||||||
|
|| current_relays != relay_hints
|
||||||
|
|| current_key.as_deref() != Some(public_key.as_str()))
|
||||||
|
{
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"conflicting node descriptor revision was rejected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transaction.execute(
|
||||||
|
r#"INSERT INTO node_directory (
|
||||||
|
node_id, descriptor_revision, descriptor_valid_until,
|
||||||
|
direct_endpoints, relay_hints, last_resolved_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
|
ON CONFLICT(node_id) DO UPDATE SET
|
||||||
|
descriptor_revision = excluded.descriptor_revision,
|
||||||
|
descriptor_valid_until = excluded.descriptor_valid_until,
|
||||||
|
direct_endpoints = excluded.direct_endpoints,
|
||||||
|
relay_hints = excluded.relay_hints,
|
||||||
|
last_resolved_at = excluded.last_resolved_at"#,
|
||||||
|
params![
|
||||||
|
descriptor.node.as_str(),
|
||||||
|
descriptor.revision,
|
||||||
|
descriptor.valid_until,
|
||||||
|
direct_endpoints,
|
||||||
|
relay_hints,
|
||||||
|
verified.resolved_at(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE node_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE node_id = ?1",
|
||||||
|
params![descriptor.node.as_str(), verified.resolved_at()],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
r#"INSERT INTO node_keys (
|
||||||
|
node_id, public_key, valid_from, valid_until, source_revision
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(node_id, public_key) DO UPDATE SET
|
||||||
|
valid_until = excluded.valid_until,
|
||||||
|
source_revision = excluded.source_revision"#,
|
||||||
|
params![
|
||||||
|
descriptor.node.as_str(),
|
||||||
|
public_key,
|
||||||
|
verified.resolved_at(),
|
||||||
|
descriptor.valid_until,
|
||||||
|
descriptor.revision,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pin_endpoint_record(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
node: &IotaNodeId,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let current = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT node_id FROM node_endpoint_pins WHERE endpoint = ?1",
|
||||||
|
[endpoint.as_str()],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if current.as_deref().is_some_and(|current| current != node.as_str()) {
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"endpoint node identity changed from its TOFU pin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
connection.execute(
|
||||||
|
"INSERT OR IGNORE INTO node_endpoint_pins (endpoint, node_id, pinned_at) VALUES (?1, ?2, ?3)",
|
||||||
|
params![endpoint.as_str(), node.as_str(), now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pinned_node_record(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<Option<IotaNodeId>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT node_id FROM node_endpoint_pins WHERE endpoint = ?1",
|
||||||
|
[endpoint.as_str()],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?
|
||||||
|
.map(IotaNodeId::new)
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pin_endpoint_certificate_record(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
certificate: &[u8],
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
if certificate.is_empty() {
|
||||||
|
return Err(IdentityError::InvalidDescriptor(
|
||||||
|
"endpoint TLS certificate is empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let current = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT tls_certificate FROM node_endpoint_pins WHERE endpoint = ?1",
|
||||||
|
[endpoint.as_str()],
|
||||||
|
|row| row.get::<_, Option<Vec<u8>>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::storage_error::StorageError::Other(
|
||||||
|
"endpoint must be pinned before its TLS certificate".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if current
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|current| current != certificate)
|
||||||
|
{
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"endpoint TLS certificate changed from its TOFU pin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE node_endpoint_pins SET tls_certificate = COALESCE(tls_certificate, ?2) WHERE endpoint = ?1",
|
||||||
|
params![endpoint.as_str(), certificate],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pinned_endpoint_certificate_record(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<Option<Vec<u8>>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT tls_certificate FROM node_endpoint_pins WHERE endpoint = ?1",
|
||||||
|
[endpoint.as_str()],
|
||||||
|
|row| row.get::<_, Option<Vec<u8>>>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map(|certificate| certificate.flatten())
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_successful_endpoint(
|
||||||
|
&self,
|
||||||
|
node: &IotaNodeId,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE node_directory SET last_successful_endpoint = ?2 WHERE node_id = ?1",
|
||||||
|
params![node.as_str(), endpoint.as_str()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(storage_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(&self, node: &IotaNodeId) -> Result<Option<ResolvedNodeIdentity>, IdentityError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let row = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT descriptor_revision, descriptor_valid_until, direct_endpoints, relay_hints, last_successful_endpoint, last_resolved_at FROM node_directory WHERE node_id = ?1",
|
||||||
|
[node.as_str()],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, Option<String>>(4)?, row.get::<_, i64>(5)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some((descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at)) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT public_key FROM node_keys WHERE node_id = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC",
|
||||||
|
)?;
|
||||||
|
let encoded = statement
|
||||||
|
.query_map(params![node.as_str(), now_millis()], |row| row.get::<_, String>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(Some((descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at, encoded)))
|
||||||
|
})
|
||||||
|
.map_err(storage_error)?
|
||||||
|
.map(|(descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at, encoded)| {
|
||||||
|
Ok(ResolvedNodeIdentity {
|
||||||
|
node: node.clone(),
|
||||||
|
public_keys: encoded
|
||||||
|
.into_iter()
|
||||||
|
.map(|key| public_key_bundle_from_base64(&key).ok_or_else(|| IdentityError::InvalidDescriptor("stored node key is invalid".into())))
|
||||||
|
.collect::<Result<Vec<PublicKeyBundle>, _>>()?,
|
||||||
|
descriptor_revision,
|
||||||
|
valid_until,
|
||||||
|
direct_endpoints: serde_json::from_str(&direct_endpoints).map_err(|error| IdentityError::Storage(error.to_string()))?,
|
||||||
|
relay_hints: serde_json::from_str(&relay_hints).map_err(|error| IdentityError::Storage(error.to_string()))?,
|
||||||
|
last_successful_endpoint: last_successful_endpoint.map(AuthorityLocator::new).transpose()?,
|
||||||
|
resolved_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NodeIdentityResolver for SqliteNodeDirectory {
|
||||||
|
async fn resolve_node(&self, node: &IotaNodeId) -> Result<ResolvedNodeIdentity, IdentityError> {
|
||||||
|
let resolved = self.load(node)?.ok_or(IdentityError::NotFound)?;
|
||||||
|
if resolved
|
||||||
|
.valid_until
|
||||||
|
.is_some_and(|valid_until| valid_until <= now_millis())
|
||||||
|
{
|
||||||
|
return Err(IdentityError::Unavailable(
|
||||||
|
"cached node descriptor is expired".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl iota_identity::NodeDirectory for SqliteNodeDirectory {
|
||||||
|
fn upsert_node_descriptor(
|
||||||
|
&self,
|
||||||
|
descriptor: &VerifiedNodeDescriptor,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
self.upsert(descriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin_endpoint(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
node: &IotaNodeId,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
self.pin_endpoint_record(endpoint, node, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_node(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<Option<IotaNodeId>, IdentityError> {
|
||||||
|
self.pinned_node_record(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin_endpoint_certificate(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
certificate: &[u8],
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
self.pin_endpoint_certificate_record(endpoint, certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_endpoint_certificate(
|
||||||
|
&self,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<Option<Vec<u8>>, IdentityError> {
|
||||||
|
self.pinned_endpoint_certificate_record(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_successful_endpoint(
|
||||||
|
&self,
|
||||||
|
node: &IotaNodeId,
|
||||||
|
endpoint: &AuthorityLocator,
|
||||||
|
) -> Result<(), IdentityError> {
|
||||||
|
SqliteNodeDirectory::record_successful_endpoint(self, node, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_revision(node: &Option<ResolvedNodeIdentity>) -> Option<i64> {
|
||||||
|
node.as_ref().map(|node| node.descriptor_revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_error(error: impl std::fmt::Display) -> IdentityError {
|
||||||
|
IdentityError::Storage(error.to_string())
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Contact {
|
pub struct Contact {
|
||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
|
pub principal: Option<iota_identity::PrincipalHandle>,
|
||||||
pub user_name: Option<String>,
|
pub user_name: Option<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub last_message_at: Option<i64>,
|
pub last_message_at: Option<i64>,
|
||||||
|
|
@ -12,6 +13,7 @@ impl Default for Contact {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Contact {
|
Contact {
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
|
principal: None,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
last_message_at: None,
|
last_message_at: None,
|
||||||
|
|
@ -28,7 +30,8 @@ impl Contact {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
Contact {
|
Contact {
|
||||||
user_id: user_id,
|
user_id,
|
||||||
|
principal: None,
|
||||||
user_name: None,
|
user_name: None,
|
||||||
created_at,
|
created_at,
|
||||||
last_message_at: None,
|
last_message_at: None,
|
||||||
|
|
@ -36,6 +39,13 @@ impl Contact {
|
||||||
notifications_read_at: 0,
|
notifications_read_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn for_principal(user_id: i64, principal: iota_identity::PrincipalHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
principal: Some(principal),
|
||||||
|
..Self::new(user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn set_last_message_at(&mut self, p0: i64) {
|
pub fn set_last_message_at(&mut self, p0: i64) {
|
||||||
self.last_message_at = Option::from(p0);
|
self.last_message_at = Option::from(p0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,31 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
|
||||||
try_add_user_with_credential_origin(user, CredentialOrigin::Local)
|
try_add_user_with_credential_origin(user, CredentialOrigin::Local)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reserve a local identity before account creation. The counter never moves
|
||||||
|
* backward, so a removed account cannot be assigned its old canonical ID. */
|
||||||
|
pub fn allocate_local_user_id(maximum: i64) -> Result<i64, crate::storage_error::StorageError> {
|
||||||
|
db::with_immediate_transaction(|tx| {
|
||||||
|
let next = tx.query_row(
|
||||||
|
"SELECT next_user_id FROM local_identity_allocator WHERE singleton = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
if next < 1 || next > maximum {
|
||||||
|
return Err(crate::storage_error::StorageError::Other(
|
||||||
|
"local user ID space is exhausted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let following = next.checked_add(1).ok_or_else(|| {
|
||||||
|
crate::storage_error::StorageError::Other("local user ID space is exhausted".into())
|
||||||
|
})?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE local_identity_allocator SET next_user_id = ?1 WHERE singleton = 1",
|
||||||
|
params![following],
|
||||||
|
)?;
|
||||||
|
Ok(next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn try_add_user_with_credential_origin(
|
pub fn try_add_user_with_credential_origin(
|
||||||
user: UserProfile,
|
user: UserProfile,
|
||||||
credential_origin: CredentialOrigin,
|
credential_origin: CredentialOrigin,
|
||||||
|
|
@ -457,15 +482,15 @@ fn purge_database_rows(user_id: i64) -> Result<(), crate::storage_error::Storage
|
||||||
params![user_id],
|
params![user_id],
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1",
|
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1 OR signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR destination_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1)",
|
||||||
params![user_id],
|
params![user_id],
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_id = ?1 OR relay_inbox.destination_id = ?1))",
|
"DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_principal = relay_replay.signer_principal AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR relay_inbox.destination_id = ?1))",
|
||||||
params![user_id],
|
params![user_id],
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1",
|
"DELETE FROM relay_inbox WHERE signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR destination_id = ?1",
|
||||||
params![user_id],
|
params![user_id],
|
||||||
)?;
|
)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
|
||||||
|
|
@ -6,35 +6,48 @@ use rusqlite::{OptionalExtension, params};
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct BlockMutation {
|
pub struct BlockMutation {
|
||||||
pub blocked_user_id: i64,
|
pub blocked_user_id: i64,
|
||||||
|
pub blocked_principal: iota_identity::PrincipalHandle,
|
||||||
pub revision: i64,
|
pub revision: i64,
|
||||||
pub deleted: bool,
|
pub deleted: bool,
|
||||||
}
|
}
|
||||||
fn validate(user_id: i64, blocked_user_id: i64) -> Result<(), StorageError> {
|
fn validate(
|
||||||
if user_id <= 0 || blocked_user_id <= 0 || user_id == blocked_user_id {
|
user_id: i64,
|
||||||
|
blocked_principal: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if user_id <= 0 || blocked_principal.0 <= 0 {
|
||||||
Err(StorageError::Other("invalid block relationship".into()))
|
Err(StorageError::Other("invalid block relationship".into()))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn block(user_id: i64, blocked_user_id: i64) -> Result<BlockMutation, StorageError> {
|
pub fn block(
|
||||||
validate(user_id, blocked_user_id)?;
|
user_id: i64,
|
||||||
|
blocked_principal: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<BlockMutation, StorageError> {
|
||||||
|
validate(user_id, blocked_principal)?;
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", params![user_id, blocked_user_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?;
|
let blocked_user_id = tx.query_row(
|
||||||
|
"SELECT remote_user_id FROM principals WHERE principal_pk = ?1",
|
||||||
|
[blocked_principal.0],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2", params![user_id, blocked_principal.0], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?;
|
||||||
if let Some((_, revision)) = prior {
|
if let Some((_, revision)) = prior {
|
||||||
return Ok(BlockMutation {
|
return Ok(BlockMutation {
|
||||||
blocked_user_id,
|
blocked_user_id,
|
||||||
|
blocked_principal,
|
||||||
revision,
|
revision,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let created_at = sync::now_millis();
|
let created_at = sync::now_millis();
|
||||||
tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, revision, created_at) VALUES (?1, ?2, 0, ?3)", params![user_id, blocked_user_id, created_at])?;
|
tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, blocked_principal, revision, created_at) VALUES (?1, ?2, ?3, 0, ?4)", params![user_id, blocked_user_id, blocked_principal.0, created_at])?;
|
||||||
let id = tx.last_insert_rowid();
|
let id = tx.last_insert_rowid();
|
||||||
let revision = sync::record_event(
|
let revision = sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
user_id,
|
user_id,
|
||||||
sync::EntityType::BlockedUser,
|
sync::EntityType::BlockedUser,
|
||||||
id,
|
blocked_principal.0,
|
||||||
sync::Operation::Upsert,
|
sync::Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
|
|
@ -43,41 +56,48 @@ pub fn block(user_id: i64, blocked_user_id: i64) -> Result<BlockMutation, Storag
|
||||||
)?;
|
)?;
|
||||||
Ok(BlockMutation {
|
Ok(BlockMutation {
|
||||||
blocked_user_id,
|
blocked_user_id,
|
||||||
|
blocked_principal,
|
||||||
revision,
|
revision,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn unblock(user_id: i64, blocked_user_id: i64) -> Result<Option<BlockMutation>, StorageError> {
|
pub fn unblock(
|
||||||
validate(user_id, blocked_user_id)?;
|
user_id: i64,
|
||||||
|
blocked_principal: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<Option<BlockMutation>, StorageError> {
|
||||||
|
validate(user_id, blocked_principal)?;
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let id = tx
|
let row = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2",
|
"SELECT id, blocked_user_id FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2",
|
||||||
params![user_id, blocked_user_id],
|
params![user_id, blocked_principal.0],
|
||||||
|r| r.get(0),
|
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
let Some(id) = id else {
|
let Some((id, blocked_user_id)) = row else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let revision = sync::record_event(
|
let revision = sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
user_id,
|
user_id,
|
||||||
sync::EntityType::BlockedUser,
|
sync::EntityType::BlockedUser,
|
||||||
id,
|
blocked_principal.0,
|
||||||
sync::Operation::Delete,
|
sync::Operation::Delete,
|
||||||
)?;
|
)?;
|
||||||
tx.execute("DELETE FROM blocked_users WHERE id = ?1", [id])?;
|
tx.execute("DELETE FROM blocked_users WHERE id = ?1", [id])?;
|
||||||
Ok(Some(BlockMutation {
|
Ok(Some(BlockMutation {
|
||||||
blocked_user_id,
|
blocked_user_id,
|
||||||
|
blocked_principal,
|
||||||
revision,
|
revision,
|
||||||
deleted: true,
|
deleted: true,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result<bool, StorageError> {
|
pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result<bool, StorageError> {
|
||||||
validate(user_id, candidate_sender_id)?;
|
if user_id <= 0 || candidate_sender_id <= 0 || user_id == candidate_sender_id {
|
||||||
|
return Err(StorageError::Other("invalid block relationship".into()));
|
||||||
|
}
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
|
|
@ -89,15 +109,41 @@ pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result<bool, Storag
|
||||||
.is_some())
|
.is_some())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn list(user_id: i64) -> Result<Vec<i64>, StorageError> {
|
|
||||||
|
pub fn is_principal_blocked(
|
||||||
|
user_id: i64,
|
||||||
|
candidate: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<bool, StorageError> {
|
||||||
|
if user_id <= 0 || candidate.0 <= 0 {
|
||||||
|
return Err(StorageError::Other("invalid block relationship".into()));
|
||||||
|
}
|
||||||
|
db::with_db(|conn| {
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2",
|
||||||
|
params![user_id, candidate.0],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.is_some())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn list(user_id: i64) -> Result<Vec<BlockMutation>, StorageError> {
|
||||||
if user_id <= 0 {
|
if user_id <= 0 {
|
||||||
return Err(StorageError::Other("invalid block owner".into()));
|
return Err(StorageError::Other("invalid block owner".into()));
|
||||||
}
|
}
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT blocked_user_id FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_user_id",
|
"SELECT blocked_user_id, blocked_principal, revision FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_principal",
|
||||||
)?;
|
)?;
|
||||||
stmt.query_map([user_id], |r| r.get(0))?
|
stmt.query_map([user_id], |r| {
|
||||||
|
Ok(BlockMutation {
|
||||||
|
blocked_user_id: r.get(0)?,
|
||||||
|
blocked_principal: iota_identity::PrincipalHandle(r.get(1)?),
|
||||||
|
revision: r.get(2)?,
|
||||||
|
deleted: false,
|
||||||
|
})
|
||||||
|
})?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(StorageError::from)
|
.map_err(StorageError::from)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,9 @@ impl MessageState {
|
||||||
pub struct StoredMessage {
|
pub struct StoredMessage {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub external_user: i64,
|
pub external_user: i64,
|
||||||
|
pub external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
pub relay_signer_id: Option<i64>,
|
pub relay_signer_id: Option<i64>,
|
||||||
|
pub relay_signer_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
pub relay_message_id: Option<String>,
|
pub relay_message_id: Option<String>,
|
||||||
pub message_time: i64,
|
pub message_time: i64,
|
||||||
pub authored_at: Option<i64>,
|
pub authored_at: Option<i64>,
|
||||||
|
|
@ -74,11 +76,13 @@ pub struct StoredMessage {
|
||||||
|
|
||||||
pub struct NewMessage<'a> {
|
pub struct NewMessage<'a> {
|
||||||
pub relay_signer_id: i64,
|
pub relay_signer_id: i64,
|
||||||
|
pub relay_signer_principal: iota_identity::PrincipalHandle,
|
||||||
pub relay_message_id: &'a str,
|
pub relay_message_id: &'a str,
|
||||||
pub authored_at: i64,
|
pub authored_at: i64,
|
||||||
pub send_time: i64,
|
pub send_time: i64,
|
||||||
pub storage_owner: i64,
|
pub storage_owner: i64,
|
||||||
pub external_user: i64,
|
pub external_user: i64,
|
||||||
|
pub external_principal: iota_identity::PrincipalHandle,
|
||||||
pub sent_by_self: bool,
|
pub sent_by_self: bool,
|
||||||
pub content: &'a str,
|
pub content: &'a str,
|
||||||
pub height: i64,
|
pub height: i64,
|
||||||
|
|
@ -109,6 +113,26 @@ pub fn edit_message(
|
||||||
update_message_content(
|
update_message_content(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
None,
|
||||||
|
message_time,
|
||||||
|
editor_id,
|
||||||
|
new_content,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_message_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
editor_id: i64,
|
||||||
|
new_content: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
update_message_content(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
message_time,
|
message_time,
|
||||||
editor_id,
|
editor_id,
|
||||||
new_content,
|
new_content,
|
||||||
|
|
@ -133,6 +157,31 @@ pub fn apply_remote_edit(
|
||||||
update_message_content(
|
update_message_content(
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
None,
|
||||||
|
message_time,
|
||||||
|
editor_id,
|
||||||
|
new_content,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_remote_edit_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
editor_id: i64,
|
||||||
|
new_content: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if editor_id != external_user {
|
||||||
|
return Err(StorageError::Other(
|
||||||
|
"Remote editor does not match chat partner".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
update_message_content(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
message_time,
|
message_time,
|
||||||
editor_id,
|
editor_id,
|
||||||
new_content,
|
new_content,
|
||||||
|
|
@ -143,6 +192,7 @@ pub fn apply_remote_edit(
|
||||||
fn update_message_content(
|
fn update_message_content(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
editor_id: i64,
|
editor_id: i64,
|
||||||
new_content: &str,
|
new_content: &str,
|
||||||
|
|
@ -153,10 +203,20 @@ fn update_message_content(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, content, sent_by_self, history_deleted
|
SELECT id, content, sent_by_self, history_deleted
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, i64>(0)?,
|
row.get::<_, i64>(0)?,
|
||||||
|
|
@ -221,16 +281,35 @@ pub fn hard_delete_message(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
hard_delete_message_with_principal(storage_owner, external_user, None, message_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hard_delete_message_with_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
message_time: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let msg_id = conn
|
let msg_id = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, history_deleted FROM messages
|
SELECT id, history_deleted FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
@ -277,14 +356,14 @@ pub fn remove_message_history_in_tx(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
message_id: i64,
|
message_id: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let message: Option<(i64, i64)> = tx
|
let message: Option<(i64, Option<i64>, i64)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT external_user, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2",
|
"SELECT external_user, external_principal, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2",
|
||||||
params![message_id, storage_owner],
|
params![message_id, storage_owner],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
let Some((external_user, history_deleted)) = message else {
|
let Some((external_user, external_principal, history_deleted)) = message else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if history_deleted != 0 {
|
if history_deleted != 0 {
|
||||||
|
|
@ -295,7 +374,7 @@ pub fn remove_message_history_in_tx(
|
||||||
[message_id],
|
[message_id],
|
||||||
)?;
|
)?;
|
||||||
tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?;
|
tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?;
|
||||||
tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_id = message_receipts.target_signer_id AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?;
|
tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_principal = message_receipts.target_signer_principal AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?;
|
||||||
tx.execute("UPDATE messages SET content = '', history_deleted = 1, history_deleted_at = ?2, expires_at = NULL, client_received_at = NULL, client_received_recorded_at = NULL, read_at = NULL, read_recorded_at = NULL WHERE id = ?1", params![message_id, sync::now_millis()])?;
|
tx.execute("UPDATE messages SET content = '', history_deleted = 1, history_deleted_at = ?2, expires_at = NULL, client_received_at = NULL, client_received_recorded_at = NULL, read_at = NULL, read_recorded_at = NULL WHERE id = ?1", params![message_id, sync::now_millis()])?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
|
|
@ -304,7 +383,12 @@ pub fn remove_message_history_in_tx(
|
||||||
message_id,
|
message_id,
|
||||||
Operation::Delete,
|
Operation::Delete,
|
||||||
)?;
|
)?;
|
||||||
update_contact_last_message_in_tx(tx, storage_owner, external_user)?;
|
update_contact_last_message_in_tx(
|
||||||
|
tx,
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal.map(iota_identity::PrincipalHandle),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,8 +396,12 @@ fn update_contact_last_message_in_tx(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
tx.execute("UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND user_id = ?2", params![storage_owner, external_user])?;
|
tx.execute(
|
||||||
|
"UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND ((?3 IS NULL AND external_user = ?2) OR external_principal = ?3) AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND ((?3 IS NULL AND user_id = ?2) OR principal_handle = ?3)",
|
||||||
|
params![storage_owner, external_user, external_principal.map(|principal| principal.0)],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,15 +410,17 @@ pub fn purge_message_in_tx(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
message_id: i64,
|
message_id: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let (external_user, relay_signer_id, relay_message_id) = tx.query_row(
|
let (external_user, external_principal, relay_signer_principal, relay_message_id) = tx.query_row(
|
||||||
"SELECT external_user, relay_signer_id, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2",
|
"SELECT external_user, external_principal, relay_signer_principal, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2",
|
||||||
params![message_id, storage_owner],
|
params![message_id, storage_owner],
|
||||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?, row.get::<_, Option<String>>(2)?)),
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<i64>>(1)?, row.get::<_, Option<i64>>(2)?, row.get::<_, Option<String>>(3)?)),
|
||||||
)?;
|
)?;
|
||||||
if let (Some(relay_signer_id), Some(relay_message_id)) = (relay_signer_id, relay_message_id) {
|
if let (Some(relay_signer_principal), Some(relay_message_id)) =
|
||||||
|
(relay_signer_principal, relay_message_id)
|
||||||
|
{
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_id = ?2 AND target_message_id = ?3",
|
"DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_principal = ?2 AND target_message_id = ?3",
|
||||||
params![storage_owner, relay_signer_id, relay_message_id],
|
params![storage_owner, relay_signer_principal, relay_message_id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
|
|
@ -346,7 +436,12 @@ pub fn purge_message_in_tx(
|
||||||
message_id,
|
message_id,
|
||||||
Operation::Delete,
|
Operation::Delete,
|
||||||
)?;
|
)?;
|
||||||
update_contact_last_message_in_tx(tx, storage_owner, external_user)?;
|
update_contact_last_message_in_tx(
|
||||||
|
tx,
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal.map(iota_identity::PrincipalHandle),
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,8 +451,50 @@ pub fn delete_message(
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
match ensure_message_direction(storage_owner, external_user, message_time, true) {
|
delete_message_with_principal(storage_owner, external_user, None, message_time, true)
|
||||||
Ok(()) => hard_delete_message(storage_owner, external_user, message_time),
|
}
|
||||||
|
|
||||||
|
pub fn delete_message_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
delete_message_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
|
message_time,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_message_with_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
message_time: i64,
|
||||||
|
sent_by_self: bool,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
match ensure_message_direction(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal,
|
||||||
|
message_time,
|
||||||
|
sent_by_self,
|
||||||
|
) {
|
||||||
|
Ok(()) if sent_by_self => hard_delete_message_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal,
|
||||||
|
message_time,
|
||||||
|
),
|
||||||
|
Ok(()) => flag_deleted_by_external_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
external_principal,
|
||||||
|
message_time,
|
||||||
|
),
|
||||||
Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()),
|
Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()),
|
||||||
Err(error) => Err(error),
|
Err(error) => Err(error),
|
||||||
}
|
}
|
||||||
|
|
@ -375,16 +512,34 @@ pub fn apply_remote_delete(
|
||||||
"Remote sender does not match chat partner".into(),
|
"Remote sender does not match chat partner".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
match ensure_message_direction(storage_owner, external_user, message_time, false) {
|
delete_message_with_principal(storage_owner, external_user, None, message_time, false)
|
||||||
Ok(()) => flag_deleted_by_external(storage_owner, external_user, message_time),
|
}
|
||||||
Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()),
|
|
||||||
Err(error) => Err(error),
|
pub fn apply_remote_delete_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
sender_id: i64,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if sender_id != external_user {
|
||||||
|
return Err(StorageError::Other(
|
||||||
|
"Remote sender does not match chat partner".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
delete_message_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
|
message_time,
|
||||||
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_message_direction(
|
fn ensure_message_direction(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
expected_sent_by_self: bool,
|
expected_sent_by_self: bool,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
|
@ -392,10 +547,20 @@ fn ensure_message_direction(
|
||||||
let sent_by_self: i64 = conn.query_row(
|
let sent_by_self: i64 = conn.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT sent_by_self FROM messages
|
SELECT sent_by_self FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
if (sent_by_self != 0) != expected_sent_by_self {
|
if (sent_by_self != 0) != expected_sent_by_self {
|
||||||
|
|
@ -415,6 +580,15 @@ pub fn flag_deleted_by_external(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
flag_deleted_by_external_with_principal(storage_owner, external_user, None, message_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag_deleted_by_external_with_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
message_time: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
@ -422,14 +596,28 @@ pub fn flag_deleted_by_external(
|
||||||
r#"
|
r#"
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET deleted_by_external = 1
|
SET deleted_by_external = 1
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return Err(StorageError::Other("Message not found".into()));
|
return Err(StorageError::Other("Message not found".into()));
|
||||||
}
|
}
|
||||||
let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?;
|
let msg_id: i64 = tx.query_row(
|
||||||
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND message_time = ?3 AND ((?4 IS NULL AND external_user = ?2) OR external_principal = ?4) ORDER BY id DESC LIMIT 1",
|
||||||
|
params![storage_owner, external_user, message_time, external_principal.map(|principal| principal.0)],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
&tx,
|
&tx,
|
||||||
storage_owner,
|
storage_owner,
|
||||||
|
|
@ -437,7 +625,7 @@ pub fn flag_deleted_by_external(
|
||||||
msg_id,
|
msg_id,
|
||||||
Operation::Delete,
|
Operation::Delete,
|
||||||
)?;
|
)?;
|
||||||
update_contact_last_message_in_tx(&tx, storage_owner, external_user)?;
|
update_contact_last_message_in_tx(&tx, storage_owner, external_user, external_principal)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -490,15 +678,65 @@ pub fn add_reaction(
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
reaction: &str,
|
reaction: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
add_reaction_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
None,
|
||||||
|
message_time,
|
||||||
|
user_id,
|
||||||
|
None,
|
||||||
|
reaction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_reaction_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
user_id: i64,
|
||||||
|
user_principal: iota_identity::PrincipalHandle,
|
||||||
|
reaction: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
add_reaction_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
|
message_time,
|
||||||
|
user_id,
|
||||||
|
Some(user_principal),
|
||||||
|
reaction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_reaction_with_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
message_time: i64,
|
||||||
|
user_id: i64,
|
||||||
|
user_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
reaction: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let (msg_id, history_deleted): (i64, i64) = tx.query_row(
|
let (msg_id, history_deleted): (i64, i64) = tx.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, history_deleted FROM messages
|
SELECT id, history_deleted FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
if history_deleted != 0 {
|
if history_deleted != 0 {
|
||||||
|
|
@ -528,10 +766,16 @@ pub fn add_reaction(
|
||||||
|
|
||||||
let inserted = tx.execute(
|
let inserted = tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at)
|
INSERT OR IGNORE INTO reactions (message_id, user_id, user_principal, reaction, created_at)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
"#,
|
"#,
|
||||||
params![msg_id, user_id, reaction, now],
|
params![
|
||||||
|
msg_id,
|
||||||
|
user_id,
|
||||||
|
user_principal.map(|principal| principal.0),
|
||||||
|
reaction,
|
||||||
|
now
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
if inserted > 0 {
|
if inserted > 0 {
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
|
|
@ -554,15 +798,65 @@ pub fn remove_reaction(
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
reaction: &str,
|
reaction: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
remove_reaction_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
None,
|
||||||
|
message_time,
|
||||||
|
user_id,
|
||||||
|
None,
|
||||||
|
reaction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_reaction_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_time: i64,
|
||||||
|
user_id: i64,
|
||||||
|
user_principal: iota_identity::PrincipalHandle,
|
||||||
|
reaction: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
remove_reaction_with_principal(
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
Some(external_principal),
|
||||||
|
message_time,
|
||||||
|
user_id,
|
||||||
|
Some(user_principal),
|
||||||
|
reaction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_reaction_with_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_user: i64,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
message_time: i64,
|
||||||
|
user_id: i64,
|
||||||
|
user_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
|
reaction: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let (msg_id, history_deleted): (i64, i64) = conn.query_row(
|
let (msg_id, history_deleted): (i64, i64) = conn.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, history_deleted FROM messages
|
SELECT id, history_deleted FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3
|
WHERE storage_owner = ?1
|
||||||
|
AND message_time = ?3
|
||||||
|
AND (
|
||||||
|
(?4 IS NULL AND external_user = ?2)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, external_user, message_time],
|
params![
|
||||||
|
storage_owner,
|
||||||
|
external_user,
|
||||||
|
message_time,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
if history_deleted != 0 {
|
if history_deleted != 0 {
|
||||||
|
|
@ -571,8 +865,13 @@ pub fn remove_reaction(
|
||||||
|
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3",
|
"DELETE FROM reactions WHERE message_id = ?1 AND ((?3 IS NULL AND user_id = ?2) OR user_principal = ?3) AND reaction = ?4",
|
||||||
params![msg_id, user_id, reaction],
|
params![
|
||||||
|
msg_id,
|
||||||
|
user_id,
|
||||||
|
user_principal.map(|principal| principal.0),
|
||||||
|
reaction
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
&tx,
|
&tx,
|
||||||
|
|
@ -589,11 +888,13 @@ pub fn remove_reaction(
|
||||||
pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
let NewMessage {
|
let NewMessage {
|
||||||
relay_signer_id,
|
relay_signer_id,
|
||||||
|
relay_signer_principal,
|
||||||
relay_message_id,
|
relay_message_id,
|
||||||
authored_at,
|
authored_at,
|
||||||
send_time,
|
send_time,
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal,
|
||||||
sent_by_self,
|
sent_by_self,
|
||||||
content,
|
content,
|
||||||
height,
|
height,
|
||||||
|
|
@ -620,8 +921,9 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
storage_owner, external_user, message_time, content, sent_by_self,
|
storage_owner, external_user, message_time, content, sent_by_self,
|
||||||
message_state, height, key_version, reply_to, relay_signer_id, relay_message_id,
|
message_state, height, key_version, reply_to, relay_signer_id, relay_message_id,
|
||||||
authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, expires_at
|
authored_at, origin_iota_received_at, destination_iota_received_at, stored_at,
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
|
expires_at, external_principal, relay_signer_principal
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||||
"#,
|
"#,
|
||||||
params![
|
params![
|
||||||
storage_owner,
|
storage_owner,
|
||||||
|
|
@ -640,6 +942,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
destination_iota_received_at,
|
destination_iota_received_at,
|
||||||
stored_at,
|
stored_at,
|
||||||
expires_at,
|
expires_at,
|
||||||
|
external_principal.0,
|
||||||
|
relay_signer_principal.0,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let msg_id = tx.last_insert_rowid();
|
let msg_id = tx.last_insert_rowid();
|
||||||
|
|
@ -650,7 +954,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
msg_id,
|
msg_id,
|
||||||
Operation::Upsert,
|
Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
let mut contact = crate::users::contact::Contact::new(external_user);
|
let mut contact =
|
||||||
|
crate::users::contact::Contact::for_principal(external_user, external_principal);
|
||||||
contact.set_last_message_at(
|
contact.set_last_message_at(
|
||||||
destination_iota_received_at
|
destination_iota_received_at
|
||||||
.or(origin_iota_received_at)
|
.or(origin_iota_received_at)
|
||||||
|
|
@ -659,8 +964,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
crate::util::chats_util::upsert_contact(&tx, storage_owner, &contact)?;
|
crate::util::chats_util::upsert_contact(&tx, storage_owner, &contact)?;
|
||||||
if !sent_by_self {
|
if !sent_by_self {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE contacts SET notifications = CASE WHEN notifications < 9223372036854775807 THEN notifications + 1 ELSE notifications END WHERE storage_owner = ?1 AND user_id = ?2",
|
"UPDATE contacts SET notifications = CASE WHEN notifications < 9223372036854775807 THEN notifications + 1 ELSE notifications END WHERE storage_owner = ?1 AND principal_handle = ?2",
|
||||||
params![storage_owner, external_user],
|
params![storage_owner, external_principal.0],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|
@ -670,7 +975,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
|
||||||
|
|
||||||
pub fn change_message_state_by_relay_id(
|
pub fn change_message_state_by_relay_id(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
relay_signer_id: i64,
|
_relay_signer_id: i64,
|
||||||
|
relay_signer_principal: iota_identity::PrincipalHandle,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
new_state: MessageState,
|
new_state: MessageState,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
|
@ -678,8 +984,8 @@ pub fn change_message_state_by_relay_id(
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let Some((msg_id, current)) = tx
|
let Some((msg_id, current)) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
params![storage_owner, relay_signer_id, relay_message_id],
|
params![storage_owner, relay_signer_principal.0, relay_message_id],
|
||||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|
@ -706,8 +1012,10 @@ pub fn change_message_state_by_relay_id(
|
||||||
pub fn record_message_receipt(
|
pub fn record_message_receipt(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
target_signer_id: i64,
|
target_signer_id: i64,
|
||||||
|
target_signer_principal: iota_identity::PrincipalHandle,
|
||||||
target_message_id: &str,
|
target_message_id: &str,
|
||||||
receipt_signer_id: i64,
|
receipt_signer_id: i64,
|
||||||
|
receipt_signer_principal: iota_identity::PrincipalHandle,
|
||||||
receipt_message_id: &str,
|
receipt_message_id: &str,
|
||||||
receipt_type: MessageState,
|
receipt_type: MessageState,
|
||||||
event_at: i64,
|
event_at: i64,
|
||||||
|
|
@ -720,17 +1028,17 @@ pub fn record_message_receipt(
|
||||||
};
|
};
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let Some((message_id, external_user, authored_at, history_deleted)) = tx
|
let Some((message_id, external_principal, authored_at, history_deleted)) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, external_user, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id, external_principal, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
params![storage_owner, target_signer_id, target_message_id],
|
params![storage_owner, target_signer_principal.0, target_message_id],
|
||||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<i64>>(2)?, row.get::<_, i64>(3)?)),
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<i64>>(2)?, row.get::<_, i64>(3)?)),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
else {
|
else {
|
||||||
return Err(StorageError::Other("message receipt target was not found".into()));
|
return Err(StorageError::Other("message receipt target was not found".into()));
|
||||||
};
|
};
|
||||||
if external_user != receipt_signer_id {
|
if external_principal != receipt_signer_principal.0 {
|
||||||
return Err(StorageError::Other(
|
return Err(StorageError::Other(
|
||||||
"message receipt signer is not the chat partner".into(),
|
"message receipt signer is not the chat partner".into(),
|
||||||
));
|
));
|
||||||
|
|
@ -746,8 +1054,8 @@ pub fn record_message_receipt(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
"INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_signer_principal, target_message_id, receipt_signer_id, receipt_signer_principal, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at],
|
params![storage_owner, target_signer_id, target_signer_principal.0, target_message_id, receipt_signer_id, receipt_signer_principal.0, receipt_message_id, receipt_type, event_at, recorded_at],
|
||||||
)?;
|
)?;
|
||||||
let (state_column, recorded_column) = if receipt_type == "read" {
|
let (state_column, recorded_column) = if receipt_type == "read" {
|
||||||
("read_at", "read_recorded_at")
|
("read_at", "read_recorded_at")
|
||||||
|
|
@ -780,7 +1088,7 @@ pub fn record_message_receipt(
|
||||||
|
|
||||||
pub fn record_destination_iota_received(
|
pub fn record_destination_iota_received(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
relay_signer_id: i64,
|
relay_signer_principal: iota_identity::PrincipalHandle,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
accepted_at: i64,
|
accepted_at: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
|
@ -788,8 +1096,8 @@ pub fn record_destination_iota_received(
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let Some(message_id) = tx
|
let Some(message_id) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
params![storage_owner, relay_signer_id, relay_message_id],
|
params![storage_owner, relay_signer_principal.0, relay_message_id],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|
@ -814,7 +1122,7 @@ pub fn record_destination_iota_received(
|
||||||
|
|
||||||
pub fn record_delivery_failure(
|
pub fn record_delivery_failure(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
relay_signer_id: i64,
|
relay_signer_principal: iota_identity::PrincipalHandle,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
failure: &str,
|
failure: &str,
|
||||||
failed_at: i64,
|
failed_at: i64,
|
||||||
|
|
@ -823,8 +1131,8 @@ pub fn record_delivery_failure(
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
let Some(message_id) = tx
|
let Some(message_id) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
params![storage_owner, relay_signer_id, relay_message_id],
|
params![storage_owner, relay_signer_principal.0, relay_message_id],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|
@ -957,6 +1265,20 @@ pub fn get_messages(
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
loaded_messages: i64,
|
loaded_messages: i64,
|
||||||
amount: i64,
|
amount: i64,
|
||||||
|
) -> Result<Vec<StoredMessage>, StorageError> {
|
||||||
|
let Some(principal) = crate::util::chats_util::get_user(storage_owner, external_user)?
|
||||||
|
.and_then(|contact| contact.principal)
|
||||||
|
else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
get_messages_for_principal(storage_owner, principal, loaded_messages, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_messages_for_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
external_principal: iota_identity::PrincipalHandle,
|
||||||
|
loaded_messages: i64,
|
||||||
|
amount: i64,
|
||||||
) -> Result<Vec<StoredMessage>, StorageError> {
|
) -> Result<Vec<StoredMessage>, StorageError> {
|
||||||
if amount <= 0 || loaded_messages < 0 {
|
if amount <= 0 || loaded_messages < 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
|
|
@ -969,21 +1291,25 @@ pub fn get_messages(
|
||||||
origin_iota_received_at, destination_iota_received_at,
|
origin_iota_received_at, destination_iota_received_at,
|
||||||
client_received_at, client_received_recorded_at, read_at,
|
client_received_at, client_received_recorded_at, read_at,
|
||||||
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
|
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
|
||||||
reply_to, edited_count
|
reply_to, edited_count, external_user, relay_signer_principal
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0
|
WHERE storage_owner = ?1 AND external_principal = ?2 AND deleted_by_external = 0 AND history_deleted = 0
|
||||||
ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC
|
ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC
|
||||||
LIMIT ?3 OFFSET ?4
|
LIMIT ?3 OFFSET ?4
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let rows = stmt.query_map(
|
let rows = stmt.query_map(
|
||||||
params![storage_owner, external_user, amount, loaded_messages],
|
params![storage_owner, external_principal.0, amount, loaded_messages],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(StoredMessage {
|
Ok(StoredMessage {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
external_user,
|
external_user: row.get(20)?,
|
||||||
|
external_principal: Some(external_principal),
|
||||||
relay_signer_id: row.get(1)?,
|
relay_signer_id: row.get(1)?,
|
||||||
|
relay_signer_principal: row
|
||||||
|
.get::<_, Option<i64>>(21)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_message_id: row.get(2)?,
|
relay_message_id: row.get(2)?,
|
||||||
message_time: row.get(3)?,
|
message_time: row.get(3)?,
|
||||||
authored_at: row.get(4)?,
|
authored_at: row.get(4)?,
|
||||||
|
|
@ -1026,6 +1352,7 @@ pub fn get_message(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
external_user: Option<i64>,
|
external_user: Option<i64>,
|
||||||
|
external_principal: Option<iota_identity::PrincipalHandle>,
|
||||||
) -> Result<Option<StoredMessage>, StorageError> {
|
) -> Result<Option<StoredMessage>, StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
|
|
@ -1034,21 +1361,36 @@ pub fn get_message(
|
||||||
origin_iota_received_at, destination_iota_received_at,
|
origin_iota_received_at, destination_iota_received_at,
|
||||||
client_received_at, client_received_recorded_at, read_at,
|
client_received_at, client_received_recorded_at, read_at,
|
||||||
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
|
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
|
||||||
reply_to, edited_count, external_user
|
reply_to, edited_count, external_user, external_principal,
|
||||||
|
relay_signer_principal
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE storage_owner = ?1
|
WHERE storage_owner = ?1
|
||||||
AND message_time = ?2
|
AND message_time = ?2
|
||||||
AND deleted_by_external = 0
|
AND deleted_by_external = 0
|
||||||
AND history_deleted = 0
|
AND history_deleted = 0
|
||||||
AND (?3 IS NULL OR external_user = ?3)
|
AND (
|
||||||
|
?3 IS NULL
|
||||||
|
OR (?4 IS NULL AND external_user = ?3)
|
||||||
|
OR external_principal = ?4
|
||||||
|
)
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| {
|
let rows = stmt.query_map(
|
||||||
|
params![
|
||||||
|
storage_owner,
|
||||||
|
message_time,
|
||||||
|
external_user,
|
||||||
|
external_principal.map(|principal| principal.0)
|
||||||
|
],
|
||||||
|
|row| {
|
||||||
Ok(StoredMessage {
|
Ok(StoredMessage {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
relay_signer_id: row.get(1)?,
|
relay_signer_id: row.get(1)?,
|
||||||
|
relay_signer_principal: row
|
||||||
|
.get::<_, Option<i64>>(22)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_message_id: row.get(2)?,
|
relay_message_id: row.get(2)?,
|
||||||
message_time: row.get(3)?,
|
message_time: row.get(3)?,
|
||||||
authored_at: row.get(4)?,
|
authored_at: row.get(4)?,
|
||||||
|
|
@ -1068,9 +1410,13 @@ pub fn get_message(
|
||||||
reply_to: row.get(18)?,
|
reply_to: row.get(18)?,
|
||||||
edited: row.get::<_, i64>(19)? > 0,
|
edited: row.get::<_, i64>(19)? > 0,
|
||||||
external_user: row.get(20)?,
|
external_user: row.get(20)?,
|
||||||
|
external_principal: row
|
||||||
|
.get::<_, Option<i64>>(21)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
reactions: Vec::new(),
|
reactions: Vec::new(),
|
||||||
})
|
})
|
||||||
})?;
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let messages: Vec<StoredMessage> = rows.collect::<Result<_, _>>()?;
|
let messages: Vec<StoredMessage> = rows.collect::<Result<_, _>>()?;
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
|
|
@ -1079,7 +1425,7 @@ pub fn get_message(
|
||||||
if external_user.is_none()
|
if external_user.is_none()
|
||||||
&& messages
|
&& messages
|
||||||
.iter()
|
.iter()
|
||||||
.map(|message| message.external_user)
|
.filter_map(|message| message.external_principal)
|
||||||
.collect::<std::collections::HashSet<_>>()
|
.collect::<std::collections::HashSet<_>>()
|
||||||
.len()
|
.len()
|
||||||
> 1
|
> 1
|
||||||
|
|
@ -1099,7 +1445,18 @@ pub fn get_message_with_offset(
|
||||||
external_user: i64,
|
external_user: i64,
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
) -> Result<Option<(StoredMessage, i64)>, StorageError> {
|
) -> Result<Option<(StoredMessage, i64)>, StorageError> {
|
||||||
let Some(message) = get_message(storage_owner, message_time, Some(external_user))? else {
|
let Some(external_principal) = crate::util::chats_util::get_user(storage_owner, external_user)?
|
||||||
|
.and_then(|contact| contact.principal)
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(message) = get_message(
|
||||||
|
storage_owner,
|
||||||
|
message_time,
|
||||||
|
Some(external_user),
|
||||||
|
Some(external_principal),
|
||||||
|
)?
|
||||||
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let offset = db::with_db(|conn| {
|
let offset = db::with_db(|conn| {
|
||||||
|
|
@ -1108,7 +1465,7 @@ pub fn get_message_with_offset(
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE storage_owner = ?1
|
WHERE storage_owner = ?1
|
||||||
AND external_user = ?2
|
AND external_principal = ?2
|
||||||
AND deleted_by_external = 0
|
AND deleted_by_external = 0
|
||||||
AND history_deleted = 0
|
AND history_deleted = 0
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -1120,7 +1477,7 @@ pub fn get_message_with_offset(
|
||||||
"#,
|
"#,
|
||||||
params![
|
params![
|
||||||
storage_owner,
|
storage_owner,
|
||||||
external_user,
|
external_principal.0,
|
||||||
message.destination_iota_received_at.or(message.origin_iota_received_at).or(message.authored_at),
|
message.destination_iota_received_at.or(message.origin_iota_received_at).or(message.authored_at),
|
||||||
message.id
|
message.id
|
||||||
],
|
],
|
||||||
|
|
@ -1142,13 +1499,19 @@ pub fn get_messages_by_ids(
|
||||||
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
|
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
|
||||||
// those ids; this keeps reaction hydration identical to normal message loading.
|
// those ids; this keeps reaction hydration identical to normal message loading.
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?;
|
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, reply_to, edited_count, external_user, external_principal, relay_signer_principal FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?;
|
||||||
let rows = stmt.query_map([storage_owner], |row| {
|
let rows = stmt.query_map([storage_owner], |row| {
|
||||||
let external_user: i64 = row.get(20)?;
|
let external_user: i64 = row.get(20)?;
|
||||||
Ok(StoredMessage {
|
Ok(StoredMessage {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
external_user,
|
external_user,
|
||||||
|
external_principal: row
|
||||||
|
.get::<_, Option<i64>>(21)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_signer_id: row.get(1)?,
|
relay_signer_id: row.get(1)?,
|
||||||
|
relay_signer_principal: row
|
||||||
|
.get::<_, Option<i64>>(22)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_message_id: row.get(2)?,
|
relay_message_id: row.get(2)?,
|
||||||
message_time: row.get(3)?,
|
message_time: row.get(3)?,
|
||||||
authored_at: row.get(4)?,
|
authored_at: row.get(4)?,
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,31 @@ pub(crate) fn upsert_contact(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
contact: &Contact,
|
contact: &Contact,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
let principal = match contact.principal {
|
||||||
|
Some(principal) => principal,
|
||||||
|
None => iota_identity::PrincipalHandle(tx.query_row(
|
||||||
|
r#"
|
||||||
|
SELECT p.principal_pk
|
||||||
|
FROM principals p
|
||||||
|
WHERE p.authority_id = COALESCE(
|
||||||
|
(SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1),
|
||||||
|
'omega:central'
|
||||||
|
)
|
||||||
|
AND p.remote_user_id = ?1
|
||||||
|
"#,
|
||||||
|
[contact.user_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?),
|
||||||
|
};
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO contacts (
|
INSERT INTO contacts (
|
||||||
storage_owner, user_id, user_name, created_at, last_message_at,
|
storage_owner, user_id, principal_handle, user_name, created_at, last_message_at,
|
||||||
notifications, notifications_read_at
|
notifications, notifications_read_at
|
||||||
)
|
)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(storage_owner, user_id) DO UPDATE SET
|
ON CONFLICT(storage_owner, principal_handle) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
user_name = COALESCE(excluded.user_name, contacts.user_name),
|
user_name = COALESCE(excluded.user_name, contacts.user_name),
|
||||||
created_at = MIN(contacts.created_at, excluded.created_at),
|
created_at = MIN(contacts.created_at, excluded.created_at),
|
||||||
last_message_at = CASE
|
last_message_at = CASE
|
||||||
|
|
@ -28,6 +45,7 @@ pub(crate) fn upsert_contact(
|
||||||
params![
|
params![
|
||||||
storage_owner,
|
storage_owner,
|
||||||
contact.user_id,
|
contact.user_id,
|
||||||
|
principal.0,
|
||||||
contact.user_name,
|
contact.user_name,
|
||||||
contact.created_at,
|
contact.created_at,
|
||||||
contact.last_message_at,
|
contact.last_message_at,
|
||||||
|
|
@ -39,63 +57,112 @@ pub(crate) fn upsert_contact(
|
||||||
tx,
|
tx,
|
||||||
storage_owner,
|
storage_owner,
|
||||||
EntityType::Contact,
|
EntityType::Contact,
|
||||||
contact.user_id,
|
principal.0,
|
||||||
Operation::Upsert,
|
Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_user(storage_owner: i64, user_id: i64) -> Result<bool, StorageError> {
|
pub fn has_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
principal: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<bool, StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
Ok(conn.query_row(
|
Ok(conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND user_id = ?2)",
|
"SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND principal_handle = ?2)",
|
||||||
params![storage_owner, user_id],
|
params![storage_owner, principal.0],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?)
|
)?)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_user(storage_owner: i64, user_id: i64) -> Result<bool, StorageError> {
|
||||||
|
get_user(storage_owner, user_id).map(|contact| contact.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> {
|
pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> {
|
||||||
db::with_immediate_transaction(|tx| upsert_contact(tx, storage_owner, contact))
|
db::with_immediate_transaction(|tx| upsert_contact(tx, storage_owner, contact))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Result<Vec<Contact>, StorageError> {
|
pub fn get_users_by_principals(
|
||||||
if ids.is_empty() {
|
storage_owner: i64,
|
||||||
|
principals: &[iota_identity::PrincipalHandle],
|
||||||
|
) -> Result<Vec<Contact>, StorageError> {
|
||||||
|
if principals.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
let wanted: std::collections::HashSet<iota_identity::PrincipalHandle> =
|
||||||
|
principals.iter().copied().collect();
|
||||||
Ok(get_users(storage_owner)?
|
Ok(get_users(storage_owner)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|contact| wanted.contains(&contact.user_id))
|
.filter(|contact| {
|
||||||
|
contact
|
||||||
|
.principal
|
||||||
|
.is_some_and(|principal| wanted.contains(&principal))
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_user(storage_owner: i64, user_id: i64) -> Result<Option<Contact>, StorageError> {
|
pub fn get_user(storage_owner: i64, user_id: i64) -> Result<Option<Contact>, StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
match conn.query_row(
|
let mut statement = conn.prepare(
|
||||||
r#"
|
r#"
|
||||||
SELECT user_id, user_name, created_at, last_message_at,
|
SELECT user_id, principal_handle, user_name, created_at, last_message_at,
|
||||||
notifications, notifications_read_at
|
notifications, notifications_read_at
|
||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE storage_owner = ?1 AND user_id = ?2
|
WHERE storage_owner = ?1 AND user_id = ?2
|
||||||
LIMIT 1
|
LIMIT 2
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, user_id],
|
)?;
|
||||||
|r| {
|
let contacts = statement
|
||||||
|
.query_map(params![storage_owner, user_id], |r| {
|
||||||
Ok(Contact {
|
Ok(Contact {
|
||||||
user_id: r.get(0)?,
|
user_id: r.get(0)?,
|
||||||
user_name: r.get(1)?,
|
principal: r
|
||||||
created_at: r.get(2)?,
|
.get::<_, Option<i64>>(1)?
|
||||||
last_message_at: r.get(3)?,
|
.map(iota_identity::PrincipalHandle),
|
||||||
notifications: r.get(4)?,
|
user_name: r.get(2)?,
|
||||||
notifications_read_at: r.get(5)?,
|
created_at: r.get(3)?,
|
||||||
|
last_message_at: r.get(4)?,
|
||||||
|
notifications: r.get(5)?,
|
||||||
|
notifications_read_at: r.get(6)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
match contacts.as_slice() {
|
||||||
|
[contact] => Ok(Some(contact.clone())),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_by_principal(
|
||||||
|
storage_owner: i64,
|
||||||
|
principal: iota_identity::PrincipalHandle,
|
||||||
|
) -> Result<Option<Contact>, StorageError> {
|
||||||
|
db::with_db(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, principal_handle, user_name, created_at, last_message_at,
|
||||||
|
notifications, notifications_read_at
|
||||||
|
FROM contacts
|
||||||
|
WHERE storage_owner = ?1 AND principal_handle = ?2
|
||||||
|
"#,
|
||||||
|
params![storage_owner, principal.0],
|
||||||
|
|row| {
|
||||||
|
Ok(Contact {
|
||||||
|
user_id: row.get(0)?,
|
||||||
|
principal: Some(iota_identity::PrincipalHandle(row.get(1)?)),
|
||||||
|
user_name: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
last_message_at: row.get(4)?,
|
||||||
|
notifications: row.get(5)?,
|
||||||
|
notifications_read_at: row.get(6)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
) {
|
)
|
||||||
Ok(c) => Ok(Some(c)),
|
.optional()
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
.map_err(Into::into)
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +170,7 @@ pub fn get_users(storage_owner: i64) -> Result<Vec<Contact>, StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"
|
r#"
|
||||||
SELECT user_id, user_name, created_at, last_message_at,
|
SELECT user_id, principal_handle, user_name, created_at, last_message_at,
|
||||||
notifications, notifications_read_at
|
notifications, notifications_read_at
|
||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE storage_owner = ?1
|
WHERE storage_owner = ?1
|
||||||
|
|
@ -117,11 +184,14 @@ pub fn get_users(storage_owner: i64) -> Result<Vec<Contact>, StorageError> {
|
||||||
let rows = stmt.query_map(params![storage_owner], |r| {
|
let rows = stmt.query_map(params![storage_owner], |r| {
|
||||||
Ok(Contact {
|
Ok(Contact {
|
||||||
user_id: r.get(0)?,
|
user_id: r.get(0)?,
|
||||||
user_name: r.get(1)?,
|
principal: r
|
||||||
created_at: r.get(2)?,
|
.get::<_, Option<i64>>(1)?
|
||||||
last_message_at: r.get(3)?,
|
.map(iota_identity::PrincipalHandle),
|
||||||
notifications: r.get(4)?,
|
user_name: r.get(2)?,
|
||||||
notifications_read_at: r.get(5)?,
|
created_at: r.get(3)?,
|
||||||
|
last_message_at: r.get(4)?,
|
||||||
|
notifications: r.get(5)?,
|
||||||
|
notifications_read_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -135,14 +205,14 @@ pub fn get_users(storage_owner: i64) -> Result<Vec<Contact>, StorageError> {
|
||||||
|
|
||||||
pub fn read_notifications(
|
pub fn read_notifications(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
user_id: i64,
|
principal: iota_identity::PrincipalHandle,
|
||||||
through: i64,
|
through: i64,
|
||||||
) -> Result<Option<Contact>, StorageError> {
|
) -> Result<Option<Contact>, StorageError> {
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let Some(current_read_at) = tx
|
let Some(current_read_at) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT notifications_read_at FROM contacts WHERE storage_owner = ?1 AND user_id = ?2",
|
"SELECT notifications_read_at FROM contacts WHERE storage_owner = ?1 AND principal_handle = ?2",
|
||||||
params![storage_owner, user_id],
|
params![storage_owner, principal.0],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|
@ -155,43 +225,46 @@ pub fn read_notifications(
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE storage_owner = ?1
|
WHERE storage_owner = ?1
|
||||||
AND external_user = ?2
|
AND external_principal = ?2
|
||||||
AND sent_by_self = 0
|
AND sent_by_self = 0
|
||||||
AND deleted_by_external = 0
|
AND deleted_by_external = 0
|
||||||
AND history_deleted = 0
|
AND history_deleted = 0
|
||||||
AND COALESCE(destination_iota_received_at, stored_at, authored_at, message_time) > ?3
|
AND COALESCE(destination_iota_received_at, stored_at, authored_at, message_time) > ?3
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, user_id, read_at],
|
params![storage_owner, principal.0, read_at],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE contacts SET notifications = ?3, notifications_read_at = ?4 WHERE storage_owner = ?1 AND user_id = ?2",
|
"UPDATE contacts SET notifications = ?3, notifications_read_at = ?4 WHERE storage_owner = ?1 AND principal_handle = ?2",
|
||||||
params![storage_owner, user_id, notifications, read_at],
|
params![storage_owner, principal.0, notifications, read_at],
|
||||||
)?;
|
)?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
storage_owner,
|
storage_owner,
|
||||||
EntityType::Contact,
|
EntityType::Contact,
|
||||||
user_id,
|
principal.0,
|
||||||
Operation::Upsert,
|
Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Some(tx.query_row(
|
Ok(Some(tx.query_row(
|
||||||
r#"
|
r#"
|
||||||
SELECT user_id, user_name, created_at, last_message_at,
|
SELECT user_id, principal_handle, user_name, created_at, last_message_at,
|
||||||
notifications, notifications_read_at
|
notifications, notifications_read_at
|
||||||
FROM contacts
|
FROM contacts
|
||||||
WHERE storage_owner = ?1 AND user_id = ?2
|
WHERE storage_owner = ?1 AND principal_handle = ?2
|
||||||
"#,
|
"#,
|
||||||
params![storage_owner, user_id],
|
params![storage_owner, principal.0],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(Contact {
|
Ok(Contact {
|
||||||
user_id: row.get(0)?,
|
user_id: row.get(0)?,
|
||||||
user_name: row.get(1)?,
|
principal: row
|
||||||
created_at: row.get(2)?,
|
.get::<_, Option<i64>>(1)?
|
||||||
last_message_at: row.get(3)?,
|
.map(iota_identity::PrincipalHandle),
|
||||||
notifications: row.get(4)?,
|
user_name: row.get(2)?,
|
||||||
notifications_read_at: row.get(5)?,
|
created_at: row.get(3)?,
|
||||||
|
last_message_at: row.get(4)?,
|
||||||
|
notifications: row.get(5)?,
|
||||||
|
notifications_read_at: row.get(6)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)?))
|
)?))
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ pub fn acknowledge_client_delivery(
|
||||||
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
|
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
|
||||||
{
|
{
|
||||||
let message_id = tx.query_row(
|
let message_id = tx.query_row(
|
||||||
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
rusqlite::params![destination_id, relay.signer_id, relay.message_id],
|
rusqlite::params![destination_id, relay.signer.0, relay.message_id],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
).optional()?;
|
).optional()?;
|
||||||
if let Some(message_id) = message_id {
|
if let Some(message_id) = message_id {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,16 @@ pub enum ConfigError {
|
||||||
#[source]
|
#[source]
|
||||||
source: std::net::AddrParseError,
|
source: std::net::AddrParseError,
|
||||||
},
|
},
|
||||||
|
#[error("invalid federation endpoint {endpoint:?}: {source}")]
|
||||||
|
InvalidFederationEndpoint {
|
||||||
|
endpoint: String,
|
||||||
|
#[source]
|
||||||
|
source: iota_identity::IdentityError,
|
||||||
|
},
|
||||||
|
#[error("invalid relay router public key: {0}")]
|
||||||
|
InvalidRelayRouterKey(String),
|
||||||
|
#[error("relay router certificate path must not be empty")]
|
||||||
|
MissingRelayRouterCertificate,
|
||||||
#[error("max_ipc_clients must be greater than zero")]
|
#[error("max_ipc_clients must be greater than zero")]
|
||||||
InvalidMaxIpcClients,
|
InvalidMaxIpcClients,
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +53,8 @@ pub struct IotaConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: WebSettings,
|
pub web: WebSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay_routers: Vec<RelayRouterSettings>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub omikron_host: Option<String>,
|
pub omikron_host: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -61,6 +73,14 @@ pub struct IotaConfig {
|
||||||
pub max_ipc_clients: usize,
|
pub max_ipc_clients: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RelayRouterSettings {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub certificate: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WebMode {
|
pub enum WebMode {
|
||||||
|
|
@ -89,6 +109,10 @@ pub struct WebSettings {
|
||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub direct_endpoints: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay_hints: Vec<String>,
|
||||||
}
|
}
|
||||||
fn default_web_bind() -> String {
|
fn default_web_bind() -> String {
|
||||||
"127.0.0.1".into()
|
"127.0.0.1".into()
|
||||||
|
|
@ -106,6 +130,8 @@ impl Default for WebSettings {
|
||||||
certificate: None,
|
certificate: None,
|
||||||
key: None,
|
key: None,
|
||||||
required: false,
|
required: false,
|
||||||
|
direct_endpoints: Vec::new(),
|
||||||
|
relay_hints: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +154,7 @@ impl Default for IotaConfig {
|
||||||
iota_id: None,
|
iota_id: None,
|
||||||
port: default_port(),
|
port: default_port(),
|
||||||
web: WebSettings::default(),
|
web: WebSettings::default(),
|
||||||
|
relay_routers: Vec::new(),
|
||||||
omikron_host: None,
|
omikron_host: None,
|
||||||
omikron_port: None,
|
omikron_port: None,
|
||||||
omikron_id: None,
|
omikron_id: None,
|
||||||
|
|
@ -187,6 +214,32 @@ pub fn validate_config(config: &IotaConfig) -> Result<(), ConfigError> {
|
||||||
if config.max_ipc_clients == 0 {
|
if config.max_ipc_clients == 0 {
|
||||||
return Err(ConfigError::InvalidMaxIpcClients);
|
return Err(ConfigError::InvalidMaxIpcClients);
|
||||||
}
|
}
|
||||||
|
for endpoint in config
|
||||||
|
.web
|
||||||
|
.direct_endpoints
|
||||||
|
.iter()
|
||||||
|
.chain(&config.web.relay_hints)
|
||||||
|
{
|
||||||
|
iota_identity::AuthorityLocator::new(endpoint.clone()).map_err(|source| {
|
||||||
|
ConfigError::InvalidFederationEndpoint {
|
||||||
|
endpoint: endpoint.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
for router in &config.relay_routers {
|
||||||
|
iota_identity::AuthorityLocator::new(router.endpoint.clone()).map_err(|source| {
|
||||||
|
ConfigError::InvalidFederationEndpoint {
|
||||||
|
endpoint: router.endpoint.clone(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
iota_identity::PublicKeyBundle::from_base64(&router.public_key)
|
||||||
|
.map_err(|error| ConfigError::InvalidRelayRouterKey(error.to_string()))?;
|
||||||
|
if router.certificate.trim().is_empty() {
|
||||||
|
return Err(ConfigError::MissingRelayRouterCertificate);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,7 +354,7 @@ pub fn configure_config_path(path: PathBuf) {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ConfigError, IotaConfig, parse_config, validate_config};
|
use super::{ConfigError, IotaConfig, RelayRouterSettings, parse_config, validate_config};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -339,4 +392,34 @@ mod tests {
|
||||||
Err(ConfigError::InvalidMaxIpcClients)
|
Err(ConfigError::InvalidMaxIpcClients)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relay_router_requires_valid_explicit_trust_pins() {
|
||||||
|
let mut config = IotaConfig::default();
|
||||||
|
config.relay_routers.push(RelayRouterSettings {
|
||||||
|
endpoint: "router.example:1984".into(),
|
||||||
|
public_key: "invalid".into(),
|
||||||
|
certificate: "router.pem".into(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
validate_config(&config),
|
||||||
|
Err(ConfigError::InvalidRelayRouterKey(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relay_router_configuration_parses_with_all_trust_material() {
|
||||||
|
let public_key = mtp::crypto::Keyring::generate()
|
||||||
|
.public_key_bundle()
|
||||||
|
.try_to_base64()
|
||||||
|
.unwrap();
|
||||||
|
let config = parse_config(
|
||||||
|
Path::new("config.yaml"),
|
||||||
|
&format!(
|
||||||
|
"relay_routers:\n - endpoint: router.example:1984\n public_key: {public_key}\n certificate: router.pem\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.relay_routers.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,65 +4,85 @@ use crate::util::{db, sync};
|
||||||
use rusqlite::{OptionalExtension, params};
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
pub fn acknowledge_iota_delivery(
|
pub fn acknowledge_iota_delivery(
|
||||||
destination_iota: u64,
|
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
signer_id: i64,
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
_legacy_signer_id: Option<i64>,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
destination_accepted_at: i64,
|
destination_accepted_at: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let destination_iota = i64::try_from(destination_iota)
|
|
||||||
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?;
|
let pending_id = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM pending_relays WHERE signer_principal = ?1 AND frame_id = ?2",
|
||||||
|
params![signer_principal.0, i64::from(frame_id)],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
let Some(pending_id) = pending_id else {
|
let Some(pending_id) = pending_id else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?;
|
let message = tx
|
||||||
if let Some(message_id) = message_id {
|
.query_row(
|
||||||
|
"SELECT id, storage_owner FROM messages WHERE relay_signer_principal = ?1 AND relay_message_id = ?2",
|
||||||
|
params![signer_principal.0, relay_message_id],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((message_id, storage_owner)) = message {
|
||||||
tx.execute("UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![destination_accepted_at, message_id])?;
|
tx.execute("UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![destination_accepted_at, message_id])?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
signer_id,
|
storage_owner,
|
||||||
sync::EntityType::Message,
|
sync::EntityType::Message,
|
||||||
message_id,
|
message_id,
|
||||||
sync::Operation::Upsert,
|
sync::Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?;
|
tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, sync::now_millis()])?;
|
||||||
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
|
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reject_iota_delivery(
|
pub fn reject_iota_delivery(
|
||||||
destination_iota: u64,
|
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
signer_id: i64,
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
_legacy_signer_id: Option<i64>,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
failure: &str,
|
failure: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let destination_iota = i64::try_from(destination_iota)
|
|
||||||
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?;
|
let pending_id = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM pending_relays WHERE signer_principal = ?1 AND frame_id = ?2",
|
||||||
|
params![signer_principal.0, i64::from(frame_id)],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
let Some(pending_id) = pending_id else {
|
let Some(pending_id) = pending_id else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?;
|
let message = tx
|
||||||
if let Some(message_id) = message_id {
|
.query_row(
|
||||||
|
"SELECT id, storage_owner FROM messages WHERE relay_signer_principal = ?1 AND relay_message_id = ?2",
|
||||||
|
params![signer_principal.0, relay_message_id],
|
||||||
|
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((message_id, storage_owner)) = message {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3",
|
"UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3",
|
||||||
params![sync::now_millis(), failure, message_id],
|
params![sync::now_millis(), failure, message_id],
|
||||||
)?;
|
)?;
|
||||||
sync::record_event(
|
sync::record_event(
|
||||||
tx,
|
tx,
|
||||||
signer_id,
|
storage_owner,
|
||||||
sync::EntityType::Message,
|
sync::EntityType::Message,
|
||||||
message_id,
|
message_id,
|
||||||
sync::Operation::Upsert,
|
sync::Operation::Upsert,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?;
|
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, sync::now_millis()])?;
|
||||||
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
|
tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,68 @@ pub struct ChatSecretQuery {
|
||||||
pub version: Option<i64>,
|
pub version: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn principal_chat_id(
|
||||||
|
first: iota_identity::PrincipalHandle,
|
||||||
|
second: iota_identity::PrincipalHandle,
|
||||||
|
) -> Option<String> {
|
||||||
|
if first.0 <= 0 || second.0 <= 0 || first == second {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (first, second) = if first.0 < second.0 {
|
||||||
|
(first.0, second.0)
|
||||||
|
} else {
|
||||||
|
(second.0, first.0)
|
||||||
|
};
|
||||||
|
Some(format!("principal:{first}:{second}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn principal_secret_id(chat_id: &str) -> String {
|
||||||
|
format!("chat:{chat_id}:main")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate_chat_secret_namespace(
|
||||||
|
user_id: &str,
|
||||||
|
legacy_chat_id: &str,
|
||||||
|
legacy_secret_id: &str,
|
||||||
|
principal_chat_id: &str,
|
||||||
|
principal_secret_id: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if [
|
||||||
|
user_id,
|
||||||
|
legacy_chat_id,
|
||||||
|
legacy_secret_id,
|
||||||
|
principal_chat_id,
|
||||||
|
principal_secret_id,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.is_empty())
|
||||||
|
{
|
||||||
|
return Err("chat secret namespace is incomplete".into());
|
||||||
|
}
|
||||||
|
db::with_conn(&E2EE_DB, |conn| {
|
||||||
|
conn.execute(
|
||||||
|
r#"
|
||||||
|
INSERT OR IGNORE INTO chat_secrets (
|
||||||
|
user_id, chat_id, secret_id, version, encrypted_secret,
|
||||||
|
kem_ciphertext, wrapping_scheme, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT user_id, ?4, ?5, version, encrypted_secret,
|
||||||
|
kem_ciphertext, wrapping_scheme, created_at, updated_at
|
||||||
|
FROM chat_secrets
|
||||||
|
WHERE user_id = ?1 AND chat_id = ?2 AND secret_id = ?3
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
user_id,
|
||||||
|
legacy_chat_id,
|
||||||
|
legacy_secret_id,
|
||||||
|
principal_chat_id,
|
||||||
|
principal_secret_id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
|
static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
|
||||||
let database = db::create_shared_connection(
|
let database = db::create_shared_connection(
|
||||||
"e2ee",
|
"e2ee",
|
||||||
|
|
@ -186,3 +248,34 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredChatS
|
||||||
updated_at: row.get(8)?,
|
updated_at: row.get(8)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{principal_chat_id, principal_secret_id};
|
||||||
|
use iota_identity::PrincipalHandle;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_secret_namespace_uses_principals() {
|
||||||
|
let first = principal_chat_id(PrincipalHandle(3), PrincipalHandle(9));
|
||||||
|
let second = principal_chat_id(PrincipalHandle(9), PrincipalHandle(3));
|
||||||
|
|
||||||
|
assert_eq!(first, Some("principal:3:9".to_string()));
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert_eq!(
|
||||||
|
principal_secret_id("principal:3:9"),
|
||||||
|
"chat:principal:3:9:main"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_or_invalid_principals_have_no_chat_namespace() {
|
||||||
|
assert_eq!(
|
||||||
|
principal_chat_id(PrincipalHandle(7), PrincipalHandle(7)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
principal_chat_id(PrincipalHandle(0), PrincipalHandle(7)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
/* Accepts an outgoing relay only when its retry record and retention state commit together. */
|
/* Accepts an outgoing relay only when its retry record and retention state commit together. */
|
||||||
use crate::storage_error::StorageError;
|
use crate::storage_error::StorageError;
|
||||||
use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay};
|
use crate::util::{chat_files, db, message_storage_policy, relay_queue, relay_replay};
|
||||||
use iota_util::route_target::RouteTarget;
|
|
||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
|
|
||||||
pub struct OutgoingRelay<'a> {
|
pub struct OutgoingRelay<'a> {
|
||||||
pub target: RouteTarget,
|
pub target: relay_queue::RelayTarget,
|
||||||
pub identity: &'a relay_queue::RelayIdentity,
|
pub identity: &'a relay_queue::RelayIdentity,
|
||||||
pub frame: &'a [u8],
|
pub frame: &'a [u8],
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|
@ -24,22 +23,30 @@ pub fn commit_outgoing_relay(relay: OutgoingRelay<'_>) -> Result<(), StorageErro
|
||||||
relay.frame_id,
|
relay.frame_id,
|
||||||
relay.type_map_version,
|
relay.type_map_version,
|
||||||
)?;
|
)?;
|
||||||
if message_storage_policy::get_in_tx(tx, relay.identity.signer_id)?.history_mode
|
let signer_id = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT local_user_id FROM hosted_principals WHERE principal_handle = ?1",
|
||||||
|
[relay.identity.signer.0],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| StorageError::Other("outgoing signer is not hosted locally".into()))?;
|
||||||
|
if message_storage_policy::get_in_tx(tx, signer_id)?.history_mode
|
||||||
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
|
== message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery
|
||||||
{
|
{
|
||||||
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", rusqlite::params![relay.identity.signer_id, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?;
|
let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", rusqlite::params![signer_id, relay.identity.signer.0, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?;
|
||||||
if let Some(message_id) = message_id {
|
if let Some(message_id) = message_id {
|
||||||
chat_files::remove_message_history_in_tx(tx, relay.identity.signer_id, message_id)?;
|
chat_files::remove_message_history_in_tx(tx, signer_id, message_id)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
relay_replay::mark_queued_in_tx(tx, relay.identity.signer_id, &relay.identity.message_id)?;
|
relay_replay::mark_queued_in_tx(tx, relay.identity.signer, &relay.identity.message_id)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_outgoing_history_policy(
|
pub fn apply_outgoing_history_policy(
|
||||||
storage_owner: i64,
|
storage_owner: i64,
|
||||||
relay_signer_id: i64,
|
relay_signer_principal: iota_identity::PrincipalHandle,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
|
|
@ -49,8 +56,8 @@ pub fn apply_outgoing_history_policy(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let message_id = tx.query_row(
|
let message_id = tx.query_row(
|
||||||
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3",
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3",
|
||||||
rusqlite::params![storage_owner, relay_signer_id, relay_message_id],
|
rusqlite::params![storage_owner, relay_signer_principal.0, relay_message_id],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
).optional()?;
|
).optional()?;
|
||||||
if let Some(message_id) = message_id {
|
if let Some(message_id) = message_id {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,44 @@
|
||||||
use crate::storage_error::StorageError;
|
use crate::storage_error::StorageError;
|
||||||
use crate::util::db;
|
use crate::util::db;
|
||||||
|
use iota_identity::{AuthorityId, IotaNodeId, PrincipalHandle};
|
||||||
use iota_util::route_target::RouteTarget;
|
use iota_util::route_target::RouteTarget;
|
||||||
use rusqlite::{OptionalExtension, Transaction, params};
|
use rusqlite::{OptionalExtension, Transaction, params};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PendingRelay {
|
pub struct PendingRelay {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub target: RouteTarget,
|
pub target: RelayTarget,
|
||||||
pub frame: Vec<u8>,
|
pub frame: Vec<u8>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub frame_id: u32,
|
pub frame_id: u32,
|
||||||
pub type_map_version: String,
|
pub type_map_version: String,
|
||||||
pub relay_signer_id: Option<i64>,
|
pub relay_signer_id: Option<i64>,
|
||||||
pub relay_destination_user_id: Option<i64>,
|
pub relay_destination_user_id: Option<i64>,
|
||||||
|
pub signer_principal: Option<PrincipalHandle>,
|
||||||
|
pub destination_principal: Option<PrincipalHandle>,
|
||||||
pub relay_message_id: Option<String>,
|
pub relay_message_id: Option<String>,
|
||||||
pub delivery_state: PendingRelayDeliveryState,
|
pub delivery_state: PendingRelayDeliveryState,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
|
pub attempt_count: i64,
|
||||||
|
pub last_attempt_at: Option<i64>,
|
||||||
|
pub next_attempt_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RelayTarget {
|
||||||
|
User(u64),
|
||||||
|
Iota(IotaNodeId),
|
||||||
|
LegacyOmegaIota { omega: AuthorityId, iota_id: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayTarget {
|
||||||
|
pub fn legacy_wire_target(&self) -> Option<RouteTarget> {
|
||||||
|
match self {
|
||||||
|
Self::User(user_id) => Some(RouteTarget::User(*user_id)),
|
||||||
|
Self::LegacyOmegaIota { iota_id, .. } => Some(RouteTarget::Iota(*iota_id)),
|
||||||
|
Self::Iota(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -38,23 +61,25 @@ impl PendingRelayDeliveryState {
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct RelayIdentity {
|
pub struct RelayIdentity {
|
||||||
pub signer_id: i64,
|
pub signer: PrincipalHandle,
|
||||||
pub destination_user_id: i64,
|
pub recipient: PrincipalHandle,
|
||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
|
pub legacy_signer_id: Option<i64>,
|
||||||
|
pub legacy_recipient_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enqueue(
|
pub fn enqueue(
|
||||||
target: RouteTarget,
|
target: RelayTarget,
|
||||||
relay: &RelayIdentity,
|
relay: &RelayIdentity,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
type_map_version: &str,
|
type_map_version: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
if let RouteTarget::User(destination) = target {
|
if let RelayTarget::User(destination) = target {
|
||||||
let destination = i64::try_from(destination)
|
let destination = i64::try_from(destination)
|
||||||
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
||||||
if destination != relay.destination_user_id {
|
if Some(destination) != relay.legacy_recipient_id {
|
||||||
return Err(StorageError::Other(
|
return Err(StorageError::Other(
|
||||||
"user relay target does not match final recipient".into(),
|
"user relay target does not match final recipient".into(),
|
||||||
));
|
));
|
||||||
|
|
@ -75,31 +100,49 @@ pub fn enqueue(
|
||||||
|
|
||||||
pub fn enqueue_in_tx(
|
pub fn enqueue_in_tx(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
target: RouteTarget,
|
target: RelayTarget,
|
||||||
relay: &RelayIdentity,
|
relay: &RelayIdentity,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
type_map_version: &str,
|
type_map_version: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let destination_id = i64::try_from(target.id())
|
let (destination_id, target_kind, destination_node_id, destination_authority_id) = match &target
|
||||||
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
{
|
||||||
let target_kind = match target {
|
RelayTarget::User(user_id) => (
|
||||||
RouteTarget::User(_) => 0_i64,
|
i64::try_from(*user_id).map_err(|_| {
|
||||||
RouteTarget::Iota(_) => 1_i64,
|
StorageError::Other("relay destination ID exceeds SQLite range".into())
|
||||||
|
})?,
|
||||||
|
0_i64,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
RelayTarget::LegacyOmegaIota { omega, iota_id } => (
|
||||||
|
i64::try_from(*iota_id).map_err(|_| {
|
||||||
|
StorageError::Other("relay destination ID exceeds SQLite range".into())
|
||||||
|
})?,
|
||||||
|
1_i64,
|
||||||
|
None,
|
||||||
|
Some(omega.as_str()),
|
||||||
|
),
|
||||||
|
RelayTarget::Iota(node) => (0, 2_i64, Some(node.as_str()), None),
|
||||||
};
|
};
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
"INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version, signer_principal, destination_principal, destination_node_id, destination_authority_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
destination_id,
|
destination_id,
|
||||||
target_kind,
|
target_kind,
|
||||||
relay.signer_id,
|
relay.legacy_signer_id,
|
||||||
relay.destination_user_id,
|
relay.legacy_recipient_id,
|
||||||
relay.message_id,
|
relay.message_id,
|
||||||
frame,
|
frame,
|
||||||
created_at,
|
created_at,
|
||||||
i64::from(frame_id),
|
i64::from(frame_id),
|
||||||
type_map_version
|
type_map_version,
|
||||||
|
relay.signer.0,
|
||||||
|
relay.recipient.0,
|
||||||
|
destination_node_id,
|
||||||
|
destination_authority_id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -108,7 +151,7 @@ pub fn enqueue_in_tx(
|
||||||
pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
let mut statement = connection.prepare(
|
let mut statement = connection.prepare(
|
||||||
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays ORDER BY id LIMIT ?1",
|
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error, destination_principal, signer_principal, destination_node_id, destination_authority_id, attempt_count, last_attempt_at, next_attempt_at FROM pending_relays ORDER BY id LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map(params![limit.clamp(1, 500)], |row| {
|
let rows = statement.query_map(params![limit.clamp(1, 500)], |row| {
|
||||||
let destination_id = row.get::<_, i64>(1)?;
|
let destination_id = row.get::<_, i64>(1)?;
|
||||||
|
|
@ -121,8 +164,26 @@ pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let target = match target_kind {
|
let target = match target_kind {
|
||||||
0 => RouteTarget::User(destination_id),
|
0 => RelayTarget::User(destination_id),
|
||||||
1 => RouteTarget::Iota(destination_id),
|
1 => RelayTarget::LegacyOmegaIota {
|
||||||
|
omega: AuthorityId::new(row.get::<_, String>(15)?).map_err(|error| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
15,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(error),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
iota_id: destination_id,
|
||||||
|
},
|
||||||
|
2 => RelayTarget::Iota(IotaNodeId::new(row.get::<_, String>(14)?).map_err(
|
||||||
|
|error| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
14,
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(error),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||||
2,
|
2,
|
||||||
|
|
@ -146,9 +207,16 @@ pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
type_map_version: row.get(6)?,
|
type_map_version: row.get(6)?,
|
||||||
relay_signer_id: row.get(7)?,
|
relay_signer_id: row.get(7)?,
|
||||||
relay_destination_user_id: row.get(8)?,
|
relay_destination_user_id: row.get(8)?,
|
||||||
|
signer_principal: row.get::<_, Option<i64>>(13)?.map(PrincipalHandle),
|
||||||
|
destination_principal: row
|
||||||
|
.get::<_, Option<i64>>(12)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_message_id: row.get(9)?,
|
relay_message_id: row.get(9)?,
|
||||||
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
|
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
|
||||||
last_error: row.get(11)?,
|
last_error: row.get(11)?,
|
||||||
|
attempt_count: row.get(16)?,
|
||||||
|
last_attempt_at: row.get(17)?,
|
||||||
|
next_attempt_at: row.get(18)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||||
|
|
@ -160,9 +228,12 @@ pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
pub fn list_active(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
pub fn list_active(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
let mut statement = connection.prepare(
|
let mut statement = connection.prepare(
|
||||||
"SELECT p.id, p.destination_id, p.target_kind, p.frame, p.created_at, p.frame_id, p.type_map_version, p.relay_signer_id, p.relay_destination_user_id, p.relay_message_id, p.delivery_state, p.last_error FROM pending_relays p WHERE p.delivery_state = 'ready' AND ((p.target_kind = 0 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.destination_id)) OR (p.target_kind = 1 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.relay_signer_id))) ORDER BY p.id LIMIT ?1",
|
"SELECT p.id, p.destination_id, p.target_kind, p.frame, p.created_at, p.frame_id, p.type_map_version, p.relay_signer_id, p.relay_destination_user_id, p.relay_message_id, p.delivery_state, p.last_error, p.destination_principal, p.signer_principal, p.destination_node_id, p.destination_authority_id, p.attempt_count, p.last_attempt_at, p.next_attempt_at FROM pending_relays p WHERE p.delivery_state = 'ready' AND p.next_attempt_at <= ?1 AND ((p.target_kind = 0 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.destination_id)) OR (p.target_kind IN (1, 2) AND p.signer_principal IS NOT NULL)) ORDER BY p.id LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![now_millis(), limit.clamp(1, 500)],
|
||||||
|
pending_relay_from_row,
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map(params![limit.clamp(1, 500)], pending_relay_from_row)?;
|
|
||||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +247,7 @@ pub fn list_without_relay_identity_after(
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<PendingRelay>, StorageError> {
|
) -> Result<Vec<PendingRelay>, StorageError> {
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?;
|
let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error, destination_principal, signer_principal, destination_node_id, destination_authority_id, attempt_count, last_attempt_at, next_attempt_at FROM pending_relays WHERE id > ?1 AND (signer_principal IS NULL OR destination_principal IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?;
|
||||||
let rows = statement.query_map(
|
let rows = statement.query_map(
|
||||||
params![after_id, limit.clamp(1, 500)],
|
params![after_id, limit.clamp(1, 500)],
|
||||||
pending_relay_from_row,
|
pending_relay_from_row,
|
||||||
|
|
@ -189,8 +260,16 @@ fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result<PendingRelay, rusql
|
||||||
let destination_id = u64::try_from(row.get::<_, i64>(1)?)
|
let destination_id = u64::try_from(row.get::<_, i64>(1)?)
|
||||||
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, 0))?;
|
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, 0))?;
|
||||||
let target = match row.get::<_, i64>(2)? {
|
let target = match row.get::<_, i64>(2)? {
|
||||||
0 => RouteTarget::User(destination_id),
|
0 => RelayTarget::User(destination_id),
|
||||||
1 => RouteTarget::Iota(destination_id),
|
1 => RelayTarget::LegacyOmegaIota {
|
||||||
|
omega: AuthorityId::new(row.get::<_, String>(15)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidQuery)?,
|
||||||
|
iota_id: destination_id,
|
||||||
|
},
|
||||||
|
2 => RelayTarget::Iota(
|
||||||
|
IotaNodeId::new(row.get::<_, String>(14)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidQuery)?,
|
||||||
|
),
|
||||||
_ => return Err(rusqlite::Error::IntegralValueOutOfRange(2, 0)),
|
_ => return Err(rusqlite::Error::IntegralValueOutOfRange(2, 0)),
|
||||||
};
|
};
|
||||||
Ok(PendingRelay {
|
Ok(PendingRelay {
|
||||||
|
|
@ -203,12 +282,55 @@ fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result<PendingRelay, rusql
|
||||||
type_map_version: row.get(6)?,
|
type_map_version: row.get(6)?,
|
||||||
relay_signer_id: row.get(7)?,
|
relay_signer_id: row.get(7)?,
|
||||||
relay_destination_user_id: row.get(8)?,
|
relay_destination_user_id: row.get(8)?,
|
||||||
|
signer_principal: row.get::<_, Option<i64>>(13)?.map(PrincipalHandle),
|
||||||
|
destination_principal: row
|
||||||
|
.get::<_, Option<i64>>(12)?
|
||||||
|
.map(iota_identity::PrincipalHandle),
|
||||||
relay_message_id: row.get(9)?,
|
relay_message_id: row.get(9)?,
|
||||||
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
|
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
|
||||||
last_error: row.get(11)?,
|
last_error: row.get(11)?,
|
||||||
|
attempt_count: row.get(16)?,
|
||||||
|
last_attempt_at: row.get(17)?,
|
||||||
|
next_attempt_at: row.get(18)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_retry(id: i64, now: i64, error: &str) -> Result<(), StorageError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
let attempts = connection.query_row(
|
||||||
|
"SELECT attempt_count FROM pending_relays WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)?;
|
||||||
|
let exponent = u32::try_from(attempts.clamp(0, 6)).unwrap_or(6);
|
||||||
|
let delay = 5_000_i64.saturating_mul(1_i64 << exponent).min(300_000);
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pending_relays SET attempt_count = attempt_count + 1, last_attempt_at = ?2, next_attempt_at = ?3, last_error = ?4 WHERE id = ?1",
|
||||||
|
params![id, now, now.saturating_add(delay), error],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quarantine_for_frame(frame_id: u32, error: &str) -> Result<(), StorageError> {
|
||||||
|
db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE pending_relays SET delivery_state = 'quarantined', last_error = ?2 WHERE frame_id = ?1",
|
||||||
|
params![i64::from(frame_id), error],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_client_offline(destination_user_id: i64, frame_id: u32) -> Result<(), StorageError> {
|
pub fn mark_client_offline(destination_user_id: i64, frame_id: u32) -> Result<(), StorageError> {
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
tx.execute("UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'client_offline' WHERE target_kind = 0 AND destination_id = ?1 AND frame_id = ?2", params![destination_user_id, i64::from(frame_id)])?;
|
tx.execute("UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'client_offline' WHERE target_kind = 0 AND destination_id = ?1 AND frame_id = ?2", params![destination_user_id, i64::from(frame_id)])?;
|
||||||
|
|
@ -258,7 +380,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE
|
||||||
[id],
|
[id],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)?;
|
)?;
|
||||||
if destination_id != relay.destination_user_id {
|
if Some(destination_id) != relay.legacy_recipient_id {
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE pending_relays SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch' WHERE id = ?1",
|
"UPDATE pending_relays SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch' WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
|
|
@ -266,7 +388,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4 WHERE id = ?1", params![id, relay.signer_id, relay.destination_user_id, relay.message_id])?;
|
connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4, signer_principal = ?5, destination_principal = ?6 WHERE id = ?1", params![id, relay.legacy_signer_id, relay.legacy_recipient_id, relay.message_id, relay.signer.0, relay.recipient.0])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -274,7 +396,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE
|
||||||
pub fn has_unclassified_relays() -> Result<bool, StorageError> {
|
pub fn has_unclassified_relays() -> Result<bool, StorageError> {
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
connection
|
connection
|
||||||
.query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0))
|
.query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE signer_principal IS NULL OR destination_principal IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0))
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -285,14 +407,24 @@ pub fn find_user_delivery_in_tx(
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
) -> Result<Option<(i64, RelayIdentity)>, StorageError> {
|
) -> Result<Option<(i64, RelayIdentity)>, StorageError> {
|
||||||
tx.query_row(
|
tx.query_row(
|
||||||
"SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2",
|
"SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id, signer_principal, destination_principal FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2",
|
||||||
params![destination_id, i64::from(frame_id)],
|
params![destination_id, i64::from(frame_id)],
|
||||||
|row| {
|
|row| {
|
||||||
let signer_id = row.get::<_, Option<i64>>(1)?;
|
let signer_id = row.get::<_, Option<i64>>(1)?;
|
||||||
let destination_user_id = row.get::<_, Option<i64>>(2)?;
|
let destination_user_id = row.get::<_, Option<i64>>(2)?;
|
||||||
let message_id = row.get::<_, Option<String>>(3)?;
|
let message_id = row.get::<_, Option<String>>(3)?;
|
||||||
match (signer_id, destination_user_id, message_id) {
|
match (signer_id, destination_user_id, message_id) {
|
||||||
(Some(signer_id), Some(destination_user_id), Some(message_id)) => Ok((row.get(0)?, RelayIdentity { signer_id, destination_user_id, message_id })),
|
(Some(signer_id), Some(destination_user_id), Some(message_id)) => {
|
||||||
|
let signer = row.get::<_, Option<i64>>(4)?.ok_or(rusqlite::Error::InvalidQuery)?;
|
||||||
|
let recipient = row.get::<_, Option<i64>>(5)?.ok_or(rusqlite::Error::InvalidQuery)?;
|
||||||
|
Ok((row.get(0)?, RelayIdentity {
|
||||||
|
signer: PrincipalHandle(signer),
|
||||||
|
recipient: PrincipalHandle(recipient),
|
||||||
|
message_id,
|
||||||
|
legacy_signer_id: Some(signer_id),
|
||||||
|
legacy_recipient_id: Some(destination_user_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
_ => Err(rusqlite::Error::InvalidQuery),
|
_ => Err(rusqlite::Error::InvalidQuery),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -310,14 +442,14 @@ pub fn acknowledge_in_tx(tx: &Transaction<'_>, id: i64) -> Result<(), StorageErr
|
||||||
pub fn reject_outgoing_relay(
|
pub fn reject_outgoing_relay(
|
||||||
destination_iota: u64,
|
destination_iota: u64,
|
||||||
frame_id: u32,
|
frame_id: u32,
|
||||||
signer_id: i64,
|
signer_principal: PrincipalHandle,
|
||||||
relay_message_id: &str,
|
relay_message_id: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let destination_iota = i64::try_from(destination_iota)
|
let destination_iota = i64::try_from(destination_iota)
|
||||||
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
|
||||||
db::with_immediate_transaction(|tx| {
|
db::with_immediate_transaction(|tx| {
|
||||||
tx.execute("DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)])?;
|
tx.execute("DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)])?;
|
||||||
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, crate::util::sync::now_millis()])?;
|
tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, crate::util::sync::now_millis()])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,30 @@ pub enum RelayReservation {
|
||||||
Existing { state: String, frame_matches: bool },
|
Existing { state: String, frame_matches: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reserve(
|
pub struct RelayReservationRequest<'a> {
|
||||||
signer_id: u64,
|
pub signer_principal: iota_identity::PrincipalHandle,
|
||||||
message_id: &str,
|
pub signer_id: u64,
|
||||||
created_at: u64,
|
pub message_id: &'a str,
|
||||||
accepted_at: i64,
|
pub created_at: u64,
|
||||||
destination_id: u64,
|
pub accepted_at: i64,
|
||||||
frame: &[u8],
|
pub destination_id: u64,
|
||||||
frame_id: u32,
|
pub frame: &'a [u8],
|
||||||
type_map_version: &str,
|
pub frame_id: u32,
|
||||||
) -> Result<RelayReservation, StorageError> {
|
pub type_map_version: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reserve(request: RelayReservationRequest<'_>) -> Result<RelayReservation, StorageError> {
|
||||||
|
let RelayReservationRequest {
|
||||||
|
signer_principal,
|
||||||
|
signer_id,
|
||||||
|
message_id,
|
||||||
|
created_at,
|
||||||
|
accepted_at,
|
||||||
|
destination_id,
|
||||||
|
frame,
|
||||||
|
frame_id,
|
||||||
|
type_map_version,
|
||||||
|
} = request;
|
||||||
let signer_id = i64::try_from(signer_id)
|
let signer_id = i64::try_from(signer_id)
|
||||||
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
|
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
|
||||||
let created_at = i64::try_from(created_at)
|
let created_at = i64::try_from(created_at)
|
||||||
|
|
@ -34,8 +48,9 @@ pub fn reserve(
|
||||||
|
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
let inserted = connection.execute(
|
let inserted = connection.execute(
|
||||||
"INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'received')",
|
"INSERT OR IGNORE INTO relay_inbox (signer_principal, signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'received')",
|
||||||
params![
|
params![
|
||||||
|
signer_principal.0,
|
||||||
signer_id,
|
signer_id,
|
||||||
message_id,
|
message_id,
|
||||||
created_at,
|
created_at,
|
||||||
|
|
@ -52,8 +67,8 @@ pub fn reserve(
|
||||||
|
|
||||||
let (state, existing_destination_id, existing_frame, existing_type_map_version):
|
let (state, existing_destination_id, existing_frame, existing_type_map_version):
|
||||||
(String, i64, Vec<u8>, String) = connection.query_row(
|
(String, i64, Vec<u8>, String) = connection.query_row(
|
||||||
"SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_id = ?1 AND message_id = ?2",
|
"SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_principal = ?1 AND message_id = ?2",
|
||||||
params![signer_id, message_id],
|
params![signer_principal.0, message_id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get(0)?,
|
row.get(0)?,
|
||||||
|
|
@ -119,52 +134,67 @@ pub fn mark_delivered_for_frame_in_tx(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_transition(
|
fn mark_transition(
|
||||||
signer_id: u64,
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
message_id: &str,
|
message_id: &str,
|
||||||
state: &str,
|
state: &str,
|
||||||
column: &str,
|
column: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let signer_id = i64::try_from(signer_id)
|
|
||||||
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
connection.execute(
|
connection.execute(
|
||||||
&format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_id = ?1 AND message_id = ?2"),
|
&format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_principal = ?1 AND message_id = ?2"),
|
||||||
params![signer_id, message_id, state, timestamp],
|
params![signer_principal.0, message_id, state, timestamp],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_applied(signer_id: u64, message_id: &str) -> Result<(), StorageError> {
|
pub fn mark_applied(
|
||||||
mark_transition(signer_id, message_id, "applied", "applied_at")
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
mark_transition(signer_principal, message_id, "applied", "applied_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_queued(signer_id: u64, message_id: &str) -> Result<(), StorageError> {
|
pub fn mark_queued(
|
||||||
mark_transition(signer_id, message_id, "queued", "queued_at")
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
mark_transition(signer_principal, message_id, "queued", "queued_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_queued_in_tx(
|
pub fn mark_queued_in_tx(
|
||||||
tx: &rusqlite::Transaction<'_>,
|
tx: &rusqlite::Transaction<'_>,
|
||||||
signer_id: i64,
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
message_id: &str,
|
message_id: &str,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_id = ?1 AND message_id = ?2",
|
"UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2",
|
||||||
params![signer_id, message_id, crate::util::sync::now_millis()],
|
params![signer_principal.0, message_id, crate::util::sync::now_millis()],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_downstream_acked(signer_id: u64, message_id: &str) -> Result<(), StorageError> {
|
pub fn mark_downstream_acked(
|
||||||
mark_transition(signer_id, message_id, "delivered", "downstream_acked_at")
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
mark_transition(
|
||||||
|
signer_principal,
|
||||||
|
message_id,
|
||||||
|
"delivered",
|
||||||
|
"downstream_acked_at",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_rejected(signer_id: u64, message_id: &str) -> Result<(), StorageError> {
|
pub fn mark_rejected(
|
||||||
mark_transition(signer_id, message_id, "rejected", "rejected_at")
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
mark_transition(signer_principal, message_id, "rejected", "rejected_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> {
|
pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> {
|
||||||
|
|
@ -174,14 +204,19 @@ pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> {
|
||||||
params![before_terminal_at],
|
params![before_terminal_at],
|
||||||
)?;
|
)?;
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id)",
|
"DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_principal = relay_replay.signer_principal AND relay_inbox.message_id = relay_replay.message_id)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result<bool, StorageError> {
|
pub fn accept(
|
||||||
|
signer_principal: iota_identity::PrincipalHandle,
|
||||||
|
signer_id: u64,
|
||||||
|
message_id: &str,
|
||||||
|
created_at: u64,
|
||||||
|
) -> Result<bool, StorageError> {
|
||||||
let signer_id = i64::try_from(signer_id)
|
let signer_id = i64::try_from(signer_id)
|
||||||
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
|
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
|
||||||
let created_at = i64::try_from(created_at)
|
let created_at = i64::try_from(created_at)
|
||||||
|
|
@ -189,8 +224,8 @@ pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result<bool,
|
||||||
|
|
||||||
db::with_db(|connection| {
|
db::with_db(|connection| {
|
||||||
let inserted = connection.execute(
|
let inserted = connection.execute(
|
||||||
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![signer_id, message_id, created_at],
|
params![signer_principal.0, signer_id, message_id, created_at],
|
||||||
)?;
|
)?;
|
||||||
Ok(inserted == 1)
|
Ok(inserted == 1)
|
||||||
})
|
})
|
||||||
|
|
@ -201,26 +236,29 @@ mod tests {
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_identity_uses_signer_and_message_id() -> Result<(), rusqlite::Error> {
|
fn replay_identity_uses_principal_and_message_id() -> Result<(), rusqlite::Error> {
|
||||||
let connection = Connection::open_in_memory()?;
|
let connection = Connection::open_in_memory()?;
|
||||||
connection.execute_batch(
|
connection.execute_batch(
|
||||||
"CREATE TABLE relay_replay (signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_id, message_id));",
|
"CREATE TABLE relay_replay (signer_principal INTEGER NOT NULL, signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_principal, message_id));",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let first = connection.execute(
|
let first = connection.execute(
|
||||||
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![7_i64, "message", 1_i64],
|
params![70_i64, 7_i64, "message", 1_i64],
|
||||||
)?;
|
)?;
|
||||||
let duplicate = connection.execute(
|
let duplicate = connection.execute(
|
||||||
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![7_i64, "message", 2_i64],
|
params![70_i64, 7_i64, "message", 2_i64],
|
||||||
)?;
|
)?;
|
||||||
let other_signer = connection.execute(
|
let other_authority_same_numeric_signer = connection.execute(
|
||||||
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![8_i64, "message", 2_i64],
|
params![71_i64, 7_i64, "message", 2_i64],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
assert_eq!((first, duplicate, other_signer), (1, 0, 1));
|
assert_eq!(
|
||||||
|
(first, duplicate, other_authority_same_numeric_signer),
|
||||||
|
(1, 0, 1)
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
iota-storage/tests/descriptor_lifecycle.rs
Normal file
132
iota-storage/tests/descriptor_lifecycle.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityKind, LocalNodeIdentity, LocalUserDescriptor, LocalUserId, PrincipalHome, PrincipalId,
|
||||||
|
PrincipalStore, VerifiedPrincipalDescriptor,
|
||||||
|
};
|
||||||
|
use iota_storage::identity::SqlitePrincipalStore;
|
||||||
|
use mtp::crypto::Keyring;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_descriptor_revision_persists_changes_and_retirement() {
|
||||||
|
let storage = tempfile::tempdir().unwrap();
|
||||||
|
iota_util::file_util::configure_storage_directory(storage.path().to_owned());
|
||||||
|
iota_storage::util::db::initialize_database().unwrap();
|
||||||
|
|
||||||
|
let identity = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let user_key = Keyring::generate();
|
||||||
|
let mut user = LocalUserDescriptor {
|
||||||
|
id: LocalUserId(9),
|
||||||
|
username: "alice".into(),
|
||||||
|
display_name: None,
|
||||||
|
public_key: user_key.public_key_bundle().try_to_base64().unwrap(),
|
||||||
|
};
|
||||||
|
iota_storage::util::db::with_db(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users (user_id, username, public_key, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
rusqlite::params![user.id.0, user.username, user.public_key, 1_i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = SqlitePrincipalStore;
|
||||||
|
let home = PrincipalHome::Iota(identity.node_id().clone());
|
||||||
|
store
|
||||||
|
.ensure_local_principal(
|
||||||
|
identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&user,
|
||||||
|
home.clone(),
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let first = store.local_principal_descriptor(user.id).unwrap();
|
||||||
|
assert_eq!(first.revision, 0);
|
||||||
|
|
||||||
|
store
|
||||||
|
.ensure_local_principal(
|
||||||
|
identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&user,
|
||||||
|
home.clone(),
|
||||||
|
1_001,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.local_principal_descriptor(user.id).unwrap().revision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
user.display_name = Some("Alice".into());
|
||||||
|
store
|
||||||
|
.ensure_local_principal(
|
||||||
|
identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&user,
|
||||||
|
home.clone(),
|
||||||
|
1_002,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.local_principal_descriptor(user.id).unwrap().revision,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement_key = Keyring::generate().public_key_bundle();
|
||||||
|
user.public_key = replacement_key.try_to_base64().unwrap();
|
||||||
|
store
|
||||||
|
.ensure_local_principal(
|
||||||
|
identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&user,
|
||||||
|
home.clone(),
|
||||||
|
1_003,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rotated = store.local_principal_descriptor(user.id).unwrap();
|
||||||
|
assert_eq!(rotated.revision, 2);
|
||||||
|
assert_eq!(rotated.public_keys.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
rotated.public_keys[0].try_to_base64().unwrap(),
|
||||||
|
replacement_key.try_to_base64().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let renewal_at = rotated.valid_until.unwrap() - 1;
|
||||||
|
store
|
||||||
|
.ensure_local_principal(
|
||||||
|
identity.authority_id(),
|
||||||
|
AuthorityKind::Iota,
|
||||||
|
&user,
|
||||||
|
home,
|
||||||
|
renewal_at,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let renewed = store.local_principal_descriptor(user.id).unwrap();
|
||||||
|
assert_eq!(renewed.revision, 3);
|
||||||
|
let mut conflicting = renewed.clone();
|
||||||
|
conflicting.display_name = Some("Changed without revision".into());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.upsert_remote_descriptor(
|
||||||
|
&VerifiedPrincipalDescriptor::from_trusted_authority(conflicting).unwrap()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.retire_local_principal(user.id, renewal_at + 1)
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.local_principal_descriptor(user.id).is_err());
|
||||||
|
let principal = PrincipalId {
|
||||||
|
authority: identity.authority_id().clone(),
|
||||||
|
user_id: 9,
|
||||||
|
};
|
||||||
|
let retired = store.get_by_canonical_id(&principal).unwrap().unwrap();
|
||||||
|
assert_eq!(retired.descriptor_revision, 4);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.upsert_remote_descriptor(
|
||||||
|
&VerifiedPrincipalDescriptor::from_trusted_authority(first).unwrap()
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
111
iota-storage/tests/node_directory.rs
Normal file
111
iota-storage/tests/node_directory.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use iota_identity::{
|
||||||
|
AuthorityLocator, LocalNodeIdentity, NodeDescriptor, NodeDirectory, NodeIdentityResolver,
|
||||||
|
SignedNodeDescriptor,
|
||||||
|
};
|
||||||
|
use iota_storage::node_directory::SqliteNodeDirectory;
|
||||||
|
use mtp::crypto::Keyring;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn node_directory_persists_identity_revision_endpoints_and_pins() {
|
||||||
|
let storage = tempfile::tempdir().unwrap();
|
||||||
|
iota_util::file_util::configure_storage_directory(storage.path().to_owned());
|
||||||
|
iota_storage::util::db::initialize_database().unwrap();
|
||||||
|
|
||||||
|
let identity = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
let endpoint = AuthorityLocator::new("iota.example.org:443").unwrap();
|
||||||
|
let relay = AuthorityLocator::new("relay.example.org:443").unwrap();
|
||||||
|
let now = iota_storage::util::sync::now_millis();
|
||||||
|
let signed = SignedNodeDescriptor::sign(
|
||||||
|
NodeDescriptor {
|
||||||
|
node: identity.node_id().clone(),
|
||||||
|
public_keys: identity.public_keys(),
|
||||||
|
direct_endpoints: vec![endpoint.clone()],
|
||||||
|
relay_hints: vec![relay.clone()],
|
||||||
|
revision: 1,
|
||||||
|
valid_until: Some(now + 60_000),
|
||||||
|
},
|
||||||
|
&identity,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let verified = signed.verify(None, now).unwrap();
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.upsert_node_descriptor(&verified)
|
||||||
|
.unwrap();
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.pin_endpoint(&endpoint, identity.node_id(), now)
|
||||||
|
.unwrap();
|
||||||
|
let certificate = b"test certificate";
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.pin_endpoint_certificate(&endpoint, certificate)
|
||||||
|
.unwrap();
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.record_successful_endpoint(identity.node_id(), &endpoint)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let resolved = SqliteNodeDirectory
|
||||||
|
.resolve_node(identity.node_id())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resolved.descriptor_revision, 1);
|
||||||
|
assert_eq!(resolved.direct_endpoints, vec![endpoint.clone()]);
|
||||||
|
assert_eq!(resolved.relay_hints, vec![relay]);
|
||||||
|
assert_eq!(resolved.last_successful_endpoint, Some(endpoint.clone()));
|
||||||
|
assert_eq!(
|
||||||
|
SqliteNodeDirectory.pinned_node(&endpoint).unwrap(),
|
||||||
|
Some(identity.node_id().clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.pinned_endpoint_certificate(&endpoint)
|
||||||
|
.unwrap(),
|
||||||
|
Some(certificate.to_vec())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.pin_endpoint_certificate(&endpoint, b"changed certificate")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let stale = SignedNodeDescriptor::sign(
|
||||||
|
NodeDescriptor {
|
||||||
|
node: identity.node_id().clone(),
|
||||||
|
public_keys: identity.public_keys(),
|
||||||
|
direct_endpoints: Vec::new(),
|
||||||
|
relay_hints: Vec::new(),
|
||||||
|
revision: 0,
|
||||||
|
valid_until: Some(now + 60_000),
|
||||||
|
},
|
||||||
|
&identity,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.verify(None, now)
|
||||||
|
.unwrap();
|
||||||
|
assert!(SqliteNodeDirectory.upsert_node_descriptor(&stale).is_err());
|
||||||
|
|
||||||
|
let conflicting = SignedNodeDescriptor::sign(
|
||||||
|
NodeDescriptor {
|
||||||
|
node: identity.node_id().clone(),
|
||||||
|
public_keys: identity.public_keys(),
|
||||||
|
direct_endpoints: Vec::new(),
|
||||||
|
relay_hints: Vec::new(),
|
||||||
|
revision: 1,
|
||||||
|
valid_until: Some(now + 60_000),
|
||||||
|
},
|
||||||
|
&identity,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.verify(None, now)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.upsert_node_descriptor(&conflicting)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let other = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap();
|
||||||
|
assert!(
|
||||||
|
SqliteNodeDirectory
|
||||||
|
.pin_endpoint(&endpoint, other.node_id(), now + 1)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue