a.0.0.5
This commit is contained in:
1 parent
e88dc07696
commit
5d01dfb63a
12 files changed
+2410
-1863
No files matched your search
Generated
+83
-349
@@ -2,15 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -19,7 +10,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -28,35 +19,6 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -112,28 +74,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -167,12 +107,6 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -186,8 +120,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -197,15 +129,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -225,78 +148,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -315,27 +172,9 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -352,12 +191,6 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -367,22 +200,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -398,6 +215,23 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
@@ -417,7 +251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -443,53 +281,6 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getset"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -549,9 +340,7 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -685,12 +474,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -713,13 +496,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -734,16 +517,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -767,17 +540,6 @@ version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "local-ip-address"
|
||||
version = "0.6.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"neli",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -822,35 +584,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"derive_builder",
|
||||
"getset",
|
||||
"libc",
|
||||
"log",
|
||||
"neli-proc-macros",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neli-proc-macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
|
||||
dependencies = [
|
||||
"either",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -908,12 +641,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -947,21 +674,19 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "ramshare_ui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-server",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"getrandom",
|
||||
"hex",
|
||||
"local-ip-address",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"if-addrs",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -970,6 +695,9 @@ dependencies = [
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1003,6 +731,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1021,12 +750,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -1038,7 +769,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
@@ -1050,7 +781,6 @@ version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -1059,15 +789,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -1083,7 +804,6 @@ version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -1134,7 +854,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1238,29 +958,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
@@ -1289,7 +992,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1330,7 +1033,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.5",
|
||||
@@ -1346,7 +1048,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1359,6 +1061,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.20"
|
||||
@@ -1368,7 +1081,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -1545,7 +1257,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -1558,6 +1270,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -1583,6 +1308,15 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1690,7 +1424,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -1711,7 +1445,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -1751,7 +1485,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+25
-11
@@ -1,23 +1,37 @@
|
||||
[package]
|
||||
name = "ramshare_ui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "time", "sync", "io-util", "signal"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
axum = { version = "0.7" }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
bytes = "1.7"
|
||||
socket2 = "0.5"
|
||||
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-no-provider", "json"] }
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "server", "http1", "service"] }
|
||||
|
||||
# 0.12.9+: нужен ClientBuilder::read_timeout / tls_info / use_preconfigured_tls
|
||||
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls-no-provider", "stream"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } # TLS 1.2 не нужен
|
||||
rcgen = "0.13"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
getrandom = "0.2"
|
||||
percent-encoding = "2"
|
||||
|
||||
local-ip-address = "0.6"
|
||||
socket2 = "0.5"
|
||||
if-addrs = "0.13"
|
||||
parking_lot = "0.12"
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
+174
-285
@@ -1,326 +1,214 @@
|
||||
mod models;
|
||||
mod net;
|
||||
mod p2p;
|
||||
mod storage;
|
||||
mod tls;
|
||||
mod ui;
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use local_ip_address::local_ip;
|
||||
use models::{
|
||||
now_sec, AppState, BeaconPacket, ScanProgress, DISCOVERY_PORT, MAX_PAYLOAD_SIZE,
|
||||
MAX_RAM_TEXTS, MEDIA_DIR,
|
||||
};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use models::*;
|
||||
use parking_lot::{Mutex as PlMutex, RwLock};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
||||
sync::{Arc, RwLock},
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
fn detect_lan_ip() -> String {
|
||||
if let Ok(sock) = StdUdpSocket::bind("0.0.0.0:0") {
|
||||
if sock.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(local_addr) = sock.local_addr() {
|
||||
let ip = local_addr.ip().to_string();
|
||||
if !ip.starts_with("127.") && !ip.starts_with("172.17.") {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
local_ip()
|
||||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
const USAGE: &str = "\
|
||||
Использование: ramshare_ui [параметры]
|
||||
--name <имя> имя устройства
|
||||
--p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443
|
||||
--ui-port <порт> порт локального UI (только 127.0.0.1), по умолчанию 8080
|
||||
--ip <IPv4> адрес интерфейса (по умолчанию определяется автоматически)
|
||||
--data-dir <путь> каталог данных (идентичность, доверенные, принятые файлы)
|
||||
-h, --help эта справка";
|
||||
|
||||
struct Config {
|
||||
name: String,
|
||||
p2p_port: u16,
|
||||
ui_port: u16,
|
||||
ip: Option<Ipv4Addr>,
|
||||
data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn subnet_broadcast_addr(ip_str: &str, port: u16) -> Option<String> {
|
||||
let ipv4: Ipv4Addr = ip_str.parse().ok()?;
|
||||
let octets = ipv4.octets();
|
||||
Some(format!("{}.{}.{}.255:{}", octets[0], octets[1], octets[2], port))
|
||||
fn fatal(msg: &str) -> ! {
|
||||
eprintln!("Ошибка: {}", msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
||||
let targets: Vec<(String, Vec<String>, u16)> = {
|
||||
let guard = state.peers.read().unwrap();
|
||||
guard
|
||||
.values()
|
||||
.map(|p| (p.id.clone(), p.ips.clone(), p.port))
|
||||
.collect()
|
||||
fn parse_args() -> Result<Config, String> {
|
||||
let default_name = std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.unwrap_or_else(|_| "My-Device".into());
|
||||
let mut cfg = Config {
|
||||
name: clean_label(&default_name, 48),
|
||||
p2p_port: 8443,
|
||||
ui_port: 8080,
|
||||
ip: None,
|
||||
data_dir: None,
|
||||
};
|
||||
|
||||
for (peer_id, ips, port) in targets {
|
||||
let mut reachable = false;
|
||||
for ip in ips {
|
||||
let url = format!("https://{}:{}/p2p/info", ip, port);
|
||||
let res = state
|
||||
.probe_client
|
||||
.get(&url)
|
||||
.header("X-Node-Fp", &state.my_fingerprint)
|
||||
.header("X-Node-Name", &state.device_name)
|
||||
.header("X-Node-Ip", &state.my_ip)
|
||||
.header("X-Node-Port", state.p2p_port.to_string())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = res {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(info) = resp.json::<serde_json::Value>().await {
|
||||
let name = info["name"].as_str().unwrap_or("Node").to_string();
|
||||
let fp = info["fingerprint"].as_str().unwrap_or("").to_string();
|
||||
if !fp.is_empty() {
|
||||
state.upsert_peer(name, ip, port, fp);
|
||||
reachable = true;
|
||||
break;
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
|
||||
match a.as_str() {
|
||||
"--name" => cfg.name = clean_label(&val("--name")?, 48),
|
||||
"--p2p-port" => {
|
||||
cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())?
|
||||
}
|
||||
"--ui-port" => {
|
||||
cfg.ui_port = val("--ui-port")?.parse().map_err(|_| "Некорректный --ui-port".to_string())?
|
||||
}
|
||||
"--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?),
|
||||
"--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)),
|
||||
"-h" | "--help" => {
|
||||
println!("{}", USAGE);
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("Неизвестный аргумент: {}\n\n{}", other, USAGE)),
|
||||
}
|
||||
}
|
||||
if cfg.name.is_empty() {
|
||||
return Err("Пустое имя устройства".into());
|
||||
}
|
||||
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
|
||||
return Err("Порт не может быть 0".into());
|
||||
}
|
||||
}
|
||||
|
||||
if !reachable {
|
||||
let mut guard = state.peers.write().unwrap();
|
||||
if let Some(peer) = guard.get_mut(&peer_id) {
|
||||
if now_sec().saturating_sub(peer.last_seen) > 15 {
|
||||
peer.online = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_udp_discovery(state: Arc<AppState>) {
|
||||
let tx_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let bind_target = format!("{}:0", tx_state.my_ip);
|
||||
let sender = match UdpSocket::bind(&bind_target).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tx_state.add_log(format!("Ошибка открытия UDP сокета отправки: {}", e));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let _ = sender.set_broadcast(true);
|
||||
|
||||
let global_bcast = format!("255.255.255.255:{}", DISCOVERY_PORT);
|
||||
let subnet_bcast = subnet_broadcast_addr(&tx_state.my_ip, DISCOVERY_PORT);
|
||||
|
||||
loop {
|
||||
let packet = BeaconPacket {
|
||||
name: tx_state.device_name.clone(),
|
||||
ip: tx_state.my_ip.clone(),
|
||||
port: tx_state.p2p_port,
|
||||
fingerprint: tx_state.my_fingerprint.clone(),
|
||||
};
|
||||
|
||||
if let Ok(bytes) = serde_json::to_vec(&packet) {
|
||||
if let Some(ref sub_target) = subnet_bcast {
|
||||
let _ = sender.send_to(&bytes, sub_target).await;
|
||||
}
|
||||
let _ = sender.send_to(&bytes, &global_bcast).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
let bind_addr: SocketAddr = format!("0.0.0.0:{}", DISCOVERY_PORT).parse().unwrap();
|
||||
let socket = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let _ = socket.set_reuse_address(true);
|
||||
let _ = socket.set_broadcast(true);
|
||||
let _ = socket.set_nonblocking(true);
|
||||
|
||||
if let Err(e) = socket.bind(&bind_addr.into()) {
|
||||
state.add_log(format!("UDP порт {} занят ({})", DISCOVERY_PORT, e));
|
||||
return;
|
||||
}
|
||||
|
||||
let std_sock: StdUdpSocket = socket.into();
|
||||
let receiver = match UdpSocket::from_std(std_sock) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
match receiver.recv_from(&mut buf).await {
|
||||
Ok((len, remote_addr)) => {
|
||||
if let Ok(beacon) = serde_json::from_slice::<BeaconPacket>(&buf[..len]) {
|
||||
if beacon.fingerprint == state.my_fingerprint {
|
||||
continue;
|
||||
}
|
||||
let peer_ip = if !beacon.ip.is_empty() && beacon.ip != "0.0.0.0" {
|
||||
beacon.ip
|
||||
} else {
|
||||
remote_addr.ip().to_string()
|
||||
};
|
||||
state.upsert_peer(beacon.name, peer_ip, beacon.port, beacon.fingerprint);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let mut device_name = "My-Device".to_string();
|
||||
let mut p2p_port: u16 = 8443;
|
||||
let mut ui_port: u16 = 8080;
|
||||
let mut custom_ip: Option<String> = None;
|
||||
let cfg = parse_args().unwrap_or_else(|e| fatal(&e));
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
let mut positional_idx = 0;
|
||||
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--ip" => {
|
||||
if i + 1 < args.len() {
|
||||
custom_ip = Some(args[i + 1].clone());
|
||||
i += 1;
|
||||
// ── адреса ──
|
||||
let (bind_ip, my_ip) = match cfg.ip {
|
||||
Some(ip) if !ip.is_unspecified() => (ip, ip),
|
||||
_ => {
|
||||
let ip = net::detect_lan_ip().unwrap_or_else(|| {
|
||||
eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (укажите --ip)");
|
||||
Ipv4Addr::LOCALHOST
|
||||
});
|
||||
(Ipv4Addr::UNSPECIFIED, ip)
|
||||
}
|
||||
}
|
||||
"--name" => {
|
||||
if i + 1 < args.len() {
|
||||
device_name = args[i + 1].clone();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--p2p-port" => {
|
||||
if i + 1 < args.len() {
|
||||
if let Ok(p) = args[i + 1].parse() {
|
||||
p2p_port = p;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--ui-port" => {
|
||||
if i + 1 < args.len() {
|
||||
if let Ok(p) = args[i + 1].parse() {
|
||||
ui_port = p;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
arg if !arg.starts_with("--") => {
|
||||
match positional_idx {
|
||||
0 => device_name = arg.to_string(),
|
||||
1 => {
|
||||
if let Ok(p) = arg.parse() {
|
||||
p2p_port = p;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if let Ok(p) = arg.parse() {
|
||||
ui_port = p;
|
||||
}
|
||||
}
|
||||
3 => custom_ip = Some(arg.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
positional_idx += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let (bind_ip, my_ip) = match custom_ip {
|
||||
Some(ip) => {
|
||||
if ip == "0.0.0.0" {
|
||||
("0.0.0.0".to_string(), detect_lan_ip())
|
||||
} else {
|
||||
(ip.clone(), ip)
|
||||
}
|
||||
}
|
||||
None => ("0.0.0.0".to_string(), detect_lan_ip()),
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all(MEDIA_DIR)
|
||||
.await
|
||||
.expect("Не удалось создать папку received_media");
|
||||
// ── каталоги ──
|
||||
let data_dir = {
|
||||
let p = cfg.data_dir.clone().unwrap_or_else(|| storage::default_data_dir(cfg.p2p_port));
|
||||
if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)
|
||||
}
|
||||
};
|
||||
let media_dir = data_dir.join("received_media");
|
||||
std::fs::create_dir_all(&media_dir).unwrap_or_else(|e| fatal(&format!("создание {}: {}", media_dir.display(), e)));
|
||||
storage::cleanup_partials(&media_dir);
|
||||
|
||||
let (cert_der, key_der, my_fingerprint) = tls::load_or_create_identity(&my_ip, p2p_port);
|
||||
let peers = Arc::new(RwLock::new(HashMap::new()));
|
||||
let http_client = tls::build_pinned_http_client(peers.clone());
|
||||
let probe_client = tls::build_probe_http_client();
|
||||
// ── идентичность и доверие ──
|
||||
let identity = tls::load_or_create_identity(&data_dir, cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
|
||||
|
||||
let mut known: HashMap<String, PeerDevice> = HashMap::new();
|
||||
for t in storage::load_trusted(&data_dir) {
|
||||
if !is_valid_fp(&t.fp) || t.fp == identity.fingerprint {
|
||||
continue;
|
||||
}
|
||||
let ips: Vec<String> = t
|
||||
.ips
|
||||
.into_iter()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.take(MAX_PEER_IPS)
|
||||
.collect();
|
||||
let port = if t.port == 0 { 8443 } else { t.port };
|
||||
known.insert(
|
||||
t.fp.clone(),
|
||||
PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true),
|
||||
);
|
||||
}
|
||||
let peers = Arc::new(RwLock::new(known));
|
||||
|
||||
let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der).unwrap_or_else(|e| fatal(&e));
|
||||
let server_cfg = tls::server_config(&identity).unwrap_or_else(|e| fatal(&e));
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
|
||||
|
||||
// ── слушатели: привязываем ДО запуска задач, чтобы ошибка порта была фатальной ──
|
||||
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
|
||||
.await
|
||||
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
|
||||
let ui_listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
|
||||
.await
|
||||
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e)));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
device_name: device_name.clone(),
|
||||
my_ip: my_ip.clone(),
|
||||
p2p_port,
|
||||
my_fingerprint: my_fingerprint.clone(),
|
||||
ping_interval_sec: 5,
|
||||
device_name: cfg.name.clone(),
|
||||
my_ip: my_ip.to_string(),
|
||||
p2p_port: cfg.p2p_port,
|
||||
my_fingerprint: identity.fingerprint.clone(),
|
||||
cert_der: identity.cert_der,
|
||||
key_der: identity.key_der,
|
||||
data_dir: data_dir.clone(),
|
||||
media_dir: media_dir.clone(),
|
||||
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
||||
disk_media: RwLock::new(VecDeque::new()),
|
||||
disk_media: RwLock::new(storage::scan_media_dir(&media_dir).into_iter().collect()),
|
||||
peers,
|
||||
http_client,
|
||||
probe_client,
|
||||
transfers: RwLock::new(Vec::new()),
|
||||
scan_status: RwLock::new(ScanProgress::default()),
|
||||
logs: RwLock::new(VecDeque::new()),
|
||||
incoming_slots: Arc::new(Semaphore::new(MAX_INCOMING)),
|
||||
ping_lock: Mutex::new(()),
|
||||
trust_dirty: AtomicBool::new(false),
|
||||
probe_cooldown: PlMutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
state.add_log(format!(
|
||||
"Узел '{}' запущен на {}:{} (PIN: {})",
|
||||
device_name,
|
||||
"Узел '{}' запущен на {}:{} (ID: {})",
|
||||
cfg.name,
|
||||
my_ip,
|
||||
p2p_port,
|
||||
&my_fingerprint[..8].to_uppercase()
|
||||
cfg.p2p_port,
|
||||
short_id(&state.my_fingerprint)
|
||||
));
|
||||
|
||||
start_udp_discovery(state.clone());
|
||||
net::start_discovery(state.clone());
|
||||
|
||||
let ping_state = state.clone();
|
||||
// периодический опрос + обслуживание (без наложения тиков)
|
||||
{
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut iv = tokio::time::interval(Duration::from_secs(5));
|
||||
iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(ping_state.ping_interval_sec)).await;
|
||||
ping_all_peers(&ping_state).await;
|
||||
iv.tick().await;
|
||||
net::ping_all_peers(&st).await;
|
||||
st.maintenance();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
|
||||
.await
|
||||
.expect("Ошибка конфигурации TLS");
|
||||
|
||||
let p2p_router = Router::new()
|
||||
.route("/p2p/info", get(p2p::p2p_info))
|
||||
.route("/p2p/text", post(p2p::p2p_receive_text))
|
||||
.route("/p2p/media", post(p2p::p2p_receive_media))
|
||||
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||
.with_state(state.clone());
|
||||
|
||||
let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port)
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("Некорректный адрес P2P: {}:{}", bind_ip, p2p_port));
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum_server::bind_rustls(p2p_addr, tls_config)
|
||||
.serve(p2p_router.into_make_service())
|
||||
.await
|
||||
.expect("Ошибка P2P HTTPS-сервера");
|
||||
// ── UI ──
|
||||
let guard = Arc::new(ui::UiGuard {
|
||||
token: random_hex(16),
|
||||
cookie_name: format!("rs_token_{}", cfg.ui_port),
|
||||
hosts: vec![
|
||||
format!("127.0.0.1:{}", cfg.ui_port),
|
||||
format!("localhost:{}", cfg.ui_port),
|
||||
],
|
||||
});
|
||||
let ui_url = format!("http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token);
|
||||
|
||||
let ui_router = Router::new()
|
||||
.route("/", get(ui::ui_index))
|
||||
.route("/app.js", get(ui::ui_js))
|
||||
.route("/ui/state", get(ui::ui_get_state))
|
||||
.route("/ui/manual-peer", post(ui::ui_add_manual_peer))
|
||||
.route("/ui/scan-range", post(ui::ui_scan_range))
|
||||
@@ -328,31 +216,32 @@ async fn main() {
|
||||
.route("/ui/trust", post(ui::ui_trust_peer))
|
||||
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||
.route("/ui/send-text", post(ui::ui_send_text))
|
||||
.route("/ui/send-media", post(ui::ui_send_media))
|
||||
.route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора
|
||||
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
|
||||
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||
.with_state(state);
|
||||
|
||||
let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port)
|
||||
.parse()
|
||||
.expect("Некорректный порт UI");
|
||||
.layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов
|
||||
.layer(middleware::from_fn_with_state(guard, ui::ui_guard))
|
||||
.with_state(state.clone());
|
||||
|
||||
println!("==============================================================");
|
||||
println!(" Устройство: {}", device_name);
|
||||
println!(" P2P сокет (HTTPS): https://{}:{}", bind_ip, p2p_port);
|
||||
println!(" Сетевой IP в локальной сети: {}", my_ip);
|
||||
println!(
|
||||
" ПОСТОЯННЫЙ PIN УСТРОЙСТВА: [ {} ]",
|
||||
&my_fingerprint[..8].to_uppercase()
|
||||
);
|
||||
println!(" Файл постоянной идентичности: .identity_{}.json", p2p_port);
|
||||
println!(" Папка для медиа на диске: ./{}/", MEDIA_DIR);
|
||||
println!(" Устройство: {}", cfg.name);
|
||||
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
|
||||
println!(" Адрес в сети: {}", my_ip);
|
||||
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
|
||||
println!(" Каталог данных: {}", data_dir.display());
|
||||
println!("--------------------------------------------------------------");
|
||||
println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port);
|
||||
println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):");
|
||||
println!(" {}", ui_url);
|
||||
println!("==============================================================");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(ui_addr)
|
||||
.await
|
||||
.expect("Не удалось открыть порт локального UI");
|
||||
axum::serve(listener, ui_router).await.unwrap();
|
||||
let ui_task = tokio::spawn(async move { axum::serve(ui_listener, ui_router).await });
|
||||
let p2p_task = tokio::spawn(p2p::serve(p2p_listener, acceptor, p2p::router(state.clone())));
|
||||
|
||||
tokio::select! {
|
||||
r = ui_task => eprintln!("UI-сервер остановился: {:?}", r),
|
||||
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
|
||||
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
|
||||
}
|
||||
|
||||
state.persist_trust();
|
||||
storage::cleanup_partials(&media_dir);
|
||||
}
|
||||
+508
-199
@@ -1,24 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::Ipv4Addr,
|
||||
sync::{Arc, RwLock},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
use crate::storage::{self, TrustedEntry};
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use parking_lot::{Mutex as PlMutex, RwLock};
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{hash_map::Entry, BTreeSet, HashMap, VecDeque},
|
||||
fmt::Display,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024;
|
||||
// ───────────────────────── Лимиты ─────────────────────────
|
||||
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл
|
||||
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // всего в received_media
|
||||
pub const MAX_TEXT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_RAM_TEXTS: usize = 50;
|
||||
pub const MAX_LOGS: usize = 100;
|
||||
pub const MEDIA_DIR: &str = "received_media";
|
||||
pub const MAX_PEERS: usize = 256;
|
||||
pub const MAX_PEER_IPS: usize = 4;
|
||||
pub const MAX_TRANSFERS: usize = 20;
|
||||
pub const MAX_INCOMING: usize = 4;
|
||||
pub const MAX_MEDIA_LIST: usize = 500;
|
||||
pub const DISCOVERY_PORT: u16 = 5354;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SavedIdentity {
|
||||
pub cert_hex: String,
|
||||
pub key_hex: String,
|
||||
pub fingerprint: String,
|
||||
// ───────────────────────── Ошибки ─────────────────────────
|
||||
pub struct AppError(pub StatusCode, pub String);
|
||||
|
||||
impl AppError {
|
||||
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
|
||||
Self(status, msg.into())
|
||||
}
|
||||
/// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей).
|
||||
pub fn internal(ctx: &str, e: impl Display) -> Self {
|
||||
eprintln!("[ERR] {}: {}", ctx, e);
|
||||
Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, self.1).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── DTO ─────────────────────────
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TextItem {
|
||||
pub sender: String,
|
||||
@@ -32,8 +67,10 @@ pub struct MediaItem {
|
||||
pub filename: String,
|
||||
pub saved_path: String,
|
||||
pub mime: String,
|
||||
pub size: usize,
|
||||
pub size: u64,
|
||||
pub timestamp: u64,
|
||||
pub previewable: bool,
|
||||
pub risky: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -43,18 +80,34 @@ pub struct PeerDevice {
|
||||
pub ips: Vec<String>,
|
||||
pub port: u16,
|
||||
pub fingerprint: String,
|
||||
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
||||
pub pin_code: String,
|
||||
pub verified: bool,
|
||||
pub online: bool,
|
||||
pub last_seen: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BeaconPacket {
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub fingerprint: String,
|
||||
impl PeerDevice {
|
||||
pub fn new(fp: &str, name: String, ips: Vec<String>, port: u16, my_fp: &str, verified: bool) -> Self {
|
||||
Self {
|
||||
id: fp.to_string(),
|
||||
name,
|
||||
ips,
|
||||
port,
|
||||
fingerprint: fp.to_string(),
|
||||
pin_code: sas_code(my_fp, fp),
|
||||
verified,
|
||||
online: false,
|
||||
last_seen: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -62,10 +115,9 @@ pub struct TransferProgress {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub peer_name: String,
|
||||
/// "out" (отправка) или "in" (прием)
|
||||
pub direction: String,
|
||||
pub transferred_bytes: usize,
|
||||
pub total_bytes: usize,
|
||||
pub direction: Direction,
|
||||
pub transferred_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
pub percent: u8,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
@@ -81,99 +133,8 @@ pub struct ScanProgress {
|
||||
pub found: usize,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub device_name: String,
|
||||
pub my_ip: String,
|
||||
pub p2p_port: u16,
|
||||
pub my_fingerprint: String,
|
||||
pub ping_interval_sec: u64,
|
||||
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub probe_client: reqwest::Client,
|
||||
pub transfers: RwLock<Vec<TransferProgress>>,
|
||||
pub scan_status: RwLock<ScanProgress>,
|
||||
pub logs: RwLock<VecDeque<String>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn add_log(&self, msg: impl Into<String>) {
|
||||
let text = msg.into();
|
||||
println!("[LOG] {}", text);
|
||||
let mut logs = self.logs.write().unwrap();
|
||||
if logs.len() >= MAX_LOGS {
|
||||
logs.pop_back();
|
||||
}
|
||||
logs.push_front(format!("[{}] {}", now_sec(), text));
|
||||
}
|
||||
|
||||
pub fn upsert_transfer(&self, item: TransferProgress) {
|
||||
let mut list = self.transfers.write().unwrap();
|
||||
if let Some(existing) = list.iter_mut().find(|t| t.id == item.id) {
|
||||
*existing = item;
|
||||
} else {
|
||||
list.insert(0, item);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_transfer(&self, id: &str) {
|
||||
let mut list = self.transfers.write().unwrap();
|
||||
list.retain(|t| t.id != id);
|
||||
}
|
||||
|
||||
pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) -> bool {
|
||||
if fingerprint == self.my_fingerprint || fingerprint.len() < 8 {
|
||||
return false;
|
||||
}
|
||||
if ip.is_empty() || ip == "0.0.0.0" || ip == "127.0.0.1" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pin_code = fingerprint[..8].to_uppercase();
|
||||
let now = now_sec();
|
||||
|
||||
let mut peers = self.peers.write().unwrap();
|
||||
if let Some(existing) = peers.get_mut(&fingerprint) {
|
||||
existing.name = name;
|
||||
existing.port = port;
|
||||
existing.online = true;
|
||||
existing.last_seen = now;
|
||||
if let Some(pos) = existing.ips.iter().position(|x| x == &ip) {
|
||||
existing.ips.remove(pos);
|
||||
}
|
||||
existing.ips.insert(0, ip);
|
||||
false
|
||||
} else {
|
||||
drop(peers);
|
||||
self.add_log(format!(
|
||||
"Обнаружено устройство '{}' ({}:{}) PIN: [{}]",
|
||||
name, ip, port, pin_code
|
||||
));
|
||||
let mut peers = self.peers.write().unwrap();
|
||||
peers.insert(
|
||||
fingerprint.clone(),
|
||||
PeerDevice {
|
||||
id: fingerprint.clone(),
|
||||
name,
|
||||
ips: vec![ip],
|
||||
port,
|
||||
fingerprint,
|
||||
pin_code,
|
||||
verified: false,
|
||||
online: true,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct IncomingTextPayload {
|
||||
pub sender: String,
|
||||
pub sender_fp: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -201,6 +162,240 @@ pub struct UiScanRangeRequest {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
// ───────────────────────── Состояние ─────────────────────────
|
||||
pub struct AppState {
|
||||
pub device_name: String,
|
||||
pub my_ip: String,
|
||||
pub p2p_port: u16,
|
||||
pub my_fingerprint: String,
|
||||
pub cert_der: Vec<u8>,
|
||||
pub key_der: Vec<u8>,
|
||||
pub data_dir: PathBuf,
|
||||
pub media_dir: PathBuf,
|
||||
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||
pub probe_client: reqwest::Client,
|
||||
pub transfers: RwLock<Vec<TransferProgress>>,
|
||||
pub scan_status: RwLock<ScanProgress>,
|
||||
pub logs: RwLock<VecDeque<String>>,
|
||||
pub incoming_slots: Arc<Semaphore>,
|
||||
pub ping_lock: Mutex<()>,
|
||||
pub trust_dirty: AtomicBool,
|
||||
pub probe_cooldown: PlMutex<HashMap<IpAddr, Instant>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn add_log(&self, msg: impl Into<String>) {
|
||||
// защита от log-injection: управляющие символы в именах и т.п.
|
||||
let text: String = msg
|
||||
.into()
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
println!("[LOG] {}", text);
|
||||
let mut logs = self.logs.write();
|
||||
if logs.len() >= MAX_LOGS {
|
||||
logs.pop_back();
|
||||
}
|
||||
logs.push_front(format!("[{}] {}", now_sec(), text));
|
||||
}
|
||||
|
||||
// ── передачи ──
|
||||
pub fn add_transfer(&self, t: TransferProgress) {
|
||||
let mut l = self.transfers.write();
|
||||
l.insert(0, t);
|
||||
l.truncate(MAX_TRANSFERS);
|
||||
}
|
||||
|
||||
pub fn update_transfer_bytes(&self, id: &str, bytes: u64) {
|
||||
let mut l = self.transfers.write();
|
||||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||||
t.transferred_bytes = bytes;
|
||||
t.percent = percent(bytes, t.total_bytes);
|
||||
t.updated_at = now_sec();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_transfer(&self, id: &str) {
|
||||
let mut l = self.transfers.write();
|
||||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||||
t.transferred_bytes = t.total_bytes;
|
||||
t.percent = 100;
|
||||
t.done = true;
|
||||
t.updated_at = now_sec();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_transfer(&self, id: &str, msg: String) {
|
||||
let mut l = self.transfers.write();
|
||||
if let Some(t) = l.iter_mut().find(|t| t.id == id) {
|
||||
t.done = true;
|
||||
t.error = Some(msg);
|
||||
t.updated_at = now_sec();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_transfer(&self, id: &str) {
|
||||
self.transfers.write().retain(|t| t.id != id);
|
||||
}
|
||||
|
||||
// ── доверие ──
|
||||
pub fn trusted_name(&self, fp: &str) -> Option<String> {
|
||||
self.peers
|
||||
.read()
|
||||
.get(fp)
|
||||
.filter(|p| p.verified)
|
||||
.map(|p| p.name.clone())
|
||||
}
|
||||
|
||||
pub fn persist_trust(&self) {
|
||||
let entries: Vec<TrustedEntry> = self
|
||||
.peers
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.verified)
|
||||
.map(|p| TrustedEntry {
|
||||
fp: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
ips: p.ips.clone(),
|
||||
port: p.port,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = storage::save_trusted(&self.data_dir, &entries) {
|
||||
self.add_log(format!("Не удалось сохранить список доверенных: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client_for_peer(&self, fp: &str, streaming: bool) -> Result<reqwest::Client, AppError> {
|
||||
crate::tls::pinned_client(
|
||||
fp.to_string(),
|
||||
self.peers.clone(),
|
||||
&self.cert_der,
|
||||
&self.key_der,
|
||||
streaming,
|
||||
)
|
||||
.map_err(|e| AppError::internal("tls client", e))
|
||||
}
|
||||
|
||||
// ── пиры ──
|
||||
/// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия.
|
||||
pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool {
|
||||
if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 {
|
||||
return false;
|
||||
}
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
let mut name = clean_label(name, 48);
|
||||
if name.is_empty() {
|
||||
name = "Unknown".into();
|
||||
}
|
||||
let ip_s = ip.to_string();
|
||||
let now = now_sec();
|
||||
let mut created = false;
|
||||
let mut dirty = false;
|
||||
|
||||
{
|
||||
let mut peers = self.peers.write();
|
||||
if !peers.contains_key(fp) && peers.len() >= MAX_PEERS {
|
||||
let victim = peers
|
||||
.values()
|
||||
.filter(|p| !p.verified && !p.online)
|
||||
.min_by_key(|p| p.last_seen)
|
||||
.map(|p| p.id.clone());
|
||||
match victim {
|
||||
Some(v) => {
|
||||
peers.remove(&v);
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
match peers.entry(fp.to_string()) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let p = e.get_mut();
|
||||
let changed = p.ips.first() != Some(&ip_s) || p.port != port;
|
||||
p.name = name.clone();
|
||||
p.port = port;
|
||||
p.online = true;
|
||||
p.last_seen = now;
|
||||
p.ips.retain(|x| x != &ip_s);
|
||||
p.ips.insert(0, ip_s.clone());
|
||||
p.ips.truncate(MAX_PEER_IPS);
|
||||
if p.verified && changed {
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
Entry::Vacant(v) => {
|
||||
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
|
||||
p.online = true;
|
||||
p.last_seen = now;
|
||||
v.insert(p);
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dirty {
|
||||
self.trust_dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if created {
|
||||
self.add_log(format!(
|
||||
"Обнаружено устройство '{}' ({}:{}), код сверки: {}",
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
sas_code(&self.my_fingerprint, fp)
|
||||
));
|
||||
}
|
||||
created
|
||||
}
|
||||
|
||||
/// Пир уже недавно виден по этому адресу — повторный probe по beacon не нужен.
|
||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr) -> bool {
|
||||
let ip_s = ip.to_string();
|
||||
self.peers.read().get(fp).map_or(false, |p| {
|
||||
p.online
|
||||
&& p.ips.first().map(String::as_str) == Some(ip_s.as_str())
|
||||
&& now_sec().saturating_sub(p.last_seen) < 10
|
||||
})
|
||||
}
|
||||
|
||||
pub fn probe_allowed(&self, ip: IpAddr) -> bool {
|
||||
let mut m = self.probe_cooldown.lock();
|
||||
let now = Instant::now();
|
||||
if m.len() > 512 {
|
||||
m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30));
|
||||
}
|
||||
match m.get(&ip) {
|
||||
Some(t) if now.duration_since(*t) < Duration::from_secs(10) => false,
|
||||
_ => {
|
||||
m.insert(ip, now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maintenance(&self) {
|
||||
let now = now_sec();
|
||||
self.peers
|
||||
.write()
|
||||
.retain(|_, p| p.verified || p.online || now.saturating_sub(p.last_seen) < 600);
|
||||
if self.trust_dirty.swap(false, Ordering::Relaxed) {
|
||||
self.persist_trust();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_transfer_later(state: &Arc<AppState>, id: String, secs: u64) {
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(secs)).await;
|
||||
st.remove_transfer(&id);
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────── Утилиты ─────────────────────────
|
||||
pub fn now_sec() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -208,132 +403,246 @@ pub fn now_sec() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub fn url_decode(input: &str) -> String {
|
||||
let mut bytes = Vec::with_capacity(input.len());
|
||||
let mut chars = input.as_bytes().iter().copied();
|
||||
while let Some(b) = chars.next() {
|
||||
if b == b'%' {
|
||||
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
|
||||
if let Ok(decoded) =
|
||||
u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16)
|
||||
{
|
||||
bytes.push(decoded);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if b == b'+' {
|
||||
bytes.push(b' ');
|
||||
pub fn percent(done: u64, total: u64) -> u8 {
|
||||
if total == 0 {
|
||||
100
|
||||
} else {
|
||||
bytes.push(b);
|
||||
((done as u128 * 100) / total as u128).min(100) as u8
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
pub fn fingerprint(der: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(der))
|
||||
}
|
||||
|
||||
pub fn is_valid_fp(fp: &str) -> bool {
|
||||
fp.len() == 64 && fp.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
pub fn short_id(fp: &str) -> String {
|
||||
fp.get(..8).unwrap_or(fp).to_uppercase()
|
||||
}
|
||||
|
||||
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
||||
/// Подбор ключа «под префикс» не работает: код зависит от обоих сертификатов.
|
||||
pub fn sas_code(a: &str, b: &str) -> String {
|
||||
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"ramshare-sas-v1");
|
||||
h.update(x.as_bytes());
|
||||
h.update(b"|");
|
||||
h.update(y.as_bytes());
|
||||
let digest = h.finalize();
|
||||
let s = hex::encode_upper(&digest[..10]);
|
||||
s.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
pub fn random_hex(n: usize) -> String {
|
||||
let mut b = vec![0u8; n];
|
||||
getrandom::getrandom(&mut b).expect("ОС не предоставила случайные байты");
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
/// Имя устройства и т.п.: без управляющих и bidi-символов, ограниченной длины.
|
||||
pub fn clean_label(s: &str, max_chars: usize) -> String {
|
||||
s.chars()
|
||||
.filter(|c| {
|
||||
!c.is_control() && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}')
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.chars()
|
||||
.take(max_chars)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pct_decode(s: &str) -> String {
|
||||
percent_decode_str(s).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub fn pct_encode(s: &str) -> String {
|
||||
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
h.get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
|
||||
ip.is_private() || ip.is_link_local()
|
||||
}
|
||||
|
||||
/// Принимает уже раскодированное имя. Ничего не декодирует.
|
||||
pub fn sanitize_filename(name: &str) -> String {
|
||||
let decoded = url_decode(name);
|
||||
let clean: String = decoded
|
||||
let clean: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
|
||||
_ => c,
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = clean.trim_matches('.').trim();
|
||||
let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
if trimmed.is_empty() {
|
||||
"media_file.bin".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
return "file.bin".into();
|
||||
}
|
||||
const MAX: usize = 120;
|
||||
if trimmed.chars().count() <= MAX {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
match trimmed.rfind('.') {
|
||||
Some(i) if trimmed[i..].chars().count() <= 16 => {
|
||||
let ext = &trimmed[i..];
|
||||
let keep = MAX - ext.chars().count();
|
||||
format!("{}{}", trimmed[..i].chars().take(keep).collect::<String>(), ext)
|
||||
}
|
||||
_ => trimmed.chars().take(MAX).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Парсер диапазонов IP-адресов:
|
||||
/// Поддерживает: "all", "192.168.1.1-254", "192.168.1.10-50", "192.168.1.10-192.168.1.80", "192.168.1.0/24"
|
||||
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<String>), String> {
|
||||
/// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32),
|
||||
/// одиночные адреса и списки через запятую. Только локальные (private/link-local) IPv4.
|
||||
pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec<Ipv4Addr>), String> {
|
||||
const MAX_TOTAL: usize = 1024;
|
||||
let raw = input.trim();
|
||||
let mine: Option<Ipv4Addr> = my_ip.parse().ok();
|
||||
|
||||
let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") {
|
||||
let base: Ipv4Addr = my_ip
|
||||
.parse()
|
||||
.map_err(|_| "Не удалось определить подсеть автоматически. Введите диапазон вручную.".to_string())?;
|
||||
let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?;
|
||||
let o = base.octets();
|
||||
format!("{}.{}.{}.1-254", o[0], o[1], o[2])
|
||||
} else {
|
||||
raw.to_string()
|
||||
};
|
||||
if effective.len() > 256 {
|
||||
return Err("Слишком длинная строка диапазона".into());
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut set: BTreeSet<u32> = BTreeSet::new();
|
||||
let mut tokens = 0;
|
||||
|
||||
for part in effective.split(',') {
|
||||
let token = part.trim();
|
||||
if token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cidr_base) = token.strip_suffix("/24") {
|
||||
let ip: Ipv4Addr = cidr_base
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("Некорректный CIDR адрес: {}", token))?;
|
||||
let o = ip.octets();
|
||||
for last in 1..=254u8 {
|
||||
result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last));
|
||||
tokens += 1;
|
||||
if tokens > 16 {
|
||||
return Err("Слишком много элементов в списке (максимум 16)".into());
|
||||
}
|
||||
|
||||
let (start, end): (u32, u32) = if let Some((base, pfx)) = token.split_once('/') {
|
||||
let ip: Ipv4Addr = base.trim().parse().map_err(|_| format!("Некорректный CIDR адрес: {}", token))?;
|
||||
let p: u32 = pfx.trim().parse().map_err(|_| format!("Некорректная маска: {}", token))?;
|
||||
if !(22..=32).contains(&p) {
|
||||
return Err("Поддерживаются маски от /22 до /32".into());
|
||||
}
|
||||
let mask: u32 = !0u32 << (32 - p);
|
||||
let s = u32::from(ip) & mask;
|
||||
(s, s | !mask)
|
||||
} else if let Some((left, right)) = token.split_once('-') {
|
||||
let left = left.trim();
|
||||
let right = right.trim();
|
||||
let start_ip: Ipv4Addr = left
|
||||
.parse()
|
||||
.map_err(|_| format!("Некорректный начальный IP: {}", left))?;
|
||||
let (left, right) = (left.trim(), right.trim());
|
||||
let s_ip: Ipv4Addr = left.parse().map_err(|_| format!("Некорректный начальный IP: {}", left))?;
|
||||
let s = u32::from(s_ip);
|
||||
let e = if right.contains('.') {
|
||||
let e_ip: Ipv4Addr = right.parse().map_err(|_| format!("Некорректный конечный IP: {}", right))?;
|
||||
u32::from(e_ip)
|
||||
} else {
|
||||
let last: u8 = right.parse().map_err(|_| format!("Некорректный конец диапазона: {}", right))?;
|
||||
(s & !0xFF) | last as u32
|
||||
};
|
||||
if e < s {
|
||||
return Err("Конец диапазона меньше начала".into());
|
||||
}
|
||||
(s, e)
|
||||
} else {
|
||||
let ip: Ipv4Addr = token.parse().map_err(|_| format!("Некорректный IP-адрес: {}", token))?;
|
||||
(u32::from(ip), u32::from(ip))
|
||||
};
|
||||
|
||||
if right.contains('.') {
|
||||
let end_ip: Ipv4Addr = right
|
||||
.parse()
|
||||
.map_err(|_| format!("Некорректный конечный IP: {}", right))?;
|
||||
let start_u = u32::from(start_ip);
|
||||
let end_u = u32::from(end_ip);
|
||||
if end_u < start_u {
|
||||
return Err("Конечный IP меньше начального".into());
|
||||
if end - start > MAX_TOTAL as u32 {
|
||||
return Err(format!("Диапазон слишком велик (максимум {} адресов)", MAX_TOTAL));
|
||||
}
|
||||
if end_u - start_u > 1024 {
|
||||
return Err("Диапазон слишком велик (максимум 1024 адреса за раз)".into());
|
||||
}
|
||||
for val in start_u..=end_u {
|
||||
let ip = Ipv4Addr::from(val);
|
||||
for v in start..=end {
|
||||
let ip = Ipv4Addr::from(v);
|
||||
let last = ip.octets()[3];
|
||||
if last != 0 && last != 255 {
|
||||
result.push(ip.to_string());
|
||||
if start != end && (last == 0 || last == 255) {
|
||||
continue;
|
||||
}
|
||||
if !is_lan_v4(ip) {
|
||||
return Err(format!("Сканирование разрешено только в локальных сетях: {}", ip));
|
||||
}
|
||||
} else {
|
||||
let end_last: u8 = right
|
||||
.parse()
|
||||
.map_err(|_| format!("Некорректный конец диапазона: {}", right))?;
|
||||
let o = start_ip.octets();
|
||||
let start_last = o[3];
|
||||
if end_last < start_last {
|
||||
return Err(format!("Конец диапазона ({}) меньше начала ({})", end_last, start_last));
|
||||
set.insert(v);
|
||||
if set.len() > MAX_TOTAL {
|
||||
return Err(format!("Слишком много адресов (максимум {})", MAX_TOTAL));
|
||||
}
|
||||
for last in start_last..=end_last {
|
||||
if last != 0 && last != 255 {
|
||||
result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ip: Ipv4Addr = token
|
||||
.parse()
|
||||
.map_err(|_| format!("Некорректный IP-адрес: {}", token))?;
|
||||
result.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result.retain(|ip| ip != my_ip && ip != "127.0.0.1" && ip != "0.0.0.0");
|
||||
result.dedup();
|
||||
let result: Vec<Ipv4Addr> = set
|
||||
.into_iter()
|
||||
.map(Ipv4Addr::from)
|
||||
.filter(|ip| Some(*ip) != mine && !ip.is_loopback() && !ip.is_unspecified())
|
||||
.collect();
|
||||
|
||||
if result.is_empty() {
|
||||
return Err("Диапазон не содержит доступных для сканирования IP-адресов".into());
|
||||
}
|
||||
|
||||
Ok((effective, result))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_short_form() {
|
||||
let (_, v) = parse_ip_range("192.168.1.10-12", "192.168.1.11").unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejects_public() {
|
||||
assert!(parse_ip_range("8.8.8.8", "192.168.1.2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_all_and_dedup() {
|
||||
let (_, v) = parse_ip_range("all", "10.0.0.5").unwrap();
|
||||
assert_eq!(v.len(), 253);
|
||||
let (_, v) = parse_ip_range("10.0.0.1,10.0.0.1,10.0.0.2", "10.0.0.5").unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_too_big() {
|
||||
assert!(parse_ip_range("10.0.0.0/16", "10.0.0.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filenames() {
|
||||
assert_eq!(sanitize_filename("../../etc/passwd"), "_.._etc_passwd");
|
||||
assert_eq!(sanitize_filename("a+b%25.txt"), "a+b%25.txt");
|
||||
assert_eq!(sanitize_filename("..."), "file.bin");
|
||||
assert!(sanitize_filename(&"x".repeat(500)).chars().count() <= 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fp_validation() {
|
||||
assert!(is_valid_fp(&"a".repeat(64)));
|
||||
assert!(!is_valid_fp("x');alert(1)//"));
|
||||
assert!(!is_valid_fp(&"A".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sas_symmetric() {
|
||||
let a = "a".repeat(64);
|
||||
let b = "b".repeat(64);
|
||||
assert_eq!(sas_code(&a, &b), sas_code(&b, &a));
|
||||
assert_eq!(sas_code(&a, &b).len(), 24);
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
use crate::models::*;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::Semaphore};
|
||||
|
||||
const MAX_PROBE_BODY: usize = 8 * 1024;
|
||||
|
||||
pub struct ProbeInfo {
|
||||
pub fp: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ───────────────────────── Интерфейсы ─────────────────────────
|
||||
/// (ip, broadcast) всех локальных (private/link-local) IPv4-интерфейсов.
|
||||
pub fn lan_interfaces() -> Vec<(Ipv4Addr, Option<Ipv4Addr>)> {
|
||||
if_addrs::get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|i| match &i.addr {
|
||||
if_addrs::IfAddr::V4(v4) if !i.is_loopback() && is_lan_v4(v4.ip) => Some((v4.ip, v4.broadcast)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Адрес интерфейса маршрута по умолчанию (UDP connect не отправляет пакетов и не требует интернета).
|
||||
pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
||||
if let Ok(s) = StdUdpSocket::bind("0.0.0.0:0") {
|
||||
if s.connect("192.0.2.1:9").is_ok() {
|
||||
if let Ok(SocketAddr::V4(a)) = s.local_addr() {
|
||||
if is_lan_v4(*a.ip()) {
|
||||
return Some(*a.ip());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let all = lan_interfaces();
|
||||
all.iter()
|
||||
.map(|(ip, _)| *ip)
|
||||
.find(|ip| !(ip.octets()[0] == 172 && ip.octets()[1] == 17)) // не docker0
|
||||
.or_else(|| all.first().map(|(ip, _)| *ip))
|
||||
}
|
||||
|
||||
// ───────────────────────── Probe ─────────────────────────
|
||||
/// Рукопожатие с узлом. Отпечаток берём из РЕАЛЬНО предъявленного сертификата.
|
||||
pub async fn probe(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
||||
let url = format!("https://{}:{}/p2p/info", ip, port);
|
||||
let mut resp = state
|
||||
.probe_client
|
||||
.get(&url)
|
||||
.header("x-node-name", pct_encode(&state.device_name))
|
||||
.header("x-node-port", state.p2p_port.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "узел недоступен".to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("узел вернул код {}", resp.status()));
|
||||
}
|
||||
let fp = resp
|
||||
.extensions()
|
||||
.get::<reqwest::tls::TlsInfo>()
|
||||
.and_then(|t| t.peer_certificate())
|
||||
.map(fingerprint)
|
||||
.ok_or_else(|| "не удалось получить сертификат узла".to_string())?;
|
||||
|
||||
if resp.content_length().map_or(false, |l| l as usize > MAX_PROBE_BODY) {
|
||||
return Err("слишком большой ответ".into());
|
||||
}
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = resp.chunk().await.map_err(|_| "ошибка чтения ответа".to_string())? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
if buf.len() > MAX_PROBE_BODY {
|
||||
return Err("слишком большой ответ".into());
|
||||
}
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_slice(&buf).map_err(|_| "некорректный ответ".to_string())?;
|
||||
Ok(ProbeInfo {
|
||||
fp,
|
||||
name: clean_label(v["name"].as_str().unwrap_or("Node"), 48),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn probe_and_register(state: &AppState, ip: IpAddr, port: u16) -> Result<ProbeInfo, String> {
|
||||
let info = probe(state, ip, port).await?;
|
||||
if info.fp == state.my_fingerprint {
|
||||
return Err("это ваше собственное устройство".into());
|
||||
}
|
||||
state.upsert_peer(&info.name, ip, port, &info.fp);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
// ───────────────────────── Ping ─────────────────────────
|
||||
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
||||
let Ok(_guard) = state.ping_lock.try_lock() else {
|
||||
return; // опрос уже идёт
|
||||
};
|
||||
let targets: Vec<(String, Vec<String>, u16)> = state
|
||||
.peers
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| !p.ips.is_empty())
|
||||
.map(|p| (p.id.clone(), p.ips.clone(), p.port))
|
||||
.collect();
|
||||
|
||||
futures_util::stream::iter(targets)
|
||||
.for_each_concurrent(8, |(id, ips, port)| async move {
|
||||
ping_one(state, &id, ips, port).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
||||
for ip_s in ips {
|
||||
let Ok(ip) = ip_s.parse::<IpAddr>() else { continue };
|
||||
if let Ok(info) = probe(state, ip, port).await {
|
||||
if info.fp == id {
|
||||
state.upsert_peer(&info.name, ip, port, id);
|
||||
return;
|
||||
}
|
||||
// по этому адресу теперь другое устройство — игнорируем
|
||||
}
|
||||
}
|
||||
let mut peers = state.peers.write();
|
||||
if let Some(p) = peers.get_mut(id) {
|
||||
if now_sec().saturating_sub(p.last_seen) > 15 {
|
||||
p.online = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Сканер ─────────────────────────
|
||||
pub async fn run_scan(state: Arc<AppState>, ips: Vec<Ipv4Addr>, port: u16) {
|
||||
let st = &state;
|
||||
futures_util::stream::iter(ips)
|
||||
.for_each_concurrent(48, |ip| async move {
|
||||
let addr = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
let open = matches!(
|
||||
tokio::time::timeout(Duration::from_millis(500), tokio::net::TcpStream::connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
);
|
||||
let found = open && probe_and_register(st, IpAddr::V4(ip), port).await.is_ok();
|
||||
let mut s = st.scan_status.write();
|
||||
s.scanned += 1;
|
||||
if found {
|
||||
s.found += 1;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// ───────────────────────── UDP-обнаружение (без mDNS) ─────────────────────────
|
||||
/// Beacon — лишь подсказка «я здесь». Ему НЕ доверяют: пир регистрируется только
|
||||
/// после настоящего TLS-рукопожатия (probe), поэтому подделка UDP не отравляет список адресов.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Beacon {
|
||||
v: u8,
|
||||
port: u16,
|
||||
fp: String,
|
||||
}
|
||||
|
||||
fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_broadcast(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
let addr: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DISCOVERY_PORT);
|
||||
socket.bind(&addr.into())?;
|
||||
UdpSocket::from_std(socket.into())
|
||||
}
|
||||
|
||||
pub fn start_discovery(state: Arc<AppState>) {
|
||||
// отправка
|
||||
let tx = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tx.add_log(format!("Ошибка UDP-сокета отправки: {}", e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = sock.set_broadcast(true);
|
||||
let pkt = serde_json::to_vec(&Beacon {
|
||||
v: 1,
|
||||
port: tx.p2p_port,
|
||||
fp: tx.my_fingerprint.clone(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
loop {
|
||||
// интерфейсы пересчитываются каждый цикл — смена сети подхватится сама
|
||||
let mut targets: Vec<Ipv4Addr> = lan_interfaces()
|
||||
.into_iter()
|
||||
.map(|(ip, bc)| {
|
||||
bc.unwrap_or_else(|| {
|
||||
let o = ip.octets();
|
||||
Ipv4Addr::new(o[0], o[1], o[2], 255)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
targets.push(Ipv4Addr::BROADCAST);
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
for t in targets {
|
||||
let _ = sock
|
||||
.send_to(&pkt, SocketAddr::new(IpAddr::V4(t), DISCOVERY_PORT))
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// приём
|
||||
tokio::spawn(async move {
|
||||
let socket = match bind_discovery_socket() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
state.add_log(format!(
|
||||
"UDP-порт {} недоступен ({}). Автообнаружение отключено, используйте сканер.",
|
||||
DISCOVERY_PORT, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let slots = Arc::new(Semaphore::new(8));
|
||||
let mut buf = [0u8; 512];
|
||||
loop {
|
||||
let (len, remote) = match socket.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(b) = serde_json::from_slice::<Beacon>(&buf[..len]) else { continue };
|
||||
if b.v != 1 || b.port == 0 || !is_valid_fp(&b.fp) || b.fp == state.my_fingerprint {
|
||||
continue;
|
||||
}
|
||||
// адрес — только реальный адрес источника пакета
|
||||
let IpAddr::V4(ip4) = remote.ip() else { continue };
|
||||
if !is_lan_v4(ip4) {
|
||||
continue;
|
||||
}
|
||||
let ip = IpAddr::V4(ip4);
|
||||
if state.is_fresh(&b.fp, ip) || !state.probe_allowed(ip) {
|
||||
continue;
|
||||
}
|
||||
let Ok(permit) = slots.clone().try_acquire_owned() else { continue };
|
||||
let st = state.clone();
|
||||
let port = b.port;
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let _ = probe_and_register(&st, ip, port).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+224
-162
@@ -1,195 +1,257 @@
|
||||
use crate::models::{
|
||||
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
|
||||
TransferProgress, MAX_RAM_TEXTS, MEDIA_DIR,
|
||||
};
|
||||
use crate::{models::*, storage};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use futures_util::StreamExt;
|
||||
use hyper_util::{
|
||||
rt::{TokioIo, TokioTimer},
|
||||
service::TowerToHyperService,
|
||||
};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
/// Публичный эндпоинт рукопожатия и взаимного обнаружения
|
||||
pub async fn p2p_info(
|
||||
/// Личность собеседника из TLS-рукопожатия (а не из заголовков!).
|
||||
#[derive(Clone)]
|
||||
pub struct PeerCtx {
|
||||
pub fp: String,
|
||||
pub addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/p2p/info", get(p2p_info))
|
||||
.route(
|
||||
"/p2p/text",
|
||||
post(p2p_receive_text).layer(DefaultBodyLimit::max(512 * 1024)),
|
||||
)
|
||||
// сырой Body не ограничивается DefaultBodyLimit: лимиты считаем сами при потоковом чтении
|
||||
.route("/p2p/media", post(p2p_receive_media))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Accept-loop: TLS 1.3 + обязательный клиентский сертификат; отпечаток кладётся в Extension.
|
||||
pub async fn serve(listener: TcpListener, acceptor: TlsAcceptor, app: Router) {
|
||||
let conn_slots = Arc::new(Semaphore::new(128));
|
||||
loop {
|
||||
let (tcp, addr) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("[ERR] accept: {}", e);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(permit) = conn_slots.clone().try_acquire_owned() else {
|
||||
continue; // перегрузка: молча сбрасываем соединение
|
||||
};
|
||||
let acceptor = acceptor.clone();
|
||||
let app = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
let tls = match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp)).await {
|
||||
Ok(Ok(t)) => t,
|
||||
_ => return,
|
||||
};
|
||||
let fp = match tls.get_ref().1.peer_certificates().and_then(|c| c.first()) {
|
||||
Some(c) => fingerprint(c.as_ref()),
|
||||
None => return,
|
||||
};
|
||||
let svc = app.layer(Extension(PeerCtx { fp, addr }));
|
||||
|
||||
let mut http = hyper::server::conn::http1::Builder::new();
|
||||
http.timer(TokioTimer::new());
|
||||
http.header_read_timeout(Duration::from_secs(10));
|
||||
let _ = http
|
||||
.serve_connection(TokioIo::new(tls), TowerToHyperService::new(svc))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Handlers ─────────────────────────
|
||||
/// Рукопожатие. Любой владелец сертификата регистрируется как НЕЗАВЕРЕННЫЙ пир;
|
||||
/// IP — из сокета, отпечаток — из TLS. Заголовки несут лишь отображаемое имя и порт.
|
||||
async fn p2p_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(ctx): Extension<PeerCtx>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<serde_json::Value> {
|
||||
let caller_fp = headers
|
||||
.get("X-Node-Fp")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let caller_name = headers
|
||||
.get("X-Node-Name")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(url_decode)
|
||||
.unwrap_or_default();
|
||||
let caller_ip = headers
|
||||
.get("X-Node-Ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let caller_port: u16 = headers
|
||||
.get("X-Node-Port")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8443);
|
||||
|
||||
if !caller_fp.is_empty() && !caller_ip.is_empty() {
|
||||
state.upsert_peer(caller_name, caller_ip, caller_port, caller_fp);
|
||||
if let Some(port) = hdr(&headers, "x-node-port").and_then(|v| v.parse::<u16>().ok()) {
|
||||
let name = hdr(&headers, "x-node-name").map(pct_decode).unwrap_or_default();
|
||||
state.upsert_peer(&name, ctx.addr.ip(), port, &ctx.fp);
|
||||
}
|
||||
Json(serde_json::json!({ "name": state.device_name, "port": state.p2p_port }))
|
||||
}
|
||||
|
||||
Json(serde_json::json!({
|
||||
"name": state.device_name,
|
||||
"ip": state.my_ip,
|
||||
"port": state.p2p_port,
|
||||
"fingerprint": state.my_fingerprint,
|
||||
"pin_code": state.my_fingerprint[..8].to_uppercase()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Прием текста -> сохраняется СТРОГО В ОЗУ
|
||||
pub async fn p2p_receive_text(
|
||||
async fn p2p_receive_text(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(ctx): Extension<PeerCtx>,
|
||||
Json(payload): Json<IncomingTextPayload>,
|
||||
) -> StatusCode {
|
||||
state.add_log(format!("Получен текст в ОЗУ от '{}'", payload.sender));
|
||||
|
||||
let mut ram_texts = state.ram_texts.write().unwrap();
|
||||
if ram_texts.len() >= MAX_RAM_TEXTS {
|
||||
ram_texts.pop_back();
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let sender = state
|
||||
.trusted_name(&ctx.fp)
|
||||
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
||||
if payload.text.trim().is_empty() {
|
||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
|
||||
}
|
||||
ram_texts.push_front(TextItem {
|
||||
sender: payload.sender,
|
||||
if payload.text.len() > MAX_TEXT_BYTES {
|
||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст слишком большой"));
|
||||
}
|
||||
state.add_log(format!("Получен текст в ОЗУ от '{}'", sender));
|
||||
let mut q = state.ram_texts.write();
|
||||
if q.len() >= MAX_RAM_TEXTS {
|
||||
q.pop_back();
|
||||
}
|
||||
q.push_front(TextItem {
|
||||
sender,
|
||||
text: payload.text,
|
||||
timestamp: now_sec(),
|
||||
});
|
||||
StatusCode::OK
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Прием медиаконтента (поддерживает почанковую передачу с прогресс-баром и записью на диск)
|
||||
pub async fn p2p_receive_media(
|
||||
struct TempGuard {
|
||||
path: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for TempGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_rx(state: &Arc<AppState>, id: &str, e: AppError) -> AppError {
|
||||
state.fail_transfer(id, e.1.clone());
|
||||
remove_transfer_later(state, id.to_string(), 10);
|
||||
e
|
||||
}
|
||||
|
||||
/// Потоковый приём файла: память O(1), лимиты по факту прочитанных байт, таймаут простоя.
|
||||
async fn p2p_receive_media(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(ctx): Extension<PeerCtx>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let sender = headers
|
||||
.get("X-Sender")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(url_decode)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
body: Body,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let sender = state
|
||||
.trusted_name(&ctx.fp)
|
||||
.ok_or_else(|| AppError::new(StatusCode::FORBIDDEN, "Устройство не заверено"))?;
|
||||
|
||||
let raw_filename = headers
|
||||
.get("X-Filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("file.bin");
|
||||
|
||||
let mime = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let transfer_id = headers
|
||||
.get("X-Transfer-Id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(sanitize_filename)
|
||||
.unwrap_or_else(|| format!("rx_{}", now_sec()));
|
||||
|
||||
let offset: usize = headers
|
||||
.get("X-Offset")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
let total: u64 = hdr(&headers, "x-total-size")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_size: usize = headers
|
||||
.get("X-Total-Size")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(|| body.len());
|
||||
|
||||
tokio::fs::create_dir_all(MEDIA_DIR)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let safe_name = sanitize_filename(raw_filename);
|
||||
let temp_path = PathBuf::from(MEDIA_DIR).join(format!(".part_{}_{}", transfer_id, safe_name));
|
||||
|
||||
if offset == 0 {
|
||||
tokio::fs::write(&temp_path, &body)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
} else {
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&temp_path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
file.write_all(&body)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан размер файла"))?;
|
||||
if total == 0 || total > MAX_FILE_SIZE {
|
||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Недопустимый размер файла"));
|
||||
}
|
||||
let used: u64 = state.disk_media.read().iter().map(|m| m.size).sum();
|
||||
if used.saturating_add(total) > MAX_MEDIA_QUOTA {
|
||||
return Err(AppError::new(StatusCode::INSUFFICIENT_STORAGE, "Превышена квота хранилища"));
|
||||
}
|
||||
let _slot = state
|
||||
.incoming_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| AppError::new(StatusCode::TOO_MANY_REQUESTS, "Слишком много входящих передач"))?;
|
||||
|
||||
let received_bytes = offset + body.len();
|
||||
let is_done = received_bytes >= total_size;
|
||||
let percent = if total_size == 0 {
|
||||
100
|
||||
} else {
|
||||
((received_bytes as f64 / total_size as f64) * 100.0).min(100.0) as u8
|
||||
};
|
||||
let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
|
||||
|
||||
let rx_id = format!("in_{}", transfer_id);
|
||||
state.upsert_transfer(TransferProgress {
|
||||
id: rx_id.clone(),
|
||||
tokio::fs::create_dir_all(&state.media_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::internal("mkdir", e))?;
|
||||
let tmp = state.media_dir.join(format!(".part_{}", random_hex(8)));
|
||||
let mut guard = TempGuard { path: tmp.clone(), armed: true };
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&tmp)
|
||||
.await
|
||||
.map_err(|e| AppError::internal("open tmp", e))?;
|
||||
|
||||
let id = format!("in_{}", random_hex(6));
|
||||
state.add_transfer(TransferProgress {
|
||||
id: id.clone(),
|
||||
filename: safe_name.clone(),
|
||||
peer_name: sender.clone(),
|
||||
direction: "in".to_string(),
|
||||
transferred_bytes: received_bytes,
|
||||
total_bytes: total_size,
|
||||
percent,
|
||||
done: is_done,
|
||||
direction: Direction::In,
|
||||
transferred_bytes: 0,
|
||||
total_bytes: total,
|
||||
percent: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
updated_at: now_sec(),
|
||||
});
|
||||
|
||||
if is_done {
|
||||
let final_filename = format!("{}_{}", now_sec(), safe_name);
|
||||
let final_path = PathBuf::from(MEDIA_DIR).join(&final_filename);
|
||||
|
||||
tokio::fs::rename(&temp_path, &final_path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let abs_path = std::fs::canonicalize(&final_path)
|
||||
.unwrap_or_else(|_| final_path.clone())
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
state.add_log(format!(
|
||||
"Принят медиафайл '{}' ({} байт) от '{}'",
|
||||
final_filename, total_size, sender
|
||||
));
|
||||
|
||||
let mut media_list = state.disk_media.write().unwrap();
|
||||
media_list.push_front(MediaItem {
|
||||
sender,
|
||||
filename: final_filename,
|
||||
saved_path: abs_path,
|
||||
mime,
|
||||
size: total_size,
|
||||
timestamp: now_sec(),
|
||||
});
|
||||
drop(media_list);
|
||||
|
||||
let st_cleanup = state.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
st_cleanup.remove_transfer(&rx_id);
|
||||
});
|
||||
// ── чтение потока ──
|
||||
let mut stream = std::pin::pin!(body.into_data_stream());
|
||||
let mut got: u64 = 0;
|
||||
let mut last = Instant::now();
|
||||
loop {
|
||||
let next = match tokio::time::timeout(Duration::from_secs(30), stream.next()).await {
|
||||
Ok(n) => n,
|
||||
Err(_) => return Err(fail_rx(&state, &id, AppError::new(StatusCode::REQUEST_TIMEOUT, "Таймаут передачи"))),
|
||||
};
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(fail_rx(&state, &id, AppError::new(StatusCode::BAD_REQUEST, "Обрыв передачи"))),
|
||||
};
|
||||
got += chunk.len() as u64;
|
||||
if got > total {
|
||||
return Err(fail_rx(&state, &id, AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Данных больше заявленного")));
|
||||
}
|
||||
if let Err(e) = file.write_all(&chunk).await {
|
||||
return Err(fail_rx(&state, &id, AppError::internal("write", e)));
|
||||
}
|
||||
if last.elapsed() >= Duration::from_millis(100) {
|
||||
state.update_transfer_bytes(&id, got);
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
if got != total {
|
||||
return Err(fail_rx(&state, &id, AppError::new(StatusCode::BAD_REQUEST, "Размер не совпал с заявленным")));
|
||||
}
|
||||
if let Err(e) = file.sync_all().await {
|
||||
return Err(fail_rx(&state, &id, AppError::internal("sync", e)));
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let final_name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name);
|
||||
let final_path = state.media_dir.join(&final_name);
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &final_path).await {
|
||||
return Err(fail_rx(&state, &id, AppError::internal("rename", e)));
|
||||
}
|
||||
guard.armed = false;
|
||||
|
||||
{
|
||||
let mut list = state.disk_media.write();
|
||||
list.push_front(MediaItem {
|
||||
sender: sender.clone(),
|
||||
filename: final_name.clone(),
|
||||
saved_path: final_path.display().to_string(),
|
||||
mime: storage::mime_from_name(&safe_name).into(),
|
||||
size: total,
|
||||
timestamp: now_sec(),
|
||||
previewable: storage::is_previewable(&safe_name),
|
||||
risky: storage::is_risky(&safe_name),
|
||||
});
|
||||
list.truncate(MAX_MEDIA_LIST);
|
||||
}
|
||||
state.finish_transfer(&id);
|
||||
remove_transfer_later(&state, id, 4);
|
||||
state.add_log(format!("Принят файл '{}' ({} байт) от '{}'", final_name, total, sender));
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let statusTimer = null;
|
||||
let lastData = null;
|
||||
let knownPeersKey = '';
|
||||
let rangeInitialized = false;
|
||||
let pendingTrustId = null;
|
||||
|
||||
function esc(v) {
|
||||
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s]));
|
||||
}
|
||||
|
||||
function showStatus(msg, isError = false) {
|
||||
const el = $('status');
|
||||
clearTimeout(statusTimer);
|
||||
el.style.display = 'block';
|
||||
el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)';
|
||||
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||||
el.textContent = msg;
|
||||
statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500);
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b < 1024) return `${b} Б`;
|
||||
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`;
|
||||
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`;
|
||||
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
|
||||
}
|
||||
|
||||
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
|
||||
function setHtml(el, html) {
|
||||
if (el._h !== html) { el._h = html; el.innerHTML = html; }
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, { credentials: 'same-origin', ...opts });
|
||||
if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
function postJson(path, body) {
|
||||
return api(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────── рендер ─────────────
|
||||
function renderHeader(d) {
|
||||
$('my-name').textContent = `Устройство: ${d.device_name}`;
|
||||
$('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
||||
$('my-pin').textContent = d.my_pin;
|
||||
$('media-dir').textContent = d.media_dir;
|
||||
if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) {
|
||||
const p = d.my_ip.split('.');
|
||||
$('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`;
|
||||
rangeInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderScan(scan) {
|
||||
const box = $('scan-progress-box');
|
||||
if (scan && (scan.active || scan.total > 0)) {
|
||||
box.style.display = 'block';
|
||||
const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0;
|
||||
$('scan-label').textContent = scan.active
|
||||
? `Сканирование ${scan.range}… (найдено: ${scan.found})`
|
||||
: `Сканирование ${scan.range} завершено (найдено: ${scan.found})`;
|
||||
$('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`;
|
||||
$('scan-bar-fill').style.width = `${pct}%`;
|
||||
$('btn-scan-range').disabled = scan.active;
|
||||
$('btn-scan-all').disabled = scan.active;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTransfers(list) {
|
||||
setHtml($('transfers-list'), (list || []).map((t) => {
|
||||
const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от';
|
||||
const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`;
|
||||
const note = t.error
|
||||
? `<span style="color:#f87171;">${esc(t.error)}</span>`
|
||||
: (t.done ? 'Готово' : 'Передача по TLS…');
|
||||
return `
|
||||
<div class="progress-wrap">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
|
||||
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
|
||||
<span>${esc(status)}</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
|
||||
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
|
||||
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
|
||||
<span>${note}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
function renderPeers(peers) {
|
||||
const box = $('peers-list');
|
||||
if (!peers.length) {
|
||||
setHtml(box, '<div class="muted" style="font-size:.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.</div>');
|
||||
} else {
|
||||
setHtml(box, peers.map((p) => `
|
||||
<div class="peer-item">
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600;">
|
||||
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
|
||||
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
|
||||
</div>
|
||||
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
||||
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
||||
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}"
|
||||
data-action="trust" data-id="${esc(p.id)}" data-trust="${p.verified ? '0' : '1'}">
|
||||
${p.verified ? 'Отозвать' : 'Заверить'}
|
||||
</button>
|
||||
</div>`).join(''));
|
||||
}
|
||||
|
||||
const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|');
|
||||
if (key !== knownPeersKey) {
|
||||
knownPeersKey = key;
|
||||
const sel = $('target-peer');
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = '<option value="">-- Выберите устройство --</option>' + peers.map((p) =>
|
||||
`<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${p.verified ? '✓' : '🔒 не заверен'}</option>`).join('');
|
||||
if (prev) sel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTexts(texts) {
|
||||
const box = $('ram-texts');
|
||||
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
|
||||
setHtml(box, texts.map((t, i) => `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta">
|
||||
<span>От: <b>${esc(t.sender)}</b></span>
|
||||
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
|
||||
</div>
|
||||
<div class="msg-text">${esc(t.text)}</div>
|
||||
</div>`).join(''));
|
||||
}
|
||||
|
||||
function renderMedia(media) {
|
||||
const box = $('disk-media');
|
||||
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
|
||||
setHtml(box, media.map((m) => {
|
||||
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||||
return `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
|
||||
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
|
||||
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
|
||||
</div>
|
||||
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
|
||||
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
|
||||
</div>`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const d = await (await api('/ui/state')).json();
|
||||
lastData = d;
|
||||
renderHeader(d);
|
||||
renderScan(d.scan);
|
||||
renderTransfers(d.transfers);
|
||||
renderPeers(d.peers);
|
||||
renderTexts(d.texts);
|
||||
renderMedia(d.media);
|
||||
}
|
||||
|
||||
// ───────────── действия ─────────────
|
||||
async function guarded(fn) {
|
||||
try { await fn(); } catch (e) { showStatus(e.message || String(e), true); }
|
||||
}
|
||||
|
||||
function scanIpRange(full) {
|
||||
return guarded(async () => {
|
||||
const range = full ? 'all' : $('scan-range-input').value.trim();
|
||||
if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true);
|
||||
await postJson('/ui/scan-range', { range });
|
||||
showStatus('Сканирование запущено…');
|
||||
fetchState().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function addManualPeer() {
|
||||
return guarded(async () => {
|
||||
const raw = $('manual-ip-input').value.trim();
|
||||
if (!raw) return showStatus('Введите IP-адрес устройства', true);
|
||||
let ip = raw, port = null;
|
||||
const i = raw.lastIndexOf(':');
|
||||
if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; }
|
||||
showStatus(`Связь с узлом ${ip}…`);
|
||||
await postJson('/ui/manual-peer', port ? { ip, port } : { ip });
|
||||
$('manual-ip-input').value = '';
|
||||
showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`);
|
||||
fetchState().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function pingPeers() {
|
||||
return guarded(async () => {
|
||||
await api('/ui/ping', { method: 'POST' });
|
||||
showStatus('Запущен опрос известных узлов');
|
||||
setTimeout(() => fetchState().catch(() => {}), 800);
|
||||
});
|
||||
}
|
||||
|
||||
function setTrust(id, trust) {
|
||||
return guarded(async () => {
|
||||
await postJson('/ui/trust', { peer_id: id, trust });
|
||||
showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано');
|
||||
fetchState().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function sendText() {
|
||||
return guarded(async () => {
|
||||
const peer_id = $('target-peer').value;
|
||||
const input = $('text-input');
|
||||
const text = input.value.trim();
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||
if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true);
|
||||
await postJson('/ui/send-text', { peer_id, text });
|
||||
input.value = '';
|
||||
showStatus('Текст доставлен в ОЗУ получателя');
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMedia() {
|
||||
const peer_id = $('target-peer').value;
|
||||
const fileInput = $('media-input');
|
||||
const btn = $('send-media-btn');
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!fileInput.files.length) return showStatus('Выберите файл!', true);
|
||||
const file = fileInput.files[0];
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('/ui/send-media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Target-Id': peer_id,
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
fileInput.value = '';
|
||||
showStatus(`Файл «${file.name}» передан`);
|
||||
} catch (e) {
|
||||
showStatus(e.message || String(e), true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
fetchState().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRam() {
|
||||
return guarded(async () => {
|
||||
await api('/ui/clear-ram', { method: 'POST' });
|
||||
showStatus('Буфер текстов в ОЗУ очищен');
|
||||
fetchState().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────── события (без inline-обработчиков: CSP script-src 'self') ─────────────
|
||||
$('btn-ping').addEventListener('click', pingPeers);
|
||||
$('btn-manual').addEventListener('click', addManualPeer);
|
||||
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
|
||||
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
|
||||
$('btn-send-text').addEventListener('click', sendText);
|
||||
$('send-media-btn').addEventListener('click', sendMedia);
|
||||
$('btn-clear-ram').addEventListener('click', clearRam);
|
||||
|
||||
$('text-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); }
|
||||
});
|
||||
|
||||
$('peers-list').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-action="trust"]');
|
||||
if (!btn || !lastData) return;
|
||||
const id = btn.dataset.id;
|
||||
if (btn.dataset.trust === '0') return setTrust(id, false);
|
||||
const peer = lastData.peers.find((p) => p.id === id);
|
||||
if (!peer) return;
|
||||
pendingTrustId = id;
|
||||
$('td-name').textContent = peer.name;
|
||||
$('td-code').textContent = peer.pin_code;
|
||||
$('trust-dialog').showModal();
|
||||
});
|
||||
|
||||
$('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); });
|
||||
$('td-ok').addEventListener('click', () => {
|
||||
const id = pendingTrustId;
|
||||
pendingTrustId = null;
|
||||
$('trust-dialog').close();
|
||||
if (id) setTrust(id, true);
|
||||
});
|
||||
|
||||
$('ram-texts').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-action="copy"]');
|
||||
if (!btn || !lastData) return;
|
||||
const item = lastData.texts[Number(btn.dataset.idx)];
|
||||
if (!item) return;
|
||||
navigator.clipboard.writeText(item.text).then(
|
||||
() => showStatus('Текст скопирован в буфер обмена'),
|
||||
() => showStatus('Не удалось скопировать', true),
|
||||
);
|
||||
});
|
||||
|
||||
// ───────────── опрос состояния: следующий запрос только после завершения предыдущего ─────────────
|
||||
async function poll() {
|
||||
try {
|
||||
if (!document.hidden) await fetchState();
|
||||
$('offline').style.display = 'none';
|
||||
} catch (_) {
|
||||
$('offline').style.display = 'block';
|
||||
}
|
||||
setTimeout(poll, document.hidden ? 2500 : 700);
|
||||
}
|
||||
poll();
|
||||
+66
-360
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LAN Share — Постоянный PIN | Текст в ОЗУ | Медиа на Диск</title>
|
||||
<title>LAN Share</title>
|
||||
<style>
|
||||
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||
@@ -12,33 +12,44 @@
|
||||
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
||||
.header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.15rem; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
h2 { margin: 0; font-size: 1.08rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
||||
.tag-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||
.tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||
input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 0.92rem; }
|
||||
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
|
||||
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
|
||||
input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
||||
button:hover { opacity: 0.9; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.btn-green { background: var(--green); }
|
||||
button.btn-red { background: var(--red); }
|
||||
button.btn-sm { padding: 7px 12px; font-size: 0.82rem; }
|
||||
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
|
||||
button:hover { opacity: .9; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-green { background: var(--green); }
|
||||
.btn-red { background: var(--red); }
|
||||
.btn-gray { background: #475569; }
|
||||
.btn-sm { padding: 7px 12px; font-size: .82rem; }
|
||||
.peer-item, .msg-item { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.msg-item { flex-direction: column; align-items: flex-start; }
|
||||
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--muted); }
|
||||
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: .8rem; color: var(--muted); }
|
||||
.msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||
.path-box { font-family: monospace; font-size: .78rem; color: #93c5fd; background: rgba(59,130,246,.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
|
||||
.warn { color: #fbbf24; font-size: .8rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .8rem; }
|
||||
.mono { font-family: monospace; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot-on { background: #10b981; }
|
||||
.dot-off { background: #64748b; }
|
||||
.progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 0.2s ease; }
|
||||
.sub-box { background: rgba(15, 23, 42, 0.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width .2s ease; }
|
||||
.sub-box { background: rgba(15,23,42,.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.col { display: flex; flex-direction: column; gap: 8px; }
|
||||
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
||||
#offline { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); }
|
||||
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 22px; max-width: 460px; }
|
||||
dialog::backdrop { background: rgba(0,0,0,.65); }
|
||||
.sas { font-family: monospace; font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; background: #1d4ed8; border-radius: 8px; padding: 12px; text-align: center; margin: 14px 0; word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -46,394 +57,89 @@
|
||||
<div class="header">
|
||||
<div>
|
||||
<div style="font-size:1.2rem;font-weight:700;" id="my-name">Загрузка...</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
|
||||
<div class="small muted" id="my-ip"></div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Постоянный PIN устройства:</span>
|
||||
<span class="small muted" style="margin-right:8px;">ID устройства:</span>
|
||||
<span class="pin-badge" id="my-pin">--------</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<!-- 1. Устройства в сети и сканер диапазона IP -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
<span>1. Устройства в сети</span>
|
||||
<button class="btn-sm" onclick="pingPeers()">Опросить известных</button>
|
||||
<button class="btn-sm" id="btn-ping">Опросить известных</button>
|
||||
</h2>
|
||||
|
||||
<!-- Одиночное добавление по IP -->
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="manual-ip-input" placeholder="Один IP:порт (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
||||
<button class="btn-sm" style="white-space: nowrap;" onclick="addManualPeer()">Найти IP</button>
|
||||
<div class="row" style="flex-wrap:nowrap;">
|
||||
<input type="text" id="manual-ip-input" placeholder="Один IP[:порт] (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
||||
<button class="btn-sm" style="white-space:nowrap;" id="btn-manual">Найти IP</button>
|
||||
</div>
|
||||
|
||||
<!-- Сканер диапазона IP -->
|
||||
<div class="sub-box">
|
||||
<div style="font-size: 0.82rem; color: var(--muted); font-weight: 600;">Сканер диапазона IP-адресов:</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<input type="text" id="scan-range-input" style="flex: 1; min-width: 190px;" placeholder="Напр. 192.168.1.1-254 или 192.168.1.10-50">
|
||||
<button class="btn-sm" id="btn-scan-range" onclick="scanIpRange(false)">Сканировать диапазон</button>
|
||||
<button class="btn-sm btn-green" id="btn-scan-all" onclick="scanIpRange(true)">Вся подсеть (.1–254)</button>
|
||||
<div class="small muted" style="font-weight:600;">Сканер диапазона IP-адресов (только локальные сети):</div>
|
||||
<div class="row">
|
||||
<input type="text" id="scan-range-input" style="flex:1;min-width:190px;" placeholder="Напр. 192.168.1.1-254, 10.0.0.0/24">
|
||||
<button class="btn-sm" id="btn-scan-range">Сканировать диапазон</button>
|
||||
<button class="btn-sm btn-green" id="btn-scan-all">Вся подсеть (.1–254)</button>
|
||||
</div>
|
||||
<div id="scan-progress-box" style="display:none;">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.78rem; color:var(--muted); margin-bottom:4px;">
|
||||
<div class="small muted" style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||||
<span id="scan-label">Сканирование...</span>
|
||||
<span id="scan-numbers">0 / 0</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" id="scan-bar-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств в локальной сети...</div>
|
||||
<div class="progress-bar-bg"><div class="progress-bar-fill" id="scan-bar-fill" style="width:0%;"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Отправка данных и прогресс-бар передачи -->
|
||||
<div id="peers-list" class="col"></div>
|
||||
<div class="small muted">Доверие должно быть взаимным: заверьте устройство на обеих сторонах после сверки кода.</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Отправка данных (по нажатию)</h2>
|
||||
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
||||
<h2>2. Отправка данных</h2>
|
||||
<label class="small muted">Получатель (должен быть заверен):</label>
|
||||
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
||||
|
||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
|
||||
<button onclick="sendText()">Отправить текст в ОЗУ</button>
|
||||
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
|
||||
<button id="btn-send-text">Отправить текст в ОЗУ</button>
|
||||
|
||||
<hr style="border:0;border-top:1px solid var(--border);width:100%;margin:4px 0;">
|
||||
<input type="file" id="media-input">
|
||||
<button class="btn-green" id="send-media-btn" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
||||
<button class="btn-green" id="send-media-btn">Отправить файл на диск получателя</button>
|
||||
|
||||
<!-- Контейнер прогресс-баров активных передач (исходящих и входящих) -->
|
||||
<div id="transfers-list" style="display:flex; flex-direction:column; gap:8px;"></div>
|
||||
<div id="transfers-list" class="col"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Принятый текст в ОЗУ -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
|
||||
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
|
||||
<button class="btn-red btn-sm" id="btn-clear-ram">Очистить ОЗУ</button>
|
||||
</h2>
|
||||
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||
<div id="ram-texts" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Принятые медиафайлы на диске -->
|
||||
<div class="card">
|
||||
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
|
||||
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||
<h2>Входящие файлы <span class="tag tag-disk">НА ДИСКЕ</span></h2>
|
||||
<div class="small muted mono" id="media-dir"></div>
|
||||
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let knownPeersKey = "";
|
||||
let defaultRangeInitialized = false;
|
||||
|
||||
function showStatus(msg, isError = false) {
|
||||
const el = document.getElementById('status');
|
||||
el.style.display = 'block';
|
||||
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
|
||||
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||||
el.textContent = msg;
|
||||
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} МБ`;
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
try {
|
||||
const res = await fetch('/ui/state');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
|
||||
document.getElementById('my-ip').textContent = `Наш адрес: ${data.my_ip}:${data.p2p_port} | ID: ${data.my_fingerprint.slice(0, 20)}...`;
|
||||
document.getElementById('my-pin').textContent = data.my_pin;
|
||||
|
||||
// Автоподстановка подсети в поле диапазона при первой загрузке
|
||||
if (!defaultRangeInitialized && data.my_ip && data.my_ip.includes('.')) {
|
||||
const parts = data.my_ip.split('.');
|
||||
if (parts.length === 4 && parts[0] !== '127') {
|
||||
document.getElementById('scan-range-input').value = `${parts[0]}.${parts[1]}.${parts[2]}.1-254`;
|
||||
defaultRangeInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение статуса сканирования IP
|
||||
const scanBox = document.getElementById('scan-progress-box');
|
||||
const btnScanRange = document.getElementById('btn-scan-range');
|
||||
const btnScanAll = document.getElementById('btn-scan-all');
|
||||
if (data.scan && (data.scan.active || data.scan.total > 0)) {
|
||||
scanBox.style.display = 'block';
|
||||
const pct = data.scan.total > 0 ? Math.round((data.scan.scanned / data.scan.total) * 100) : 0;
|
||||
document.getElementById('scan-label').textContent = data.scan.active
|
||||
? `Сканирование ${data.scan.range}... (найдено: ${data.scan.found})`
|
||||
: `Сканирование ${data.scan.range} завершено (найдено: ${data.scan.found})`;
|
||||
document.getElementById('scan-numbers').textContent = `${data.scan.scanned} / ${data.scan.total} (${pct}%)`;
|
||||
document.getElementById('scan-bar-fill').style.width = `${pct}%`;
|
||||
btnScanRange.disabled = data.scan.active;
|
||||
btnScanAll.disabled = data.scan.active;
|
||||
}
|
||||
|
||||
// Отображение прогресс-баров передачи файлов
|
||||
const transfersContainer = document.getElementById('transfers-list');
|
||||
if (!data.transfers || data.transfers.length === 0) {
|
||||
transfersContainer.innerHTML = '';
|
||||
} else {
|
||||
transfersContainer.innerHTML = data.transfers.map(tr => {
|
||||
const dirBadge = tr.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Прием от';
|
||||
const statusText = tr.done ? '✓ Завершено' : `${tr.percent}%`;
|
||||
return `
|
||||
<div class="progress-wrap">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.83rem; font-weight:600;">
|
||||
<span>${dirBadge} ${escapeHtml(tr.peer_name)}: <span style="color:#60a5fa;">${escapeHtml(tr.filename)}</span></span>
|
||||
<span>${statusText}</span>
|
||||
<dialog id="trust-dialog">
|
||||
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
|
||||
<div>«<b id="td-name"></b>»</div>
|
||||
<div class="sas" id="td-code"></div>
|
||||
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.</div>
|
||||
<div class="row" style="margin-top:16px;justify-content:flex-end;">
|
||||
<button class="btn-gray" id="td-cancel">Отмена</button>
|
||||
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width: ${tr.percent}%;"></div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.76rem; color:var(--muted);">
|
||||
<span>${formatBytes(tr.transferred_bytes)} из ${formatBytes(tr.total_bytes)}</span>
|
||||
<span>${tr.done ? 'Сохранено на диске' : 'Передача по TLS...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
</dialog>
|
||||
|
||||
// Список устройств
|
||||
const peersContainer = document.getElementById('peers-list');
|
||||
if (data.peers.length === 0) {
|
||||
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть (.1–254)» или введите IP вручную.</div>';
|
||||
} else {
|
||||
peersContainer.innerHTML = data.peers.map(p => {
|
||||
const ipStr = p.ips.join(', ');
|
||||
const dotClass = p.online ? 'dot-on' : 'dot-off';
|
||||
return `
|
||||
<div class="peer-item">
|
||||
<div>
|
||||
<div style="font-weight:600;">
|
||||
<span class="dot ${dotClass}"></span>${escapeHtml(p.name)}
|
||||
<span style="color:var(--muted); font-size:0.85rem;">(${escapeHtml(ipStr)}:${p.port})</span>
|
||||
</div>
|
||||
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace; margin-top:3px;">
|
||||
PIN: <b style="color:#fff;">[${p.pin_code}]</b> |
|
||||
Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.id}', ${!p.verified})">
|
||||
${p.verified ? 'Отозвать' : 'Заверить'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const currentSelectKey = data.peers.map(p => `${p.id}:${p.verified}:${p.ips[0]}`).join('|');
|
||||
if (currentSelectKey !== knownPeersKey) {
|
||||
knownPeersKey = currentSelectKey;
|
||||
const select = document.getElementById('target-peer');
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
|
||||
data.peers.map(p => `<option value="${p.id}">${escapeHtml(p.name)} [${p.pin_code}] (${p.ips[0] || ''}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
|
||||
if (prevVal) select.value = prevVal;
|
||||
}
|
||||
|
||||
const textsContainer = document.getElementById('ram-texts');
|
||||
if (data.texts.length === 0) {
|
||||
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
|
||||
} else {
|
||||
textsContainer.innerHTML = data.texts.map(t => `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta">
|
||||
<span>От: <b>${escapeHtml(t.sender)}</b></span>
|
||||
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '"')})">Скопировать</button>
|
||||
</div>
|
||||
<div class="msg-text">${escapeHtml(t.text)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const mediaContainer = document.getElementById('disk-media');
|
||||
if (data.media.length === 0) {
|
||||
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
|
||||
} else {
|
||||
mediaContainer.innerHTML = data.media.map(m => {
|
||||
const isImg = m.mime.startsWith('image/');
|
||||
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||||
return `
|
||||
<div class="msg-item">
|
||||
<div class="msg-meta">
|
||||
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
||||
<span>${formatBytes(m.size)}</span>
|
||||
</div>
|
||||
<div style="font-weight:600; margin:2px 0;">
|
||||
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
||||
</div>
|
||||
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
|
||||
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function scanIpRange(fullSubnet = false) {
|
||||
const input = document.getElementById('scan-range-input');
|
||||
const range = fullSubnet ? 'all' : input.value.trim();
|
||||
if (!fullSubnet && !range) {
|
||||
return showStatus('Введите диапазон IP (например 192.168.1.1-254 или 192.168.1.10-50)', true);
|
||||
}
|
||||
|
||||
const res = await fetch('/ui/scan-range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ range })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showStatus('Сканирование диапазона запущено...');
|
||||
fetchState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function addManualPeer() {
|
||||
const input = document.getElementById('manual-ip-input');
|
||||
const raw = input.value.trim();
|
||||
if (!raw) return showStatus('Введите IP-адрес устройства', true);
|
||||
|
||||
let ip = raw;
|
||||
let port = 8443;
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':');
|
||||
ip = parts[0];
|
||||
port = parseInt(parts[1], 10) || 8443;
|
||||
}
|
||||
|
||||
showStatus(`Связь с узлом ${ip}:${port}...`);
|
||||
const res = await fetch('/ui/manual-peer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip, port })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
input.value = '';
|
||||
showStatus(`Узел ${ip} подключен (оба устройства обменялись PIN-кодами)!`);
|
||||
fetchState();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function pingPeers() {
|
||||
await fetch('/ui/ping', { method: 'POST' });
|
||||
showStatus('Запущен опрос известных узлов');
|
||||
setTimeout(fetchState, 600);
|
||||
}
|
||||
|
||||
async function toggleTrust(peer_id, trust) {
|
||||
const res = await fetch('/ui/trust', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ peer_id, trust })
|
||||
});
|
||||
if (res.ok) {
|
||||
showStatus(trust ? 'Сертификат устройства заверен' : 'Доверие устройству отозвано');
|
||||
fetchState();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
const peer_id = document.getElementById('target-peer').value;
|
||||
const input = document.getElementById('text-input');
|
||||
const text = input.value.trim();
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||
|
||||
const res = await fetch('/ui/send-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ peer_id, text })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
input.value = '';
|
||||
showStatus('Текст успешно доставлен в ОЗУ получателя');
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMedia() {
|
||||
const peer_id = document.getElementById('target-peer').value;
|
||||
const fileInput = document.getElementById('media-input');
|
||||
const sendBtn = document.getElementById('send-media-btn');
|
||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
||||
|
||||
const file = fileInput.files[0];
|
||||
sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/ui/send-media', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Target-Id': peer_id,
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
'Content-Type': file.type || 'application/octet-stream'
|
||||
},
|
||||
body: file
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
fileInput.value = '';
|
||||
showStatus(`Файл "${file.name}" успешно передан на диск получателя`);
|
||||
} else {
|
||||
const err = await res.text();
|
||||
showStatus(err, true);
|
||||
}
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
fetchState();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRam() {
|
||||
await fetch('/ui/clear-ram', { method: 'POST' });
|
||||
fetchState();
|
||||
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
showStatus('Текст скопирован в буфер обмена');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
|
||||
}
|
||||
|
||||
document.getElementById('text-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendText();
|
||||
}
|
||||
});
|
||||
|
||||
// Опрос состояния каждые 500 мс для плавного отображения прогресс-баров
|
||||
setInterval(fetchState, 500);
|
||||
fetchState();
|
||||
</script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
use crate::models::MediaItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrustedEntry {
|
||||
pub fp: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub ips: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
pub fn default_data_dir(p2p_port: u16) -> PathBuf {
|
||||
let base: Option<PathBuf> = if cfg!(windows) {
|
||||
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"))
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
|
||||
};
|
||||
let name = if p2p_port == 8443 {
|
||||
"ramshare".to_string()
|
||||
} else {
|
||||
format!("ramshare-{}", p2p_port)
|
||||
};
|
||||
base.unwrap_or_else(|| PathBuf::from(".")).join(name)
|
||||
}
|
||||
|
||||
/// Атомарная запись: tmp + fsync + rename. Для секретов на Unix права 0600.
|
||||
pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> {
|
||||
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
|
||||
tmp_name.push(".tmp");
|
||||
let tmp = path.with_file_name(tmp_name);
|
||||
|
||||
let mut opts = fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
}
|
||||
let _ = private;
|
||||
|
||||
let res = (|| {
|
||||
let mut f = opts.open(&tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
fs::rename(&tmp, path)
|
||||
})();
|
||||
if res.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub fn load_trusted(dir: &Path) -> Vec<TrustedEntry> {
|
||||
let path = dir.join("trusted.json");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
|
||||
eprintln!("[WARN] trusted.json повреждён ({}), список доверенных пуст", e);
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_trusted(dir: &Path, entries: &[TrustedEntry]) -> io::Result<()> {
|
||||
let json = serde_json::to_vec_pretty(entries).map_err(io::Error::other)?;
|
||||
write_atomic(&dir.join("trusted.json"), &json, false)
|
||||
}
|
||||
|
||||
fn ext_of(name: &str) -> String {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mime_from_name(name: &str) -> &'static str {
|
||||
match ext_of(name).as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"pdf" => "application/pdf",
|
||||
"txt" | "md" | "log" => "text/plain",
|
||||
"mp4" => "video/mp4",
|
||||
"mp3" => "audio/mpeg",
|
||||
"zip" => "application/zip",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Только растровые изображения показываем inline.
|
||||
pub fn is_previewable(name: &str) -> bool {
|
||||
matches!(ext_of(name).as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp")
|
||||
}
|
||||
|
||||
pub fn is_risky(name: &str) -> bool {
|
||||
matches!(
|
||||
ext_of(name).as_str(),
|
||||
"exe" | "bat" | "cmd" | "com" | "scr" | "msi" | "lnk" | "js" | "jse" | "vbs" | "vbe" | "wsf" | "ps1"
|
||||
| "jar" | "apk" | "sh" | "html" | "htm" | "svg" | "hta" | "dll" | "reg" | "app" | "dmg" | "pif" | "cpl"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cleanup_partials(dir: &Path) {
|
||||
if let Ok(rd) = fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with(".part_") {
|
||||
let _ = fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Восстанавливает список принятых файлов после перезапуска.
|
||||
pub fn scan_media_dir(dir: &Path) -> Vec<MediaItem> {
|
||||
let mut items = Vec::new();
|
||||
if let Ok(rd) = fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ts = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_secs());
|
||||
items.push(MediaItem {
|
||||
sender: "—".into(),
|
||||
mime: mime_from_name(&name).into(),
|
||||
previewable: is_previewable(&name),
|
||||
risky: is_risky(&name),
|
||||
saved_path: e.path().display().to_string(),
|
||||
size: meta.len(),
|
||||
timestamp: ts,
|
||||
filename: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
items.truncate(crate::models::MAX_MEDIA_LIST);
|
||||
items
|
||||
}
|
||||
+221
-175
@@ -1,239 +1,285 @@
|
||||
use crate::models::{PeerDevice, SavedIdentity};
|
||||
use crate::{
|
||||
models::{fingerprint, PeerDevice},
|
||||
storage,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
||||
use rustls::{
|
||||
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider},
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme,
|
||||
crypto::{verify_tls13_signature, CryptoProvider},
|
||||
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime},
|
||||
server::danger::{ClientCertVerified, ClientCertVerifier},
|
||||
ClientConfig, DigitallySignedStruct, DistinguishedName, Error as TlsError, ServerConfig, SignatureScheme,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{Arc, RwLock},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
type Peers = Arc<RwLock<HashMap<String, PeerDevice>>>;
|
||||
|
||||
fn provider() -> Arc<CryptoProvider> {
|
||||
static P: OnceLock<Arc<CryptoProvider>> = OnceLock::new();
|
||||
P.get_or_init(|| Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ───────────────────────── Идентичность ─────────────────────────
|
||||
pub struct Identity {
|
||||
pub cert_der: Vec<u8>,
|
||||
pub key_der: Vec<u8>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SavedIdentity {
|
||||
cert_hex: String,
|
||||
key_hex: String,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
fn read_identity(path: &Path) -> Result<Identity, String> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("чтение {}: {}", path.display(), e))?;
|
||||
let saved: SavedIdentity =
|
||||
serde_json::from_str(&content).map_err(|e| format!("файл {} повреждён: {}", path.display(), e))?;
|
||||
let cert_der = hex::decode(&saved.cert_hex).map_err(|_| format!("{}: неверный cert_hex", path.display()))?;
|
||||
let key_der = hex::decode(&saved.key_hex).map_err(|_| format!("{}: неверный key_hex", path.display()))?;
|
||||
let fp = fingerprint(&cert_der);
|
||||
if fp != saved.fingerprint {
|
||||
return Err(format!("{}: отпечаток не совпадает с сертификатом", path.display()));
|
||||
}
|
||||
Ok(Identity { cert_der, key_der, fingerprint: fp })
|
||||
}
|
||||
|
||||
fn save_identity(path: &Path, id: &Identity) -> Result<(), String> {
|
||||
let json = serde_json::to_vec_pretty(&SavedIdentity {
|
||||
cert_hex: hex::encode(&id.cert_der),
|
||||
key_hex: hex::encode(&id.key_der),
|
||||
fingerprint: id.fingerprint.clone(),
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
storage::write_atomic(path, &json, true).map_err(|e| format!("запись {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
/// Битый файл идентичности — ошибка запуска, а НЕ тихая регенерация.
|
||||
pub fn load_or_create_identity(data_dir: &Path, legacy_port: u16) -> Result<Identity, String> {
|
||||
let path = data_dir.join("identity.json");
|
||||
if path.exists() {
|
||||
return read_identity(&path);
|
||||
}
|
||||
// миграция со старой версии (.identity_<port>.json в текущей папке)
|
||||
let legacy = PathBuf::from(format!(".identity_{}.json", legacy_port));
|
||||
if legacy.exists() {
|
||||
if let Ok(id) = read_identity(&legacy) {
|
||||
save_identity(&path, &id)?;
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
let CertifiedKey { cert, key_pair } =
|
||||
generate_simple_self_signed(vec!["ramshare.local".to_string()]).map_err(|e| format!("генерация сертификата: {e}"))?;
|
||||
let cert_der = cert.der().to_vec();
|
||||
let id = Identity {
|
||||
fingerprint: fingerprint(&cert_der),
|
||||
cert_der,
|
||||
key_der: key_pair.serialize_der(),
|
||||
};
|
||||
save_identity(&path, &id)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn key_of(der: &[u8]) -> PrivateKeyDer<'static> {
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der.to_vec()))
|
||||
}
|
||||
|
||||
// ───────────────────────── Сервер: mTLS ─────────────────────────
|
||||
/// Принимает любой самоподписанный сертификат, но ПРОВЕРЯЕТ подпись (владение ключом).
|
||||
/// Решение о доверии принимается в handler'е по отпечатку из рукопожатия.
|
||||
#[derive(Debug)]
|
||||
pub struct PinnedCertVerifier {
|
||||
peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||
provider: Arc<CryptoProvider>,
|
||||
}
|
||||
struct AnyClientCert;
|
||||
|
||||
impl PinnedCertVerifier {
|
||||
pub fn new(peers: Arc<RwLock<HashMap<String, PeerDevice>>>) -> Self {
|
||||
Self {
|
||||
peers,
|
||||
provider: Arc::new(rustls::crypto::ring::default_provider()),
|
||||
impl ClientCertVerifier for AnyClientCert {
|
||||
fn root_hint_subjects(&self) -> &[DistinguishedName] {
|
||||
&[]
|
||||
}
|
||||
fn client_auth_mandatory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn verify_client_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_now: UnixTime,
|
||||
) -> Result<ClientCertVerified, TlsError> {
|
||||
Ok(ClientCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_m: &[u8],
|
||||
_c: &CertificateDer<'_>,
|
||||
_d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
Err(TlsError::General("tls12-disabled".into()))
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
m: &[u8],
|
||||
c: &CertificateDer<'_>,
|
||||
d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
provider().signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
fn cert_fingerprint(cert: &CertificateDer<'_>) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(cert.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
pub fn server_config(id: &Identity) -> Result<Arc<ServerConfig>, String> {
|
||||
let mut cfg = ServerConfig::builder_with_provider(provider())
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_client_cert_verifier(Arc::new(AnyClientCert))
|
||||
.with_single_cert(vec![CertificateDer::from(id.cert_der.clone())], key_of(&id.key_der))
|
||||
.map_err(|e| format!("сертификат/ключ не подходят друг другу: {e}"))?;
|
||||
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||
Ok(Arc::new(cfg))
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for PinnedCertVerifier {
|
||||
// ───────────────────────── Клиенты ─────────────────────────
|
||||
/// Пиннинг на КОНКРЕТНЫЙ отпечаток + пользователь должен был заверить пира.
|
||||
#[derive(Debug)]
|
||||
struct PinVerifier {
|
||||
expected: String,
|
||||
peers: Peers,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for PinVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, TlsError> {
|
||||
let actual_fp = Self::cert_fingerprint(end_entity);
|
||||
let pin = actual_fp.get(..8).unwrap_or("UNKNOWN").to_uppercase();
|
||||
|
||||
let peers_guard = self.peers.read().unwrap();
|
||||
match peers_guard.get(&actual_fp) {
|
||||
Some(peer) if peer.verified => Ok(ServerCertVerified::assertion()),
|
||||
Some(peer) => Err(TlsError::General(format!(
|
||||
"Устройство '{}' [PIN: {}] не заверено! Нажмите «Заверить» в списке устройств.",
|
||||
peer.name, pin
|
||||
))),
|
||||
None => Err(TlsError::General(format!(
|
||||
"Неизвестный сертификат устройства [PIN: {}]. Дождитесь обнаружения или добавьте по IP.",
|
||||
pin
|
||||
))),
|
||||
let fp = fingerprint(end_entity.as_ref());
|
||||
if fp != self.expected {
|
||||
return Err(TlsError::General("peer-mismatch".into()));
|
||||
}
|
||||
let trusted = self.peers.read().get(&fp).map_or(false, |p| p.verified);
|
||||
if trusted {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
} else {
|
||||
Err(TlsError::General("peer-not-trusted".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
_m: &[u8],
|
||||
_c: &CertificateDer<'_>,
|
||||
_d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
Err(TlsError::General("tls12-disabled".into()))
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
m: &[u8],
|
||||
c: &CertificateDer<'_>,
|
||||
d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
provider().signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Принимает любой сертификат сервера; отпечаток берётся из реального рукопожатия (TlsInfo),
|
||||
/// а НЕ из JSON-ответа. Используется только для /p2p/info.
|
||||
#[derive(Debug)]
|
||||
pub struct AnyCertVerifier {
|
||||
provider: Arc<CryptoProvider>,
|
||||
}
|
||||
struct AnyServerCert;
|
||||
|
||||
impl AnyCertVerifier {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
provider: Arc::new(rustls::crypto::ring::default_provider()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for AnyCertVerifier {
|
||||
impl ServerCertVerifier for AnyServerCert {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
_e: &CertificateDer<'_>,
|
||||
_i: &[CertificateDer<'_>],
|
||||
_s: &ServerName<'_>,
|
||||
_o: &[u8],
|
||||
_n: UnixTime,
|
||||
) -> Result<ServerCertVerified, TlsError> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
_m: &[u8],
|
||||
_c: &CertificateDer<'_>,
|
||||
_d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
Err(TlsError::General("tls12-disabled".into()))
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
m: &[u8],
|
||||
c: &CertificateDer<'_>,
|
||||
d: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||
verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
verify_tls13_signature(m, c, d, &provider().signature_verification_algorithms)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
provider().signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает постоянный сертификат устройства или создает его один раз при первом запуске
|
||||
pub fn load_or_create_identity(ip: &str, port: u16) -> (Vec<u8>, Vec<u8>, String) {
|
||||
let identity_file = format!(".identity_{}.json", port);
|
||||
let path = Path::new(&identity_file);
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
if let Ok(saved) = serde_json::from_str::<SavedIdentity>(&content) {
|
||||
if let (Ok(cert_der), Ok(key_der)) =
|
||||
(hex::decode(&saved.cert_hex), hex::decode(&saved.key_hex))
|
||||
{
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&cert_der);
|
||||
let actual_fp = hex::encode(hasher.finalize());
|
||||
if actual_fp == saved.fingerprint {
|
||||
return (cert_der, key_der, actual_fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let san = vec!["localhost".to_string(), ip.to_string()];
|
||||
let CertifiedKey { cert, key_pair } =
|
||||
generate_simple_self_signed(san).expect("Ошибка генерации сертификата");
|
||||
|
||||
let cert_der = cert.der().to_vec();
|
||||
let key_der = key_pair.serialize_der();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&cert_der);
|
||||
let fingerprint = hex::encode(hasher.finalize());
|
||||
|
||||
let to_save = SavedIdentity {
|
||||
cert_hex: hex::encode(&cert_der),
|
||||
key_hex: hex::encode(&key_der),
|
||||
fingerprint: fingerprint.clone(),
|
||||
};
|
||||
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&to_save) {
|
||||
let _ = std::fs::write(path, json_str);
|
||||
}
|
||||
|
||||
(cert_der, key_der, fingerprint)
|
||||
}
|
||||
|
||||
pub fn build_pinned_http_client(
|
||||
peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||
) -> reqwest::Client {
|
||||
let verifier = Arc::new(PinnedCertVerifier::new(peers));
|
||||
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
fn client_config(
|
||||
verifier: Arc<dyn ServerCertVerifier>,
|
||||
cert: &[u8],
|
||||
key: &[u8],
|
||||
) -> Result<ClientConfig, String> {
|
||||
let mut cfg = ClientConfig::builder_with_provider(provider())
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|e| e.to_string())?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
|
||||
reqwest::Client::builder()
|
||||
.use_preconfigured_tls(rustls_client_config)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Ошибка создания HTTPS-клиента")
|
||||
.with_client_auth_cert(vec![CertificateDer::from(cert.to_vec())], key_of(key))
|
||||
.map_err(|e| e.to_string())?;
|
||||
cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn build_probe_http_client() -> reqwest::Client {
|
||||
let verifier = Arc::new(AnyCertVerifier::new());
|
||||
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
|
||||
fn base_builder(cfg: ClientConfig) -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder()
|
||||
.use_preconfigured_tls(rustls_client_config)
|
||||
.use_preconfigured_tls(cfg)
|
||||
.no_proxy() // не читать HTTP(S)_PROXY из окружения
|
||||
.redirect(reqwest::redirect::Policy::none()) // нет SSRF через 302
|
||||
.pool_max_idle_per_host(0) // отзыв доверия действует сразу
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
}
|
||||
|
||||
/// Клиент на одну передачу к конкретному пиру.
|
||||
/// streaming=true: без общего таймаута (большие файлы); простой контролирует приёмник.
|
||||
pub fn pinned_client(
|
||||
expected_fp: String,
|
||||
peers: Peers,
|
||||
cert: &[u8],
|
||||
key: &[u8],
|
||||
streaming: bool,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
let cfg = client_config(Arc::new(PinVerifier { expected: expected_fp, peers }), cert, key)?;
|
||||
let mut b = base_builder(cfg);
|
||||
if !streaming {
|
||||
b = b.timeout(Duration::from_secs(15));
|
||||
}
|
||||
b.build().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn probe_client(cert: &[u8], key: &[u8]) -> Result<reqwest::Client, String> {
|
||||
let cfg = client_config(Arc::new(AnyServerCert), cert, key)?;
|
||||
base_builder(cfg)
|
||||
.tls_info(true)
|
||||
.timeout(Duration::from_secs(4))
|
||||
.build()
|
||||
.expect("Ошибка создания probe-клиента")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,433 +1,467 @@
|
||||
use crate::models::{
|
||||
now_sec, parse_ip_range, sanitize_filename, url_decode, AppState, IncomingTextPayload,
|
||||
MediaItem, PeerDevice, ScanProgress, TextItem, TransferProgress, UiManualPeerRequest,
|
||||
UiScanRangeRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
|
||||
};
|
||||
use crate::{models::*, net, storage};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse},
|
||||
body::{Body, Bytes},
|
||||
extract::{Path, Request, State},
|
||||
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{Html, IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::sync::Semaphore;
|
||||
use futures_util::StreamExt;
|
||||
use std::{
|
||||
io,
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||
const APP_JS: &str = include_str!("static/app.js");
|
||||
|
||||
const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \
|
||||
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||
|
||||
// ───────────────────────── Защита UI (CSRF / DNS-rebinding) ─────────────────────────
|
||||
pub struct UiGuard {
|
||||
pub token: String,
|
||||
pub cookie_name: String,
|
||||
pub hosts: Vec<String>,
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn cookie_ok(headers: &HeaderMap, g: &UiGuard) -> bool {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|s| s.split(';'))
|
||||
.any(|kv| {
|
||||
kv.trim()
|
||||
.strip_prefix(g.cookie_name.as_str())
|
||||
.and_then(|r| r.strip_prefix('='))
|
||||
.map_or(false, |v| ct_eq(v.as_bytes(), g.token.as_bytes()))
|
||||
})
|
||||
}
|
||||
|
||||
fn with_security_headers(mut resp: Response) -> Response {
|
||||
let items: [(HeaderName, &'static str); 5] = [
|
||||
(header::CONTENT_SECURITY_POLICY, CSP),
|
||||
(HeaderName::from_static("x-content-type-options"), "nosniff"),
|
||||
(header::X_FRAME_OPTIONS, "DENY"),
|
||||
(header::REFERRER_POLICY, "no-referrer"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
];
|
||||
let h = resp.headers_mut();
|
||||
for (k, v) in items {
|
||||
h.entry(k).or_insert(HeaderValue::from_static(v)); // не перетираем заголовки обработчика
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// 1) Host только 127.0.0.1/localhost:порт (анти-rebinding)
|
||||
/// 2) POST только с Origin нашего же хоста (анти-CSRF)
|
||||
/// 3) Доступ по токену из консоли: /?t=… выставляет HttpOnly SameSite=Strict cookie
|
||||
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
|
||||
let headers = req.headers();
|
||||
|
||||
let host_ok = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map_or(false, |h| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(h)));
|
||||
if !host_ok {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
if !matches!(*req.method(), Method::GET | Method::HEAD) {
|
||||
let origin_ok = headers
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|o| o.strip_prefix("http://"))
|
||||
.map_or(false, |o| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(o)));
|
||||
if !origin_ok {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if req.uri().path() == "/" {
|
||||
if let Some(q) = req.uri().query() {
|
||||
let token_ok = q
|
||||
.split('&')
|
||||
.filter_map(|kv| kv.strip_prefix("t="))
|
||||
.any(|t| ct_eq(t.as_bytes(), g.token.as_bytes()));
|
||||
if token_ok {
|
||||
let mut resp = StatusCode::SEE_OTHER.into_response();
|
||||
resp.headers_mut().insert(header::LOCATION, HeaderValue::from_static("/"));
|
||||
if let Ok(c) = HeaderValue::from_str(&format!(
|
||||
"{}={}; Path=/; HttpOnly; SameSite=Strict",
|
||||
g.cookie_name, g.token
|
||||
)) {
|
||||
resp.headers_mut().insert(header::SET_COOKIE, c);
|
||||
}
|
||||
return with_security_headers(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cookie_ok(headers, &g) {
|
||||
return with_security_headers(
|
||||
(StatusCode::FORBIDDEN, "Откройте адрес из консоли (с токеном ?t=…)").into_response(),
|
||||
);
|
||||
}
|
||||
with_security_headers(next.run(req).await)
|
||||
}
|
||||
|
||||
// ───────────────────────── Статика и состояние ─────────────────────────
|
||||
pub async fn ui_index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
let mut peers_list: Vec<PeerDevice> =
|
||||
state.peers.read().unwrap().values().cloned().collect();
|
||||
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
pub async fn ui_js() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS)
|
||||
}
|
||||
|
||||
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
||||
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
|
||||
let transfers: Vec<TransferProgress> = state.transfers.read().unwrap().clone();
|
||||
let scan = state.scan_status.read().unwrap().clone();
|
||||
let logs: Vec<String> = state.logs.read().unwrap().iter().cloned().collect();
|
||||
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
||||
peers.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
||||
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
||||
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
|
||||
let scan = state.scan_status.read().clone();
|
||||
|
||||
Json(serde_json::json!({
|
||||
"device_name": state.device_name,
|
||||
"my_ip": state.my_ip,
|
||||
"p2p_port": state.p2p_port,
|
||||
"my_fingerprint": state.my_fingerprint,
|
||||
"my_pin": state.my_fingerprint[..8].to_uppercase(),
|
||||
"peers": peers_list,
|
||||
"my_pin": short_id(&state.my_fingerprint),
|
||||
"media_dir": state.media_dir.display().to_string(),
|
||||
"peers": peers,
|
||||
"texts": texts,
|
||||
"media": media,
|
||||
"transfers": transfers,
|
||||
"scan": scan,
|
||||
"logs": logs
|
||||
}))
|
||||
}
|
||||
|
||||
// ───────────────────────── Устройства ─────────────────────────
|
||||
pub async fn ui_add_manual_peer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UiManualPeerRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let ip = req.ip.trim().to_string();
|
||||
let port = req.port.unwrap_or(state.p2p_port);
|
||||
|
||||
let url = format!("https://{}:{}/p2p/info", ip, port);
|
||||
let resp = state
|
||||
.probe_client
|
||||
.get(&url)
|
||||
.header("X-Node-Fp", &state.my_fingerprint)
|
||||
.header("X-Node-Name", &state.device_name)
|
||||
.header("X-Node-Ip", &state.my_ip)
|
||||
.header("X-Node-Port", state.p2p_port.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Узел {} недоступен: {}", ip, e)))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err((StatusCode::BAD_GATEWAY, "Удаленный узел вернул ошибку".into()));
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let ip: IpAddr = req
|
||||
.ip
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| AppError::new(StatusCode::BAD_REQUEST, "Некорректный IP-адрес"))?;
|
||||
match ip {
|
||||
IpAddr::V4(v4) if is_lan_v4(v4) => {}
|
||||
_ => return Err(AppError::new(StatusCode::BAD_REQUEST, "Разрешены только локальные IPv4-адреса")),
|
||||
}
|
||||
|
||||
let info: serde_json::Value = resp
|
||||
.json()
|
||||
let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port);
|
||||
net::probe_and_register(&state, ip, port)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::BAD_GATEWAY, "Некорректный JSON от узла".into()))?;
|
||||
|
||||
let name = info["name"].as_str().unwrap_or("Remote-Node").to_string();
|
||||
let fingerprint = info["fingerprint"].as_str().unwrap_or("").to_string();
|
||||
let remote_port = info["port"].as_u64().unwrap_or(port as u64) as u16;
|
||||
|
||||
if fingerprint.len() < 8 {
|
||||
return Err((StatusCode::BAD_GATEWAY, "Недействительный отпечаток сертификата".into()));
|
||||
}
|
||||
|
||||
state.upsert_peer(name, ip, remote_port, fingerprint);
|
||||
.map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Узел {}: {}", ip, e)))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Асинхронное сканирование всего или части диапазона IP-адресов
|
||||
pub async fn ui_scan_range(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UiScanRangeRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
{
|
||||
let scan = state.scan_status.read().unwrap();
|
||||
if scan.active {
|
||||
return Err((StatusCode::CONFLICT, "Сканирование уже выполняется".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let port = req.port.unwrap_or(state.p2p_port);
|
||||
let (range_label, ips) =
|
||||
parse_ip_range(&req.range, &state.my_ip).map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port);
|
||||
let (label, ips) =
|
||||
parse_ip_range(&req.range, &state.my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
|
||||
let total = ips.len();
|
||||
|
||||
{
|
||||
let mut scan = state.scan_status.write().unwrap();
|
||||
*scan = ScanProgress {
|
||||
// проверка и установка флага — атомарно, под одним write-замком
|
||||
let mut s = state.scan_status.write();
|
||||
if s.active {
|
||||
return Err(AppError::new(StatusCode::CONFLICT, "Сканирование уже выполняется"));
|
||||
}
|
||||
*s = ScanProgress {
|
||||
active: true,
|
||||
range: range_label.clone(),
|
||||
range: label.clone(),
|
||||
scanned: 0,
|
||||
total,
|
||||
found: 0,
|
||||
};
|
||||
}
|
||||
state.add_log(format!("Запущено сканирование {} ({} адресов, порт {})", label, total, port));
|
||||
|
||||
state.add_log(format!(
|
||||
"Запущено сканирование диапазона {} ({} адресов, порт {})",
|
||||
range_label, total, port
|
||||
));
|
||||
|
||||
let state_bg = state.clone();
|
||||
let st = state.clone();
|
||||
let worker = tokio::spawn(net::run_scan(state.clone(), ips, port));
|
||||
tokio::spawn(async move {
|
||||
let semaphore = Arc::new(Semaphore::new(48));
|
||||
let mut handles = Vec::with_capacity(total);
|
||||
|
||||
for ip in ips {
|
||||
let sem = semaphore.clone();
|
||||
let st = state_bg.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
|
||||
// Быстрая проверка открытости TCP-порта (350 мс) перед HTTPS-рукопожатием
|
||||
let sock_addr: Option<SocketAddr> = format!("{}:{}", ip, port).parse().ok();
|
||||
let mut found_node = false;
|
||||
|
||||
if let Some(addr) = sock_addr {
|
||||
if tokio::time::timeout(
|
||||
Duration::from_millis(350),
|
||||
tokio::net::TcpStream::connect(addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let url = format!("https://{}:{}/p2p/info", ip, port);
|
||||
if let Ok(resp) = st
|
||||
.probe_client
|
||||
.get(&url)
|
||||
.header("X-Node-Fp", &st.my_fingerprint)
|
||||
.header("X-Node-Name", &st.device_name)
|
||||
.header("X-Node-Ip", &st.my_ip)
|
||||
.header("X-Node-Port", st.p2p_port.to_string())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if let Ok(info) = resp.json::<serde_json::Value>().await {
|
||||
let name = info["name"].as_str().unwrap_or("Node").to_string();
|
||||
let fp = info["fingerprint"].as_str().unwrap_or("").to_string();
|
||||
let r_port = info["port"].as_u64().unwrap_or(port as u64) as u16;
|
||||
if fp.len() >= 8 {
|
||||
st.upsert_peer(name, ip, r_port, fp);
|
||||
found_node = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut scan = st.scan_status.write().unwrap();
|
||||
scan.scanned += 1;
|
||||
if found_node {
|
||||
scan.found += 1;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
let found_total = {
|
||||
let mut scan = state_bg.scan_status.write().unwrap();
|
||||
scan.active = false;
|
||||
scan.found
|
||||
let _ = worker.await; // даже при панике воркера флаг active будет сброшен
|
||||
let found = {
|
||||
let mut s = st.scan_status.write();
|
||||
s.active = false;
|
||||
s.found
|
||||
};
|
||||
|
||||
state_bg.add_log(format!(
|
||||
"Сканирование {} завершено. Найдено устройств: {}",
|
||||
range_label, found_total
|
||||
));
|
||||
st.add_log(format!("Сканирование {} завершено. Найдено устройств: {}", label, found));
|
||||
});
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::ping_all_peers(&state_clone).await;
|
||||
});
|
||||
tokio::spawn(async move { net::ping_all_peers(&state).await });
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
pub async fn ui_trust_peer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UiTrustRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let mut peers = state.peers.write().unwrap();
|
||||
let peer = peers
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let msg = {
|
||||
let mut peers = state.peers.write();
|
||||
let p = peers
|
||||
.get_mut(&req.peer_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "Устройство не найдено".to_string()))?;
|
||||
|
||||
peer.verified = req.trust;
|
||||
let msg = if req.trust {
|
||||
format!("Сертификат '{}' [{}] ЗАВЕРЕН", peer.name, peer.pin_code)
|
||||
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?;
|
||||
p.verified = req.trust;
|
||||
if req.trust {
|
||||
format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code)
|
||||
} else {
|
||||
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", peer.name, peer.pin_code)
|
||||
format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code)
|
||||
}
|
||||
};
|
||||
drop(peers);
|
||||
state.persist_trust();
|
||||
state.add_log(msg);
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||
state.ram_texts.write().unwrap().clear();
|
||||
state.ram_texts.write().clear();
|
||||
state.add_log("Текстовый буфер в ОЗУ очищен");
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
// ───────────────────────── Отправка ─────────────────────────
|
||||
fn resolve_target(state: &AppState, id: &str) -> Result<(Vec<IpAddr>, u16, String), AppError> {
|
||||
let peers = state.peers.read();
|
||||
let p = peers
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Получатель не найден"))?;
|
||||
if !p.verified {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
format!("Сначала заверьте устройство '{}' (код сверки {})", p.name, p.pin_code),
|
||||
));
|
||||
}
|
||||
let ips: Vec<IpAddr> = p.ips.iter().filter_map(|s| s.parse().ok()).collect();
|
||||
if ips.is_empty() {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Адрес получателя неизвестен. Выполните опрос или сканирование.",
|
||||
));
|
||||
}
|
||||
Ok((ips, p.port, p.name.clone()))
|
||||
}
|
||||
|
||||
pub async fn ui_send_text(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UiSendTextRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let (target_ip, port, peer_name) = {
|
||||
let peers = state.peers.read().unwrap();
|
||||
let peer = peers
|
||||
.get(&req.peer_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "Получатель не найден".to_string()))?;
|
||||
if !peer.verified {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
format!(
|
||||
"Сначала заверьте сертификат устройства '{}' [PIN: {}]!",
|
||||
peer.name, peer.pin_code
|
||||
),
|
||||
));
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if req.text.trim().is_empty() {
|
||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Пустой текст"));
|
||||
}
|
||||
let ip = peer
|
||||
.ips
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "У получателя нет известного IP".to_string()))?;
|
||||
(ip, peer.port, peer.name.clone())
|
||||
};
|
||||
if req.text.len() > MAX_TEXT_BYTES {
|
||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Текст больше 64 КБ"));
|
||||
}
|
||||
let (ips, port, peer_name) = resolve_target(&state, &req.peer_id)?;
|
||||
let client = state.client_for_peer(&req.peer_id, false)?;
|
||||
let body = serde_json::to_vec(&IncomingTextPayload { text: req.text })
|
||||
.map_err(|e| AppError::internal("json", e))?;
|
||||
|
||||
let url = format!("https://{}:{}/p2p/text", target_ip, port);
|
||||
let payload = IncomingTextPayload {
|
||||
sender: state.device_name.clone(),
|
||||
sender_fp: state.my_fingerprint.clone(),
|
||||
text: req.text,
|
||||
};
|
||||
|
||||
let resp = state
|
||||
.http_client
|
||||
for ip in ips {
|
||||
let url = format!("https://{}:{}/p2p/text", ip, port);
|
||||
match client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
state.add_log(format!("Текст доставлен в ОЗУ устройства '{}'", peer_name));
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
Err((resp.status(), "Удаленный узел отклонил запрос".to_string()))
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
Ok(r) if r.status() == StatusCode::FORBIDDEN => {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Получатель ещё не заверил ваше устройство (доверие должно быть взаимным)",
|
||||
))
|
||||
}
|
||||
Ok(r) => {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Получатель отклонил запрос ({})", r.status()),
|
||||
))
|
||||
}
|
||||
Err(_) => continue, // пробуем следующий адрес
|
||||
}
|
||||
}
|
||||
Err(AppError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Не удалось установить защищённое соединение (узел недоступен или сертификат не совпал)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Почанковая отправка медиафайла с реальным отслеживанием прогресса по сети
|
||||
/// Потоковая отправка: браузер → (канал с backpressure) → TLS → получатель. Память O(1).
|
||||
pub async fn ui_send_media(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let peer_id = headers
|
||||
.get("X-Target-Id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Не указан ID получателя".to_string()))?;
|
||||
|
||||
let raw_filename = headers
|
||||
.get("X-Filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("file.bin")
|
||||
body: Body,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let peer_id = hdr(&headers, "x-target-id")
|
||||
.ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))?
|
||||
.to_string();
|
||||
|
||||
let display_name = sanitize_filename(&url_decode(&raw_filename));
|
||||
|
||||
let mime = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let (target_ip, port, peer_name) = {
|
||||
let peers = state.peers.read().unwrap();
|
||||
let peer = peers
|
||||
.get(peer_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "Получатель не найден".to_string()))?;
|
||||
if !peer.verified {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
format!(
|
||||
"Сначала заверьте сертификат устройства '{}' [PIN: {}]!",
|
||||
peer.name, peer.pin_code
|
||||
),
|
||||
));
|
||||
let display_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
|
||||
let total: u64 = hdr(&headers, "content-length")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?;
|
||||
if total == 0 {
|
||||
return Err(AppError::new(StatusCode::BAD_REQUEST, "Файл пустой"));
|
||||
}
|
||||
if total > MAX_FILE_SIZE {
|
||||
return Err(AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "Файл больше 2 ГБ"));
|
||||
}
|
||||
let ip = peer
|
||||
.ips
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "У получателя нет IP".to_string()))?;
|
||||
(ip, peer.port, peer.name.clone())
|
||||
};
|
||||
|
||||
let total_size = body.len();
|
||||
let transfer_id = format!("tx_{}_{}", now_sec(), total_size);
|
||||
let url = format!("https://{}:{}/p2p/media", target_ip, port);
|
||||
let (ips, port, peer_name) = resolve_target(&state, &peer_id)?;
|
||||
let client = state.client_for_peer(&peer_id, true)?;
|
||||
let url = format!("https://{}:{}/p2p/media", ips[0], port);
|
||||
|
||||
// Адаптивный размер чанка для плавного обновления прогресс-бара на любых файлах
|
||||
let chunk_size = if total_size < 1024 * 1024 {
|
||||
32 * 1024 // 32 КБ для файлов до 1 МБ
|
||||
} else if total_size < 15 * 1024 * 1024 {
|
||||
128 * 1024 // 128 КБ для файлов до 15 МБ
|
||||
} else {
|
||||
512 * 1024 // 512 КБ для крупных файлов
|
||||
};
|
||||
|
||||
state.upsert_transfer(TransferProgress {
|
||||
id: transfer_id.clone(),
|
||||
let tid = format!("tx_{}", random_hex(6));
|
||||
state.add_transfer(TransferProgress {
|
||||
id: tid.clone(),
|
||||
filename: display_name.clone(),
|
||||
peer_name: peer_name.clone(),
|
||||
direction: "out".to_string(),
|
||||
direction: Direction::Out,
|
||||
transferred_bytes: 0,
|
||||
total_bytes: total_size,
|
||||
total_bytes: total,
|
||||
percent: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
updated_at: now_sec(),
|
||||
});
|
||||
|
||||
let mut offset = 0usize;
|
||||
loop {
|
||||
let end = (offset + chunk_size).min(total_size);
|
||||
let chunk = body.slice(offset..end);
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, io::Error>>(4);
|
||||
let fwd_state = state.clone();
|
||||
let fwd_id = tid.clone();
|
||||
let fwd = tokio::spawn(async move {
|
||||
let mut stream = std::pin::pin!(body.into_data_stream());
|
||||
let mut sent: u64 = 0;
|
||||
let mut last = Instant::now();
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = match item {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "client aborted"))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
sent += chunk.len() as u64;
|
||||
if sent > total {
|
||||
let _ = tx.send(Err(io::Error::new(io::ErrorKind::Other, "body too long"))).await;
|
||||
return;
|
||||
}
|
||||
// если получатель не читает 30 с — закрываем поток, запрос завершится ошибкой
|
||||
match tokio::time::timeout(Duration::from_secs(30), tx.send(Ok(chunk))).await {
|
||||
Ok(Ok(())) => {}
|
||||
_ => return,
|
||||
}
|
||||
if last.elapsed() >= Duration::from_millis(100) {
|
||||
fwd_state.update_transfer_bytes(&fwd_id, sent);
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let send_res = state
|
||||
.http_client
|
||||
let result = client
|
||||
.post(&url)
|
||||
.header("X-Sender", &state.device_name)
|
||||
.header("X-Filename", &raw_filename)
|
||||
.header("X-Transfer-Id", &transfer_id)
|
||||
.header("X-Offset", offset.to_string())
|
||||
.header("X-Total-Size", total_size.to_string())
|
||||
.header(header::CONTENT_TYPE, &mime)
|
||||
.body(chunk)
|
||||
.header("x-filename", pct_encode(&display_name))
|
||||
.header("x-total-size", total.to_string())
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_LENGTH, total.to_string())
|
||||
.body(reqwest::Body::wrap_stream(ReceiverStream::new(rx)))
|
||||
.send()
|
||||
.await;
|
||||
fwd.abort();
|
||||
|
||||
match send_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
offset = end;
|
||||
let is_done = offset >= total_size;
|
||||
let percent = if total_size == 0 {
|
||||
100
|
||||
} else {
|
||||
((offset as f64 / total_size as f64) * 100.0).min(100.0) as u8
|
||||
let fail = |msg: &str, status: StatusCode| {
|
||||
state.fail_transfer(&tid, msg.to_string());
|
||||
remove_transfer_later(&state, tid.clone(), 10);
|
||||
AppError::new(status, msg)
|
||||
};
|
||||
|
||||
state.upsert_transfer(TransferProgress {
|
||||
id: transfer_id.clone(),
|
||||
filename: display_name.clone(),
|
||||
peer_name: peer_name.clone(),
|
||||
direction: "out".to_string(),
|
||||
transferred_bytes: offset,
|
||||
total_bytes: total_size,
|
||||
percent,
|
||||
done: is_done,
|
||||
error: None,
|
||||
updated_at: now_sec(),
|
||||
});
|
||||
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let err_msg = format!("Узел отклонил чанк (код {})", resp.status());
|
||||
state.remove_transfer(&transfer_id);
|
||||
return Err((resp.status(), err_msg));
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Ошибка сети при передаче файла: {}", e);
|
||||
state.remove_transfer(&transfer_id);
|
||||
return Err((StatusCode::BAD_GATEWAY, err_msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
state.finish_transfer(&tid);
|
||||
remove_transfer_later(&state, tid.clone(), 4);
|
||||
state.add_log(format!(
|
||||
"Медиафайл '{}' ({} байт) успешно доставлен на '{}'",
|
||||
display_name, total_size, peer_name
|
||||
"Файл '{}' ({} байт) доставлен на '{}'",
|
||||
display_name, total, peer_name
|
||||
));
|
||||
|
||||
let st_cleanup = state.clone();
|
||||
let tid_cleanup = transfer_id.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
st_cleanup.remove_transfer(&tid_cleanup);
|
||||
});
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Ok(r) if r.status() == StatusCode::FORBIDDEN => Err(fail(
|
||||
"Получатель ещё не заверил ваше устройство",
|
||||
StatusCode::FORBIDDEN,
|
||||
)),
|
||||
Ok(r) => Err(fail(
|
||||
&format!("Получатель отклонил передачу (код {})", r.status()),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
)),
|
||||
Err(_) => Err(fail(
|
||||
"Ошибка сети/TLS при передаче (узел недоступен, сертификат не совпал или обрыв)",
|
||||
StatusCode::BAD_GATEWAY,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ui_serve_saved_file(Path(filename): Path<String>) -> impl IntoResponse {
|
||||
let safe_name = sanitize_filename(&filename);
|
||||
let path = PathBuf::from(MEDIA_DIR).join(&safe_name);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => (StatusCode::OK, bytes).into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(),
|
||||
// ───────────────────────── Раздача принятых файлов ─────────────────────────
|
||||
pub async fn ui_serve_saved_file(State(state): State<Arc<AppState>>, Path(filename): Path<String>) -> Response {
|
||||
let not_found = || (StatusCode::NOT_FOUND, "Файл не найден").into_response();
|
||||
if filename.is_empty()
|
||||
|| filename.starts_with('.')
|
||||
|| filename.contains(|c: char| matches!(c, '/' | '\\' | '\0'))
|
||||
{
|
||||
return not_found();
|
||||
}
|
||||
let path = state.media_dir.join(&filename);
|
||||
let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() };
|
||||
if !meta.is_file() {
|
||||
return not_found();
|
||||
}
|
||||
let Ok(file) = tokio::fs::File::open(&path).await else { return not_found() };
|
||||
|
||||
let inline = storage::is_previewable(&filename);
|
||||
// Content-Type определяем САМИ по расширению, а не по заявлению отправителя
|
||||
let ctype = if inline { storage::mime_from_name(&filename) } else { "application/octet-stream" };
|
||||
let disposition = format!(
|
||||
"{}; filename*=UTF-8''{}",
|
||||
if inline { "inline" } else { "attachment" },
|
||||
pct_encode(&filename)
|
||||
);
|
||||
|
||||
let mut resp = Response::new(Body::from_stream(ReaderStream::new(file)));
|
||||
let h = resp.headers_mut();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||
h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len()));
|
||||
if let Ok(v) = HeaderValue::from_str(&disposition) {
|
||||
h.insert(header::CONTENT_DISPOSITION, v);
|
||||
}
|
||||
h.insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
h.insert(
|
||||
HeaderName::from_static("cross-origin-resource-policy"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user