This commit is contained in:
netadmin committed 2026-10-23 21:34:20 +03:00
1 parent e88dc07696
commit 5d01dfb63a
12 files changed
+2410 -1863

No files matched your search

Generated
+83 -349
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+327
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
View File
@@ -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, '&quot;')})">Скопировать</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
View File
@@ -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
View File
@@ -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())
}
+356 -322
View File
@@ -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.