Compare commits
17 Commits
97a1f27771
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
60663a461c
|
|||
|
1923ff2a6f
|
|||
|
a7b3abd54a
|
|||
|
c072b93726
|
|||
|
428af52e2f
|
|||
|
996ad5c4c8
|
|||
|
8d81b436e5
|
|||
|
547838e285
|
|||
|
c3b47047d4
|
|||
|
aeec7a0345
|
|||
|
858f4d949c
|
|||
|
35cd0657bd
|
|||
|
5ca52b5780
|
|||
|
1594c65d89
|
|||
|
24ecdbd251
|
|||
|
a3f369f437
|
|||
|
4527e23b8b
|
Generated
+1196
@@ -0,0 +1,1196 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
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 = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "upl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"http-body-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
+13
@@ -4,6 +4,19 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
time = { version = "0.3.47", features = ["formatting", "serde"] }
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tower-http = { version = "0.6.11", features = ["fs"] }
|
||||
uuid = { version = "1.23.2", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.3"
|
||||
tempfile = "3.27.0"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
|
||||
[lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# Resumable Upload Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Build a small personal web app for uploading large files without losing
|
||||
progress when the network drops, the tab closes, or the Rust server restarts.
|
||||
|
||||
The final deployment is:
|
||||
|
||||
```text
|
||||
browser -> nginx -> upl Rust server -> local filesystem
|
||||
```
|
||||
|
||||
The program should stay simple:
|
||||
|
||||
- one Rust server binary
|
||||
- one static browser UI
|
||||
- no database server
|
||||
- no frontend framework
|
||||
- no Tus/Uppy/Resumable.js for the first version
|
||||
- local filesystem metadata as the source of truth
|
||||
|
||||
## Top-Level Design
|
||||
|
||||
### Browser
|
||||
|
||||
The browser owns file selection and chunk scheduling.
|
||||
|
||||
- Let the user pick one or more files.
|
||||
- Slice it into fixed-size chunks with `Blob.slice()`.
|
||||
- Upload a few files concurrently, with a separate chunk pool per file.
|
||||
- Retry failed chunks with exponential backoff.
|
||||
- Persist pending upload state in IndexedDB.
|
||||
- Use the File System Access API when available so the same local file can be
|
||||
reopened after a browser restart without making the user browse to it again.
|
||||
|
||||
### nginx
|
||||
|
||||
nginx owns TLS, external access control, and reverse proxying.
|
||||
|
||||
- Bind the Rust server to localhost only.
|
||||
- Terminate HTTPS in nginx.
|
||||
- Protect the app because it is a personal upload tool.
|
||||
- Forward upload API requests to the Rust server without buffering whole request
|
||||
bodies before they reach Rust.
|
||||
|
||||
### Rust Server
|
||||
|
||||
The Rust server owns upload identity, chunk validation, progress reporting, and
|
||||
final assembly.
|
||||
|
||||
- Serve the static page.
|
||||
- Create upload records.
|
||||
- Accept raw binary chunk bodies.
|
||||
- Store chunks on disk as they arrive.
|
||||
- Report which chunks already exist.
|
||||
- Assemble chunks into the final file once all chunks are present.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```text
|
||||
data/
|
||||
staging/
|
||||
<upload_id>/
|
||||
meta.json
|
||||
chunks/
|
||||
000000.part
|
||||
000001.part
|
||||
000002.part
|
||||
complete/
|
||||
<safe_file_name>
|
||||
```
|
||||
|
||||
`meta.json` is the durable upload record:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "random-server-id",
|
||||
"original_name": "movie.mkv",
|
||||
"safe_name": "movie.mkv",
|
||||
"size": 1234567890,
|
||||
"last_modified": 1760000000000,
|
||||
"chunk_size": 16777216,
|
||||
"total_chunks": 74,
|
||||
"created_at": "2026-05-30T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The server should generate `upload_id`. The browser should not invent the
|
||||
primary upload identity from file metadata. File name, size, and modified time
|
||||
are useful for display and sanity checks, but they are not unique enough to be
|
||||
the durable server identity.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Keep the API small and boring.
|
||||
|
||||
```text
|
||||
GET /
|
||||
POST /api/uploads
|
||||
GET /api/uploads/:id
|
||||
PUT /api/uploads/:id/chunks/:index
|
||||
POST /api/uploads/:id/complete
|
||||
```
|
||||
|
||||
### Create Upload
|
||||
|
||||
`POST /api/uploads`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "movie.mkv",
|
||||
"size": 1234567890,
|
||||
"last_modified": 1760000000000
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"upload_id": "random-server-id",
|
||||
"chunk_size": 16777216,
|
||||
"total_chunks": 74,
|
||||
"completed_chunks": []
|
||||
}
|
||||
```
|
||||
|
||||
Start with a fixed chunk size of 16 MiB. This keeps request count reasonable
|
||||
while making failed chunks cheap enough to retry.
|
||||
|
||||
### Query Progress
|
||||
|
||||
`GET /api/uploads/:id`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"upload_id": "random-server-id",
|
||||
"name": "movie.mkv",
|
||||
"size": 1234567890,
|
||||
"chunk_size": 16777216,
|
||||
"total_chunks": 74,
|
||||
"completed_chunks": [0, 1, 2, 5]
|
||||
}
|
||||
```
|
||||
|
||||
The server can compute `completed_chunks` by scanning the chunk directory and
|
||||
checking file lengths. This avoids needing a database.
|
||||
|
||||
### Upload Chunk
|
||||
|
||||
`PUT /api/uploads/:id/chunks/:index`
|
||||
|
||||
Use a raw request body:
|
||||
|
||||
```http
|
||||
Content-Type: application/octet-stream
|
||||
```
|
||||
|
||||
Do not use multipart form uploads for chunks in the minimal version. Raw bytes
|
||||
make the Rust handler simpler and avoid multipart parsing.
|
||||
|
||||
Server rules:
|
||||
|
||||
- reject unknown upload IDs
|
||||
- reject out-of-range chunk indexes
|
||||
- reject chunks with the wrong length
|
||||
- allow the final chunk to be shorter than `chunk_size`
|
||||
- write to `000123.part.tmp` first
|
||||
- rename the temp file to `000123.part` only after the write succeeds
|
||||
- make duplicate chunk uploads idempotent when the existing chunk has the
|
||||
expected length
|
||||
|
||||
### Complete Upload
|
||||
|
||||
`POST /api/uploads/:id/complete`
|
||||
|
||||
The server should:
|
||||
|
||||
1. Load `meta.json`.
|
||||
2. Verify every expected chunk exists.
|
||||
3. Verify every chunk has the expected length.
|
||||
4. Concatenate chunks in order into a temp final file.
|
||||
5. Rename the temp final file into `data/complete/`.
|
||||
6. Return the final file path or download URL.
|
||||
|
||||
The server should not delete staging data until assembly succeeds.
|
||||
|
||||
## Resume Flow
|
||||
|
||||
### First Upload
|
||||
|
||||
1. User selects one or more files.
|
||||
2. Browser creates one selected upload row per file.
|
||||
3. Browser calls `POST /api/uploads` once for each file being started.
|
||||
4. Browser stores each returned `upload_id` and file handle in IndexedDB.
|
||||
5. Browser uploads missing chunks with bounded file and chunk concurrency pools.
|
||||
6. Browser calls `/complete` for each file when all of its chunks are uploaded.
|
||||
|
||||
### After Interruption
|
||||
|
||||
1. Browser loads pending upload records from IndexedDB.
|
||||
2. Browser calls `GET /api/uploads/:id`.
|
||||
3. Browser asks for read permission on the saved file handle.
|
||||
4. Browser compares server `completed_chunks` with total chunks.
|
||||
5. Browser uploads only missing chunks.
|
||||
6. Browser calls `/complete`.
|
||||
|
||||
The server is authoritative. Browser state helps find the file again, but
|
||||
server state decides what has actually been uploaded.
|
||||
|
||||
## Browser State
|
||||
|
||||
IndexedDB record:
|
||||
|
||||
```json
|
||||
{
|
||||
"upload_id": "random-server-id",
|
||||
"name": "movie.mkv",
|
||||
"size": 1234567890,
|
||||
"last_modified": 1760000000000,
|
||||
"chunk_size": 16777216,
|
||||
"total_chunks": 74,
|
||||
"file_handle": "<FileSystemFileHandle>",
|
||||
"updated_at": "2026-05-30T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
If `showOpenFilePicker()` is unavailable, fall back to a normal
|
||||
`<input type="file">`. That fallback can still resume server-side progress, but
|
||||
the user must reselect the same file after a page reload.
|
||||
|
||||
## Upload Scheduler
|
||||
|
||||
Start with these defaults:
|
||||
|
||||
```text
|
||||
chunk size: 16 MiB
|
||||
file concurrency: 3
|
||||
chunk concurrency per file: 3
|
||||
max retries per chunk: 5
|
||||
```
|
||||
|
||||
The scheduler should support:
|
||||
|
||||
- pause with `AbortController`
|
||||
- resume by rebuilding the missing chunk list
|
||||
- retry with exponential backoff
|
||||
- visible progress based on verified completed chunks
|
||||
|
||||
Progress should be based on chunks the server has accepted, not bytes merely
|
||||
sent by the browser.
|
||||
|
||||
## nginx Requirements
|
||||
|
||||
Example shape:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name uploads.example.com;
|
||||
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `client_max_body_size` only needs to exceed the maximum single chunk size, not
|
||||
the full file size.
|
||||
- `proxy_request_buffering off` lets the Rust server receive upload bodies
|
||||
directly instead of waiting for nginx to buffer the whole chunk first.
|
||||
- Long timeouts are useful for slow links and large chunks.
|
||||
- Add HTTP basic auth, an IP allowlist, VPN-only access, or another protection
|
||||
layer before exposing this publicly.
|
||||
|
||||
## Rust Implementation Shape
|
||||
|
||||
Suggested crates:
|
||||
|
||||
- `axum` for HTTP routing
|
||||
- `tokio` for async runtime and filesystem operations
|
||||
- `serde` and `serde_json` for metadata
|
||||
- `uuid` or `nanoid` for upload IDs
|
||||
- `tower-http` for static file serving
|
||||
|
||||
Suggested modules:
|
||||
|
||||
```text
|
||||
src/
|
||||
main.rs
|
||||
api.rs
|
||||
storage.rs
|
||||
model.rs
|
||||
static_files.rs
|
||||
```
|
||||
|
||||
`storage.rs` should be the only module that knows the on-disk layout.
|
||||
|
||||
## Validation
|
||||
|
||||
Manual checks for the MVP:
|
||||
|
||||
- upload a small file in one pass
|
||||
- upload a file larger than one chunk
|
||||
- kill the browser tab mid-upload and resume
|
||||
- restart the Rust server mid-upload and resume
|
||||
- interrupt the network and resume
|
||||
- retry a duplicate chunk and confirm it is accepted idempotently
|
||||
- attempt an invalid chunk index and confirm it is rejected
|
||||
- attempt a wrong-size non-final chunk and confirm it is rejected
|
||||
- complete an upload and compare the final file with the source file
|
||||
|
||||
Useful checksum command:
|
||||
|
||||
```sh
|
||||
sha256sum source-file data/complete/uploaded-file
|
||||
```
|
||||
|
||||
## Milestones
|
||||
|
||||
1. Serve a static page from Rust.
|
||||
2. Add upload creation and on-disk metadata.
|
||||
3. Add raw chunk upload and chunk validation.
|
||||
4. Add progress query from existing chunk files.
|
||||
5. Add browser chunk slicing and concurrency.
|
||||
6. Add IndexedDB state.
|
||||
7. Add File System Access API resume.
|
||||
8. Add completion assembly.
|
||||
9. Put the server behind nginx and verify resume still works.
|
||||
|
||||
## Explicit Non-Goals For The First Version
|
||||
|
||||
- multiple-user accounts
|
||||
- cloud object storage
|
||||
- encryption at rest
|
||||
- background service worker upload
|
||||
- content-addressed deduplication
|
||||
- full-file hashing before upload
|
||||
- Tus protocol compatibility
|
||||
- drag-and-drop polish
|
||||
- mobile browser support
|
||||
|
||||
These can be added later if they become useful, but they are unnecessary for a
|
||||
correct personal uploader.
|
||||
@@ -0,0 +1,102 @@
|
||||
# upl
|
||||
|
||||
`upl` is a small personal resumable upload service. The intended deployment is:
|
||||
|
||||
```text
|
||||
browser -> nginx -> upl Rust server -> local filesystem
|
||||
```
|
||||
|
||||
The server writes upload chunks directly into an inaccessible temp file at
|
||||
their final offsets. Once every chunk is present, completion promotes that temp
|
||||
file into the completed upload directory without replacing an existing file.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
upl
|
||||
Rust server
|
||||
src/main.rs binary entrypoint and listener setup
|
||||
src/app.rs Axum router, shared state, static file service
|
||||
src/api.rs HTTP handlers and API error responses
|
||||
src/model.rs JSON request, response, and metadata shapes
|
||||
src/storage.rs local filesystem layout, offset writes, and final promotion
|
||||
src/lib.rs library surface used by integration tests
|
||||
Browser UI
|
||||
static/index.html upload tool markup
|
||||
static/styles.css responsive tool styling
|
||||
static/app.js multi-file scheduler, retries, and browser resume state
|
||||
Deployment
|
||||
deploy/nginx/ nginx reverse proxy example
|
||||
scripts/ reusable local smoke tests
|
||||
Validation
|
||||
tests/ integration tests for server behavior
|
||||
TESTS.md reusable manual and automated test checklist
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `--bind` sets the listen address. It overrides `UPL_BIND` and defaults to
|
||||
`127.0.0.1:3000`.
|
||||
- `--static-dir` sets the static asset directory. It overrides `UPL_STATIC_DIR`
|
||||
and defaults to `static/` inside this repository.
|
||||
- `--data-dir` sets the completed upload data root. Completed files land under
|
||||
its `complete/` subdirectory. It overrides `UPL_DATA_DIR` and defaults to
|
||||
`data/` inside this repository.
|
||||
- `--temp-dir` sets the directory for upload metadata, completion markers, and
|
||||
inaccessible temp upload files. It overrides `UPL_TEMP_DIR` and defaults to
|
||||
`<data-dir>/staging`.
|
||||
- `upl --help` prints the full argument help text.
|
||||
- The server accepts request bodies up to 64 MiB, which leaves room for the
|
||||
planned 16 MiB upload chunks and matches the nginx example in `PLAN.md`.
|
||||
- Keep `UPL_TEMP_DIR` on the same filesystem as `<data-dir>/complete` for the
|
||||
cheapest final promotion. Cross-filesystem temp directories still work, but
|
||||
completion falls back to copying into a newly created final file.
|
||||
|
||||
## Common Commands
|
||||
|
||||
Use the `justfile` for routine tasks:
|
||||
|
||||
```sh
|
||||
just check
|
||||
just run
|
||||
```
|
||||
|
||||
`just check` also syntax-checks the static browser JavaScript with `node`.
|
||||
|
||||
## Browser Uploads
|
||||
|
||||
The browser UI accepts multiple selected files. `Start all` runs up to three
|
||||
file uploads at the same time, and each file still uploads up to three chunks
|
||||
concurrently. Every selected file keeps its own upload id, progress markers,
|
||||
abort controller, retry state, and saved IndexedDB resume record.
|
||||
|
||||
If a completed file with the same sanitized name already exists, the server
|
||||
rejects the upload before staging begins. The selected row is marked
|
||||
unavailable and tells the user to rename the file if they want to upload that
|
||||
copy.
|
||||
|
||||
## nginx
|
||||
|
||||
Run `upl` on localhost and put nginx in front of it for TLS and access control:
|
||||
|
||||
```sh
|
||||
UPL_BIND=127.0.0.1:3000 \
|
||||
UPL_DATA_DIR=/srv/upl/data \
|
||||
UPL_TEMP_DIR=/srv/upl/data/staging \
|
||||
upl
|
||||
```
|
||||
|
||||
Use `deploy/nginx/upl.conf.example` as the starting point for the nginx site.
|
||||
Before exposing the service, replace the certificate paths and add a protection
|
||||
layer such as HTTP basic auth, an IP allowlist, or VPN-only access. The nginx
|
||||
example aliases only `/srv/upl/data/complete`; do not expose `UPL_TEMP_DIR`.
|
||||
|
||||
For a local Docker-based reverse-proxy smoke test:
|
||||
|
||||
```sh
|
||||
just nginx-smoke
|
||||
```
|
||||
|
||||
The smoke test binds the Rust server to `0.0.0.0` so the nginx container can
|
||||
reach it through Docker's host gateway. The production nginx example keeps the
|
||||
server bound to localhost.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Test Scenarios
|
||||
|
||||
Keep this file as the reusable verification checklist while implementing
|
||||
`PLAN.md`.
|
||||
|
||||
## Automated
|
||||
|
||||
- `just check`
|
||||
- Runs formatting, all Rust tests, and clippy.
|
||||
- Current coverage:
|
||||
- `GET /` serves the static browser page.
|
||||
- `GET /healthz` reports `ok`.
|
||||
- `POST /api/uploads` creates `meta.json`, a temp upload file, and a
|
||||
completion-marker directory.
|
||||
- `POST /api/uploads` rejects an empty file name.
|
||||
- `POST /api/uploads` rejects a name that already exists in completed
|
||||
storage before staging begins.
|
||||
- `PUT /api/uploads/:id/chunks/:index` writes validated chunks into the
|
||||
temp upload file and records completion markers.
|
||||
- `PUT /api/uploads/:id/chunks/:index` rejects wrong-size chunks.
|
||||
- `PUT /api/uploads/:id/chunks/:index` rejects out-of-range indexes.
|
||||
- `PUT /api/uploads/:id/chunks/:index` accepts duplicate chunks.
|
||||
- `GET /api/uploads/:id` reports completed chunks from disk markers.
|
||||
- `POST /api/uploads/:id/complete` promotes the verified temp upload file
|
||||
and removes staging data.
|
||||
- Parallel upload requests for separate files complete without crossing
|
||||
bytes between temp upload files.
|
||||
- `POST /api/uploads/:id/complete` rejects incomplete uploads.
|
||||
- `POST /api/uploads/:id/complete` refuses to replace a completed file that
|
||||
appears after the upload was created.
|
||||
- `POST /api/uploads/:id/complete` rejects tampered temp upload files.
|
||||
- `static/app.js` passes `node --check`.
|
||||
- `just nginx-smoke`
|
||||
- Runs upl behind nginx in Docker.
|
||||
- Uploads a 17 MiB file through nginx.
|
||||
- Restarts the Rust backend mid-upload, resumes through nginx, completes, and
|
||||
compares SHA-256 hashes.
|
||||
- Serves the completed file through nginx's final-upload alias.
|
||||
|
||||
## Manual
|
||||
|
||||
These scenarios come from `PLAN.md` and remain useful for real browser and
|
||||
deployment retests.
|
||||
|
||||
- Upload a small file in one pass.
|
||||
- Upload a file larger than one chunk.
|
||||
- Select multiple files and confirm several upload rows advance at the same
|
||||
time.
|
||||
- Kill the browser tab mid-upload and resume.
|
||||
- Restart the Rust server mid-upload and resume.
|
||||
- Interrupt the network and resume.
|
||||
- Pause from the browser controls and resume.
|
||||
- Reload the page and resume from the pending upload list.
|
||||
- In a browser with the File System Access API, resume without reselecting the
|
||||
file after granting read permission.
|
||||
- In a browser without the File System Access API, resume after reselecting the
|
||||
same file.
|
||||
- Retry a duplicate chunk and confirm it is accepted idempotently.
|
||||
- Attempt an invalid chunk index and confirm it is rejected.
|
||||
- Attempt a wrong-size non-final chunk and confirm it is rejected.
|
||||
- Install `deploy/nginx/upl.conf.example` on the deployment host, add the real
|
||||
TLS certificate and access-control settings, and repeat the resume scenarios
|
||||
through the public nginx URL.
|
||||
- Complete an upload and compare the final file with the source file:
|
||||
|
||||
```sh
|
||||
sha256sum source-file data/complete/uploaded-file
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Production shape for browser -> nginx -> upl -> local filesystem.
|
||||
#
|
||||
# Replace server_name, certificate paths, and access control before exposing
|
||||
# this app. Keep upl itself bound to 127.0.0.1.
|
||||
|
||||
upstream upl_backend {
|
||||
server 127.0.0.1:3000;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name uploads.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/uploads.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/uploads.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 64m;
|
||||
|
||||
# Add HTTP basic auth, an IP allowlist, VPN-only access, or another
|
||||
# protection layer before exposing this personal upload tool publicly.
|
||||
# auth_basic "upl";
|
||||
# auth_basic_user_file /etc/nginx/upl.htpasswd;
|
||||
|
||||
# Expose only completed uploads. Keep UPL_TEMP_DIR outside every nginx
|
||||
# alias/root so in-progress temp files and progress markers are private.
|
||||
location /files/ {
|
||||
alias /srv/upl/data/complete/;
|
||||
autoindex on;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://upl_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
fmt:
|
||||
cargo +nightly fmt --all
|
||||
fd -e toml -x tombi format --quiet
|
||||
|
||||
test:
|
||||
cargo test --all-targets
|
||||
|
||||
static-check:
|
||||
node --check static/app.js
|
||||
|
||||
clippy:
|
||||
cargo clippy --all-targets
|
||||
|
||||
nginx-smoke:
|
||||
./scripts/nginx-smoke.sh
|
||||
|
||||
check:
|
||||
just fmt
|
||||
just test
|
||||
just static-check
|
||||
just clippy
|
||||
|
||||
run *args:
|
||||
cargo run -- {{args}}
|
||||
@@ -1,3 +1,4 @@
|
||||
edition = "2024"
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_granularity = "Crate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
backend_port="${UPL_SMOKE_BACKEND_PORT:-39123}"
|
||||
proxy_port="${UPL_SMOKE_PROXY_PORT:-39124}"
|
||||
nginx_image="${NGINX_IMAGE:-nginx:stable-alpine}"
|
||||
|
||||
workspace_dir="$(pwd)"
|
||||
mkdir -p "$workspace_dir/target/nginx-smoke"
|
||||
tmp_dir="$(mktemp -d "$workspace_dir/target/nginx-smoke/run.XXXXXXXX")"
|
||||
data_dir="$tmp_dir/data"
|
||||
complete_dir="$data_dir/complete"
|
||||
temp_dir="$tmp_dir/upload-temp"
|
||||
nginx_conf_dir="$tmp_dir/nginx-conf.d"
|
||||
nginx_conf="$nginx_conf_dir/default.conf"
|
||||
backend_log="$tmp_dir/backend.log"
|
||||
source_file="$tmp_dir/source.bin"
|
||||
served_file="$tmp_dir/served.bin"
|
||||
chunk0="$tmp_dir/chunk0.part"
|
||||
chunk1="$tmp_dir/chunk1.part"
|
||||
backend_pid=""
|
||||
nginx_container="upl-nginx-smoke-$$"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$backend_pid" ]] && kill -0 "$backend_pid" 2>/dev/null; then
|
||||
kill "$backend_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" 2>/dev/null || true
|
||||
fi
|
||||
docker rm -f "$nginx_container" >/dev/null 2>&1 || true
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_backend() {
|
||||
UPL_BIND="0.0.0.0:$backend_port" UPL_DATA_DIR="$data_dir" UPL_TEMP_DIR="$temp_dir" \
|
||||
cargo run --quiet >"$backend_log" 2>&1 &
|
||||
backend_pid="$!"
|
||||
wait_for "http://127.0.0.1:$backend_port/healthz"
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
local url="$1"
|
||||
for _ in $(seq 1 80); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
echo "Timed out waiting for $url" >&2
|
||||
if [[ -f "$backend_log" ]]; then
|
||||
tail -n 80 "$backend_log" >&2 || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
json_field() {
|
||||
local field="$1"
|
||||
node -e '
|
||||
const field = process.argv[1];
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => input += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
const value = JSON.parse(input)[field];
|
||||
if (value === undefined) process.exit(2);
|
||||
process.stdout.write(String(value));
|
||||
});
|
||||
' "$field"
|
||||
}
|
||||
|
||||
mkdir -p "$complete_dir" "$temp_dir" "$nginx_conf_dir"
|
||||
|
||||
cat >"$nginx_conf" <<EOF
|
||||
server {
|
||||
listen $proxy_port;
|
||||
client_max_body_size 64m;
|
||||
|
||||
location /files/ {
|
||||
alias /upl-complete/;
|
||||
autoindex off;
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://host.docker.internal:$backend_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
start_backend
|
||||
|
||||
docker run -d --rm \
|
||||
--name "$nginx_container" \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-p "127.0.0.1:$proxy_port:$proxy_port" \
|
||||
-v "$nginx_conf_dir:/etc/nginx/conf.d:ro" \
|
||||
-v "$complete_dir:/upl-complete:ro" \
|
||||
"$nginx_image" >/dev/null
|
||||
wait_for "http://127.0.0.1:$proxy_port/healthz"
|
||||
|
||||
dd if=/dev/urandom of="$source_file" bs=1M count=17 status=none
|
||||
dd if="$source_file" of="$chunk0" bs=1M count=16 status=none
|
||||
dd if="$source_file" of="$chunk1" bs=1M skip=16 status=none
|
||||
|
||||
size="$(wc -c <"$source_file" | tr -d ' ')"
|
||||
create_response="$(
|
||||
curl -fsS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"source.bin\",\"size\":$size,\"last_modified\":1760000000000}" \
|
||||
"http://127.0.0.1:$proxy_port/api/uploads"
|
||||
)"
|
||||
upload_id="$(printf '%s' "$create_response" | json_field upload_id)"
|
||||
|
||||
curl -fsS -X PUT \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$chunk0" \
|
||||
"http://127.0.0.1:$proxy_port/api/uploads/$upload_id/chunks/0" >/dev/null
|
||||
|
||||
progress_before_restart="$(
|
||||
curl -fsS "http://127.0.0.1:$proxy_port/api/uploads/$upload_id"
|
||||
)"
|
||||
printf '%s' "$progress_before_restart" | grep -q '"completed_chunks":\[0\]'
|
||||
|
||||
kill "$backend_pid"
|
||||
wait "$backend_pid" 2>/dev/null || true
|
||||
backend_pid=""
|
||||
start_backend
|
||||
|
||||
progress_after_restart="$(
|
||||
curl -fsS "http://127.0.0.1:$proxy_port/api/uploads/$upload_id"
|
||||
)"
|
||||
printf '%s' "$progress_after_restart" | grep -q '"completed_chunks":\[0\]'
|
||||
|
||||
curl -fsS -X PUT \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$chunk1" \
|
||||
"http://127.0.0.1:$proxy_port/api/uploads/$upload_id/chunks/1" >/dev/null
|
||||
|
||||
complete_response="$(
|
||||
curl -fsS -X POST "http://127.0.0.1:$proxy_port/api/uploads/$upload_id/complete"
|
||||
)"
|
||||
complete_path="$(printf '%s' "$complete_response" | json_field file_path)"
|
||||
|
||||
source_hash="$(sha256sum "$source_file" | awk '{print $1}')"
|
||||
complete_hash="$(sha256sum "$complete_path" | awk '{print $1}')"
|
||||
curl -fsS "http://127.0.0.1:$proxy_port/files/source.bin" -o "$served_file"
|
||||
served_hash="$(sha256sum "$served_file" | awk '{print $1}')"
|
||||
|
||||
if [[ "$source_hash" != "$complete_hash" ]]; then
|
||||
echo "Checksum mismatch after nginx-proxied resume" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$source_hash" != "$served_hash" ]]; then
|
||||
echo "Checksum mismatch through nginx completed-file alias" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "nginx smoke ok: $upload_id"
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
model::{CompleteUploadResponse, CreateUploadRequest, CreateUploadResponse},
|
||||
storage::StorageError,
|
||||
};
|
||||
|
||||
/// Creates an upload record and persists its metadata before returning.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an API error when request validation fails or metadata cannot be
|
||||
/// written to storage.
|
||||
pub async fn create_upload(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<CreateUploadRequest>,
|
||||
) -> Result<Json<CreateUploadResponse>, ApiError> {
|
||||
let meta = state.storage.create_upload(request).await?;
|
||||
Ok(Json(meta.create_response()))
|
||||
}
|
||||
|
||||
/// Returns server-authoritative upload progress.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an API error when the upload id is invalid, unknown, or cannot be
|
||||
/// read from storage.
|
||||
pub async fn get_upload(
|
||||
State(state): State<AppState>,
|
||||
Path(upload_id): Path<String>,
|
||||
) -> Result<Json<crate::model::UploadProgressResponse>, ApiError> {
|
||||
Ok(Json(state.storage.progress(&upload_id).await?))
|
||||
}
|
||||
|
||||
/// Stores one raw binary chunk body.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an API error when the upload id is invalid or unknown, the chunk
|
||||
/// index is out of range, the body length is wrong, or the write fails.
|
||||
pub async fn put_chunk(
|
||||
State(state): State<AppState>,
|
||||
Path((upload_id, index)): Path<(String, u64)>,
|
||||
body: Bytes,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state.storage.store_chunk(&upload_id, index, &body).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Promotes a fully uploaded temp file into the final completed file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an API error when the upload is unknown, incomplete, invalid, or
|
||||
/// cannot be promoted on disk.
|
||||
pub async fn complete_upload(
|
||||
State(state): State<AppState>,
|
||||
Path(upload_id): Path<String>,
|
||||
) -> Result<Json<CompleteUploadResponse>, ApiError> {
|
||||
Ok(Json(state.storage.complete_upload(&upload_id).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
self.status,
|
||||
Json(ErrorResponse {
|
||||
error: self.message,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(error: StorageError) -> Self {
|
||||
let status = if error.is_not_found() {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if error.is_conflict() {
|
||||
StatusCode::CONFLICT
|
||||
} else if error.is_invalid_input() {
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
|
||||
Self {
|
||||
status,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{get, post},
|
||||
};
|
||||
use clap::Parser;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::{api, storage::Storage};
|
||||
|
||||
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
|
||||
const STATIC_DIR_ENV: &str = "UPL_STATIC_DIR";
|
||||
const DATA_DIR_ENV: &str = "UPL_DATA_DIR";
|
||||
const TEMP_DIR_ENV: &str = "UPL_TEMP_DIR";
|
||||
const BIND_ENV: &str = "UPL_BIND";
|
||||
const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub bind_addr: SocketAddr,
|
||||
pub static_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState {
|
||||
pub storage: Storage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Parser)]
|
||||
#[command(
|
||||
name = "upl",
|
||||
version,
|
||||
about = "Run the upl resumable upload server.",
|
||||
long_about = "Run the upl resumable upload server.\n\nCommand-line arguments override environment variables. When neither is set, upl uses local development defaults inside the repository.",
|
||||
after_help = "Environment variables:\n UPL_BIND Default listen address\n UPL_STATIC_DIR Default static asset directory\n UPL_DATA_DIR Default completed upload data directory\n UPL_TEMP_DIR Default temporary upload directory"
|
||||
)]
|
||||
pub struct CliArgs {
|
||||
/// Socket address to listen on. Overrides `UPL_BIND`. Defaults to 127.0.0.1:3000.
|
||||
#[arg(long, value_name = "ADDR")]
|
||||
pub bind: Option<SocketAddr>,
|
||||
|
||||
/// Directory containing index.html and other browser assets. Overrides `UPL_STATIC_DIR`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub static_dir: Option<PathBuf>,
|
||||
|
||||
/// Directory where completed upload files are written. Overrides `UPL_DATA_DIR`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub data_dir: Option<PathBuf>,
|
||||
|
||||
/// Directory where upload metadata, progress markers, and temp files are written. Overrides `UPL_TEMP_DIR`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub temp_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Loads settings from command-line arguments and environment variables.
|
||||
///
|
||||
/// Command-line arguments take precedence over environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when an argument or `UPL_BIND` is not a valid socket
|
||||
/// address.
|
||||
pub fn from_args() -> Result<Self, Box<dyn Error>> {
|
||||
Self::from_cli_and_env(CliArgs::parse())
|
||||
}
|
||||
|
||||
/// Loads settings from environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `UPL_BIND` is set but is not a valid socket address.
|
||||
pub fn from_env() -> Result<Self, Box<dyn Error>> {
|
||||
Self::from_cli_and_env(CliArgs::default())
|
||||
}
|
||||
|
||||
fn from_cli_and_env(cli: CliArgs) -> Result<Self, Box<dyn Error>> {
|
||||
Self::from_sources(
|
||||
cli,
|
||||
env::var(BIND_ENV).ok(),
|
||||
env::var_os(STATIC_DIR_ENV),
|
||||
env::var_os(DATA_DIR_ENV),
|
||||
env::var_os(TEMP_DIR_ENV),
|
||||
)
|
||||
}
|
||||
|
||||
fn from_sources(
|
||||
cli: CliArgs,
|
||||
bind_env: Option<String>,
|
||||
static_dir_env: Option<OsString>,
|
||||
data_dir_env: Option<OsString>,
|
||||
temp_dir_env: Option<OsString>,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let bind_addr = match (cli.bind, bind_env) {
|
||||
(Some(bind_addr), _) => bind_addr,
|
||||
(None, Some(bind_addr)) => bind_addr.parse()?,
|
||||
(None, None) => DEFAULT_BIND_ADDR.parse()?,
|
||||
};
|
||||
|
||||
let static_dir = cli
|
||||
.static_dir
|
||||
.or_else(|| static_dir_env.map(PathBuf::from))
|
||||
.unwrap_or_else(default_static_dir);
|
||||
let data_dir = cli
|
||||
.data_dir
|
||||
.or_else(|| data_dir_env.map(PathBuf::from))
|
||||
.unwrap_or_else(default_data_dir);
|
||||
let temp_dir = cli
|
||||
.temp_dir
|
||||
.or_else(|| temp_dir_env.map(PathBuf::from))
|
||||
.unwrap_or_else(|| default_temp_dir(&data_dir));
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
static_dir,
|
||||
data_dir,
|
||||
temp_dir,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
bind_addr: SocketAddr,
|
||||
static_dir: impl Into<PathBuf>,
|
||||
data_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
let data_dir = data_dir.into();
|
||||
let temp_dir = default_temp_dir(&data_dir);
|
||||
Self {
|
||||
bind_addr,
|
||||
static_dir: static_dir.into(),
|
||||
data_dir,
|
||||
temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_temp_dir(
|
||||
bind_addr: SocketAddr,
|
||||
static_dir: impl Into<PathBuf>,
|
||||
data_dir: impl Into<PathBuf>,
|
||||
temp_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bind_addr,
|
||||
static_dir: static_dir.into(),
|
||||
data_dir: data_dir.into(),
|
||||
temp_dir: temp_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router(config: &AppConfig) -> Router {
|
||||
let state = AppState {
|
||||
storage: Storage::new(&config.data_dir, &config.temp_dir),
|
||||
};
|
||||
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/api/uploads", post(api::create_upload))
|
||||
.route("/api/uploads/{upload_id}", get(api::get_upload))
|
||||
.route(
|
||||
"/api/uploads/{upload_id}/complete",
|
||||
post(api::complete_upload),
|
||||
)
|
||||
.route(
|
||||
"/api/uploads/{upload_id}/chunks/{index}",
|
||||
axum::routing::put(api::put_chunk),
|
||||
)
|
||||
.layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
|
||||
.fallback_service(static_service(&config.static_dir))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn static_service(static_dir: &Path) -> ServeDir<ServeFile> {
|
||||
ServeDir::new(static_dir).fallback(ServeFile::new(static_dir.join("index.html")))
|
||||
}
|
||||
|
||||
fn default_static_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
|
||||
}
|
||||
|
||||
fn default_temp_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("staging")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{ffi::OsString, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use super::{AppConfig, CliArgs};
|
||||
|
||||
#[test]
|
||||
fn parses_config_arguments() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = CliArgs::try_parse_from([
|
||||
"upl",
|
||||
"--bind",
|
||||
"127.0.0.1:4000",
|
||||
"--static-dir",
|
||||
"public",
|
||||
"--data-dir",
|
||||
"uploads",
|
||||
"--temp-dir",
|
||||
"upload-temp",
|
||||
])?;
|
||||
|
||||
assert_eq!(args.bind, Some("127.0.0.1:4000".parse()?));
|
||||
assert_eq!(args.static_dir, Some(PathBuf::from("public")));
|
||||
assert_eq!(args.data_dir, Some(PathBuf::from("uploads")));
|
||||
assert_eq!(args.temp_dir, Some(PathBuf::from("upload-temp")));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_arguments_override_environment_values() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = AppConfig::from_sources(
|
||||
CliArgs {
|
||||
bind: Some("127.0.0.1:4000".parse()?),
|
||||
static_dir: Some(PathBuf::from("cli-static")),
|
||||
data_dir: Some(PathBuf::from("cli-data")),
|
||||
temp_dir: Some(PathBuf::from("cli-temp")),
|
||||
},
|
||||
Some("127.0.0.1:3001".to_owned()),
|
||||
Some(OsString::from("env-static")),
|
||||
Some(OsString::from("env-data")),
|
||||
Some(OsString::from("env-temp")),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.bind_addr, "127.0.0.1:4000".parse::<SocketAddr>()?);
|
||||
assert_eq!(config.static_dir, PathBuf::from("cli-static"));
|
||||
assert_eq!(config.data_dir, PathBuf::from("cli-data"));
|
||||
assert_eq!(config.temp_dir, PathBuf::from("cli-temp"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_values_are_used_when_arguments_are_absent()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = AppConfig::from_sources(
|
||||
CliArgs::default(),
|
||||
Some("127.0.0.1:3001".to_owned()),
|
||||
Some(OsString::from("env-static")),
|
||||
Some(OsString::from("env-data")),
|
||||
Some(OsString::from("env-temp")),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.bind_addr, "127.0.0.1:3001".parse::<SocketAddr>()?);
|
||||
assert_eq!(config.static_dir, PathBuf::from("env-static"));
|
||||
assert_eq!(config.data_dir, PathBuf::from("env-data"));
|
||||
assert_eq!(config.temp_dir, PathBuf::from("env-temp"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_dir_defaults_under_data_dir() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = AppConfig::from_sources(
|
||||
CliArgs {
|
||||
data_dir: Some(PathBuf::from("uploads")),
|
||||
..CliArgs::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
assert_eq!(config.temp_dir, PathBuf::from("uploads").join("staging"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod model;
|
||||
pub mod storage;
|
||||
+14
-2
@@ -1,3 +1,15 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use std::error::Error;
|
||||
|
||||
use upl::app::{AppConfig, build_router};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let config = AppConfig::from_args()?;
|
||||
let listener = tokio::net::TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
println!("upl listening on http://{}", listener.local_addr()?);
|
||||
|
||||
axum::serve(listener, build_router(&config)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const CHUNK_SIZE: u64 = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUploadRequest {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub last_modified: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateUploadResponse {
|
||||
pub upload_id: String,
|
||||
pub chunk_size: u64,
|
||||
pub total_chunks: u64,
|
||||
pub completed_chunks: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UploadProgressResponse {
|
||||
pub upload_id: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub chunk_size: u64,
|
||||
pub total_chunks: u64,
|
||||
pub completed_chunks: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CompleteUploadResponse {
|
||||
pub upload_id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct UploadMeta {
|
||||
pub id: String,
|
||||
pub original_name: String,
|
||||
pub safe_name: String,
|
||||
pub size: u64,
|
||||
pub last_modified: i64,
|
||||
pub chunk_size: u64,
|
||||
pub total_chunks: u64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl UploadMeta {
|
||||
#[must_use]
|
||||
pub fn create_response(&self) -> CreateUploadResponse {
|
||||
CreateUploadResponse {
|
||||
upload_id: self.id.clone(),
|
||||
chunk_size: self.chunk_size,
|
||||
total_chunks: self.total_chunks,
|
||||
completed_chunks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn progress_response(&self, completed_chunks: Vec<u64>) -> UploadProgressResponse {
|
||||
UploadProgressResponse {
|
||||
upload_id: self.id.clone(),
|
||||
name: self.original_name.clone(),
|
||||
size: self.size,
|
||||
chunk_size: self.chunk_size,
|
||||
total_chunks: self.total_chunks,
|
||||
completed_chunks,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn complete_response(&self, file_path: String) -> CompleteUploadResponse {
|
||||
CompleteUploadResponse {
|
||||
upload_id: self.id.clone(),
|
||||
name: self.safe_name.clone(),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::{self, Display},
|
||||
io::SeekFrom,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::{
|
||||
fs,
|
||||
io::{AsyncSeekExt, AsyncWriteExt},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{
|
||||
CHUNK_SIZE,
|
||||
CompleteUploadResponse,
|
||||
CreateUploadRequest,
|
||||
UploadMeta,
|
||||
UploadProgressResponse,
|
||||
};
|
||||
|
||||
const FILE_EXISTS_MESSAGE: &str = "file already exists";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Storage {
|
||||
data_dir: PathBuf,
|
||||
temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
#[must_use]
|
||||
pub fn new(data_dir: impl Into<PathBuf>, temp_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
data_dir: data_dir.into(),
|
||||
temp_dir: temp_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a durable upload metadata record and temp upload file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when directories cannot be created, the temp file
|
||||
/// cannot be created, metadata cannot be serialized, or the metadata file
|
||||
/// cannot be written atomically.
|
||||
pub async fn create_upload(
|
||||
&self,
|
||||
request: CreateUploadRequest,
|
||||
) -> Result<UploadMeta, StorageError> {
|
||||
let original_name = request.name.trim();
|
||||
if original_name.is_empty() {
|
||||
return Err(StorageError::InvalidInput("file name cannot be empty"));
|
||||
}
|
||||
|
||||
self.ensure_layout().await?;
|
||||
|
||||
let safe_name = safe_file_name(original_name);
|
||||
if fs::try_exists(self.final_path(&safe_name)).await? {
|
||||
return Err(StorageError::Conflict(FILE_EXISTS_MESSAGE));
|
||||
}
|
||||
|
||||
let created_at = OffsetDateTime::now_utc().format(&Rfc3339)?;
|
||||
|
||||
for _ in 0..8 {
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let upload_dir = self.upload_dir(&id);
|
||||
if fs::try_exists(&upload_dir).await? {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::create_dir_all(self.completed_dir(&id)).await?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(self.upload_file_path(&id))
|
||||
.await?;
|
||||
|
||||
let meta = UploadMeta {
|
||||
id,
|
||||
original_name: original_name.to_owned(),
|
||||
safe_name,
|
||||
size: request.size,
|
||||
last_modified: request.last_modified,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
total_chunks: total_chunks(request.size, CHUNK_SIZE),
|
||||
created_at,
|
||||
};
|
||||
|
||||
self.write_meta(&meta).await?;
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
Err(StorageError::IdCollision)
|
||||
}
|
||||
|
||||
/// Loads upload progress by scanning durable completion markers.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the upload id is invalid, metadata is missing, or
|
||||
/// the temp directory cannot be scanned.
|
||||
pub async fn progress(&self, upload_id: &str) -> Result<UploadProgressResponse, StorageError> {
|
||||
let meta = self.load_meta(upload_id).await?;
|
||||
let completed_chunks = self.completed_chunks(&meta).await?;
|
||||
|
||||
Ok(meta.progress_response(completed_chunks))
|
||||
}
|
||||
|
||||
/// Validates and stores one raw chunk body in the temp upload file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the upload is unknown, the index is out of range,
|
||||
/// the body length is not the expected chunk length, or the chunk cannot be
|
||||
/// written to its final offset in the temp upload file.
|
||||
pub async fn store_chunk(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
index: u64,
|
||||
body: &[u8],
|
||||
) -> Result<(), StorageError> {
|
||||
let meta = self.load_meta(upload_id).await?;
|
||||
let expected_len = expected_chunk_len(&meta, index)?;
|
||||
let body_len = u64::try_from(body.len())
|
||||
.map_err(|_| StorageError::InvalidInput("chunk body is too large"))?;
|
||||
|
||||
if body_len != expected_len {
|
||||
return Err(StorageError::InvalidInput("chunk has the wrong length"));
|
||||
}
|
||||
|
||||
if self.chunk_is_complete(&meta, index).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(self.upload_file_path(upload_id))
|
||||
.await?;
|
||||
output
|
||||
.seek(SeekFrom::Start(chunk_offset(&meta, index)))
|
||||
.await?;
|
||||
output.write_all(body).await?;
|
||||
output.flush().await?;
|
||||
drop(output);
|
||||
|
||||
self.mark_chunk_complete(&meta, index).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically promotes a complete temp upload file into completed storage.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the upload is unknown, any expected chunk is
|
||||
/// missing, the final file already exists, or the temp upload file cannot
|
||||
/// be renamed into place.
|
||||
pub async fn complete_upload(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
) -> Result<CompleteUploadResponse, StorageError> {
|
||||
let meta = self.load_meta(upload_id).await?;
|
||||
|
||||
self.verify_all_chunks(&meta).await?;
|
||||
|
||||
let final_path = self.final_path(&meta.safe_name);
|
||||
|
||||
promote_file_without_overwrite(&self.upload_file_path(upload_id), &final_path).await?;
|
||||
self.remove_upload_dir(upload_id).await?;
|
||||
|
||||
Ok(meta.complete_response(final_path.display().to_string()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn temp_dir(&self) -> &Path {
|
||||
&self.temp_dir
|
||||
}
|
||||
|
||||
fn staging_dir(&self) -> PathBuf {
|
||||
self.temp_dir.clone()
|
||||
}
|
||||
|
||||
fn final_dir(&self) -> PathBuf {
|
||||
self.data_dir.join("complete")
|
||||
}
|
||||
|
||||
fn final_path(&self, safe_name: &str) -> PathBuf {
|
||||
self.final_dir().join(safe_name)
|
||||
}
|
||||
|
||||
fn upload_dir(&self, upload_id: &str) -> PathBuf {
|
||||
self.staging_dir().join(upload_id)
|
||||
}
|
||||
|
||||
fn upload_file_path(&self, upload_id: &str) -> PathBuf {
|
||||
self.upload_dir(upload_id).join(".upload.tmp")
|
||||
}
|
||||
|
||||
fn completed_dir(&self, upload_id: &str) -> PathBuf {
|
||||
self.upload_dir(upload_id).join("completed")
|
||||
}
|
||||
|
||||
fn completed_marker_path(&self, upload_id: &str, index: u64) -> PathBuf {
|
||||
self.completed_dir(upload_id)
|
||||
.join(format!("{index:06}.done"))
|
||||
}
|
||||
|
||||
async fn ensure_layout(&self) -> Result<(), StorageError> {
|
||||
fs::create_dir_all(self.staging_dir()).await?;
|
||||
fs::create_dir_all(self.final_dir()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_meta(&self, meta: &UploadMeta) -> Result<(), StorageError> {
|
||||
let meta_path = self.upload_dir(&meta.id).join("meta.json");
|
||||
let tmp_path = meta_path.with_extension("json.tmp");
|
||||
let json = serde_json::to_vec_pretty(meta)?;
|
||||
|
||||
fs::write(&tmp_path, json).await?;
|
||||
fs::rename(&tmp_path, &meta_path).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_meta(&self, upload_id: &str) -> Result<UploadMeta, StorageError> {
|
||||
validate_upload_id(upload_id)?;
|
||||
|
||||
let meta_path = self.upload_dir(upload_id).join("meta.json");
|
||||
let bytes = match fs::read(meta_path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(StorageError::NotFound);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
async fn completed_chunks(&self, meta: &UploadMeta) -> Result<Vec<u64>, StorageError> {
|
||||
let mut completed = Vec::new();
|
||||
|
||||
for index in 0..meta.total_chunks {
|
||||
if self.chunk_is_complete(meta, index).await? {
|
||||
completed.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(completed)
|
||||
}
|
||||
|
||||
async fn verify_all_chunks(&self, meta: &UploadMeta) -> Result<(), StorageError> {
|
||||
if file_len(&self.upload_file_path(&meta.id)).await? != Some(meta.size) {
|
||||
return Err(StorageError::Conflict("upload data file is incomplete"));
|
||||
}
|
||||
|
||||
for index in 0..meta.total_chunks {
|
||||
if !self.chunk_is_complete(meta, index).await? {
|
||||
return Err(StorageError::Conflict(
|
||||
"upload is missing one or more complete chunks",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn chunk_is_complete(&self, meta: &UploadMeta, index: u64) -> Result<bool, StorageError> {
|
||||
expected_chunk_len(meta, index)?;
|
||||
Ok(file_len(&self.completed_marker_path(&meta.id, index))
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn mark_chunk_complete(&self, meta: &UploadMeta, index: u64) -> Result<(), StorageError> {
|
||||
let marker_path = self.completed_marker_path(&meta.id, index);
|
||||
match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(marker_path)
|
||||
.await
|
||||
{
|
||||
Ok(mut marker) => {
|
||||
marker.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_upload_dir(&self, upload_id: &str) -> Result<(), StorageError> {
|
||||
match fs::remove_dir_all(self.upload_dir(upload_id)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
Conflict(&'static str),
|
||||
Format(time::error::Format),
|
||||
IdCollision,
|
||||
InvalidInput(&'static str),
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
#[must_use]
|
||||
pub fn is_invalid_input(&self) -> bool {
|
||||
matches!(self, Self::InvalidInput(_))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_not_found(&self) -> bool {
|
||||
matches!(self, Self::NotFound)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_conflict(&self) -> bool {
|
||||
matches!(self, Self::Conflict(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for StorageError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Format(error) => write!(formatter, "failed to format timestamp: {error}"),
|
||||
Self::IdCollision => formatter.write_str("could not allocate a unique upload id"),
|
||||
Self::Conflict(message) | Self::InvalidInput(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "storage I/O error: {error}"),
|
||||
Self::Json(error) => write!(formatter, "metadata JSON error: {error}"),
|
||||
Self::NotFound => formatter.write_str("upload not found"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for StorageError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
Self::Format(error) => Some(error),
|
||||
Self::Io(error) => Some(error),
|
||||
Self::Json(error) => Some(error),
|
||||
Self::Conflict(_) | Self::IdCollision | Self::InvalidInput(_) | Self::NotFound => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for StorageError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for StorageError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<time::error::Format> for StorageError {
|
||||
fn from(error: time::error::Format) -> Self {
|
||||
Self::Format(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
let safe: String = name
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
character if character.is_control() => '_',
|
||||
character => character,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let trimmed = safe.trim_matches(['.', ' ', '_']);
|
||||
if trimmed.is_empty() {
|
||||
"upload".to_owned()
|
||||
} else {
|
||||
trimmed.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn total_chunks(size: u64, chunk_size: u64) -> u64 {
|
||||
if size == 0 {
|
||||
0
|
||||
} else {
|
||||
size.div_ceil(chunk_size)
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_chunk_len(meta: &UploadMeta, index: u64) -> Result<u64, StorageError> {
|
||||
if index >= meta.total_chunks {
|
||||
return Err(StorageError::InvalidInput("chunk index is out of range"));
|
||||
}
|
||||
|
||||
if index + 1 == meta.total_chunks {
|
||||
let bytes_before_final = meta.chunk_size * index;
|
||||
Ok(meta.size - bytes_before_final)
|
||||
} else {
|
||||
Ok(meta.chunk_size)
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_offset(meta: &UploadMeta, index: u64) -> u64 {
|
||||
meta.chunk_size * index
|
||||
}
|
||||
|
||||
async fn file_len(path: &Path) -> Result<Option<u64>, StorageError> {
|
||||
match fs::metadata(path).await {
|
||||
Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())),
|
||||
Ok(_) => Ok(None),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn promote_file_without_overwrite(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), StorageError> {
|
||||
match fs::hard_link(source, destination).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(StorageError::Conflict(FILE_EXISTS_MESSAGE));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
let mut output = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(destination)
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(StorageError::Conflict(FILE_EXISTS_MESSAGE));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let copy_result = async {
|
||||
let mut input = fs::File::open(source).await?;
|
||||
tokio::io::copy(&mut input, &mut output).await?;
|
||||
output.flush().await
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = copy_result {
|
||||
let _ = fs::remove_file(destination).await;
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_upload_id(upload_id: &str) -> Result<(), StorageError> {
|
||||
let is_valid = !upload_id.is_empty()
|
||||
&& upload_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
|
||||
|
||||
if is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(StorageError::InvalidInput("upload id is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{expected_chunk_len, safe_file_name, total_chunks, validate_upload_id};
|
||||
use crate::model::UploadMeta;
|
||||
|
||||
#[test]
|
||||
fn computes_total_chunks() {
|
||||
assert_eq!(total_chunks(0, 16), 0);
|
||||
assert_eq!(total_chunks(1, 16), 1);
|
||||
assert_eq!(total_chunks(16, 16), 1);
|
||||
assert_eq!(total_chunks(17, 16), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_file_names() {
|
||||
assert_eq!(safe_file_name("../movie:mkv"), "movie_mkv");
|
||||
assert_eq!(safe_file_name(" "), "upload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computes_expected_chunk_lengths() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let meta = test_meta(33, 16, 3);
|
||||
|
||||
assert_eq!(expected_chunk_len(&meta, 0)?, 16);
|
||||
assert_eq!(expected_chunk_len(&meta, 1)?, 16);
|
||||
assert_eq!(expected_chunk_len(&meta, 2)?, 1);
|
||||
assert!(expected_chunk_len(&meta, 3).is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_upload_ids() {
|
||||
assert!(validate_upload_id("abc123").is_ok());
|
||||
assert!(validate_upload_id("../abc").is_err());
|
||||
assert!(validate_upload_id("").is_err());
|
||||
}
|
||||
|
||||
fn test_meta(size: u64, chunk_size: u64, total_chunks: u64) -> UploadMeta {
|
||||
UploadMeta {
|
||||
id: "abc123".to_owned(),
|
||||
original_name: "file.bin".to_owned(),
|
||||
safe_name: "file.bin".to_owned(),
|
||||
size,
|
||||
last_modified: 0,
|
||||
chunk_size,
|
||||
total_chunks,
|
||||
created_at: "2026-05-30T16:00:00Z".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+954
@@ -0,0 +1,954 @@
|
||||
const DB_NAME = "upl";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "uploads";
|
||||
const CHUNK_CONCURRENCY = 3;
|
||||
const FILE_CONCURRENCY = 3;
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_RETRY_DELAY_MS = 500;
|
||||
const FILE_EXISTS_MESSAGE = "file already exists";
|
||||
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const pickButton = document.querySelector("#pick-button");
|
||||
const uploadSection = document.querySelector("#upload-section");
|
||||
const uploadList = document.querySelector("#upload-list");
|
||||
const startButton = document.querySelector("#start-button");
|
||||
const pauseButton = document.querySelector("#pause-button");
|
||||
const resumeButton = document.querySelector("#resume-button");
|
||||
const eventLog = document.querySelector("#event-log");
|
||||
const pendingSection = document.querySelector("#pending-section");
|
||||
const pendingList = document.querySelector("#pending-list");
|
||||
|
||||
const state = {
|
||||
pendingRecords: [],
|
||||
resumeAfterReselect: null,
|
||||
schedulerAbortController: null,
|
||||
schedulerRunning: false,
|
||||
uploadItems: [],
|
||||
};
|
||||
|
||||
const dbReady = "indexedDB" in window ? openDatabase() : Promise.resolve(null);
|
||||
let nextUploadItemId = 1;
|
||||
let saveChain = Promise.resolve();
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes;
|
||||
let unit = units[0];
|
||||
|
||||
for (const candidate of units) {
|
||||
unit = candidate;
|
||||
if (value < 1024 || candidate === units[units.length - 1]) {
|
||||
break;
|
||||
}
|
||||
value /= 1024;
|
||||
}
|
||||
|
||||
return `${formatter.format(value)} ${unit}`;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = message;
|
||||
eventLog.prepend(item);
|
||||
|
||||
while (eventLog.children.length > 8) {
|
||||
eventLog.lastElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "upload_id" });
|
||||
}
|
||||
});
|
||||
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
}).catch((error) => {
|
||||
log(`IndexedDB unavailable: ${error.message}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function requestToPromise(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore(mode, callback) {
|
||||
const db = await dbReady;
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transaction = db.transaction(STORE_NAME, mode);
|
||||
return requestToPromise(callback(transaction.objectStore(STORE_NAME)));
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
const records = (await withStore("readonly", (store) => store.getAll())) ?? [];
|
||||
records.sort((left, right) =>
|
||||
(right.updated_at ?? "").localeCompare(left.updated_at ?? ""),
|
||||
);
|
||||
state.pendingRecords = records;
|
||||
renderPendingRecords();
|
||||
}
|
||||
|
||||
async function saveRecord(record) {
|
||||
const nextSave = saveChain.catch(() => null).then(() => writeRecord(record));
|
||||
saveChain = nextSave;
|
||||
return nextSave;
|
||||
}
|
||||
|
||||
async function writeRecord(record) {
|
||||
const storedRecord = { ...record, updated_at: new Date().toISOString() };
|
||||
try {
|
||||
await withStore("readwrite", (store) => store.put(storedRecord));
|
||||
} catch (error) {
|
||||
if (!storedRecord.file_handle) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
delete storedRecord.file_handle;
|
||||
await withStore("readwrite", (store) => store.put(storedRecord));
|
||||
log("Saved resume state without a reusable file handle.");
|
||||
}
|
||||
|
||||
await loadRecords();
|
||||
return storedRecord;
|
||||
}
|
||||
|
||||
async function deleteRecord(uploadId) {
|
||||
await withStore("readwrite", (store) => store.delete(uploadId));
|
||||
await loadRecords();
|
||||
}
|
||||
|
||||
function renderPendingRecords() {
|
||||
pendingList.replaceChildren();
|
||||
|
||||
const activeUploadIds = new Set(
|
||||
state.uploadItems
|
||||
.map((item) => item.record?.upload_id)
|
||||
.filter((uploadId) => Boolean(uploadId)),
|
||||
);
|
||||
const visibleRecords = state.pendingRecords.filter(
|
||||
(record) => !activeUploadIds.has(record.upload_id),
|
||||
);
|
||||
|
||||
pendingSection.hidden = visibleRecords.length === 0;
|
||||
|
||||
for (const record of visibleRecords) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "pending-item";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "pending-meta";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = record.name;
|
||||
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = savedUploadDetail(record);
|
||||
|
||||
const resume = document.createElement("button");
|
||||
resume.type = "button";
|
||||
resume.className = "secondary";
|
||||
resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume";
|
||||
resume.disabled = state.schedulerRunning || !hasAvailableFileSlot();
|
||||
resume.addEventListener("click", () => {
|
||||
void resumePendingRecord(record);
|
||||
});
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "danger";
|
||||
remove.textContent = "Clear";
|
||||
remove.disabled = state.schedulerRunning;
|
||||
remove.addEventListener("click", () => {
|
||||
void deleteRecord(record.upload_id);
|
||||
});
|
||||
|
||||
meta.append(title, detail);
|
||||
item.append(meta, resume, remove);
|
||||
pendingList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUploadItems() {
|
||||
uploadList.replaceChildren();
|
||||
uploadSection.hidden = state.uploadItems.length === 0;
|
||||
|
||||
for (const item of state.uploadItems) {
|
||||
const row = document.createElement("li");
|
||||
row.className = "upload-item";
|
||||
if (item.terminal) {
|
||||
row.classList.add("upload-item-blocked");
|
||||
row.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "upload-item-header";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "upload-meta";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = item.file.name;
|
||||
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = uploadItemDetail(item);
|
||||
|
||||
meta.append(title, detail);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "upload-item-actions";
|
||||
|
||||
const start = document.createElement("button");
|
||||
start.type = "button";
|
||||
start.textContent = uploadActionLabel(item);
|
||||
start.disabled =
|
||||
!canRunItem(item) || state.schedulerRunning || !hasAvailableFileSlot();
|
||||
start.addEventListener("click", () => {
|
||||
void runUploadItem(item);
|
||||
});
|
||||
|
||||
const pause = document.createElement("button");
|
||||
pause.type = "button";
|
||||
pause.className = "secondary";
|
||||
pause.textContent = "Pause";
|
||||
pause.disabled = !item.running;
|
||||
pause.addEventListener("click", () => {
|
||||
item.abortController?.abort();
|
||||
});
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "secondary";
|
||||
remove.textContent = "Remove";
|
||||
remove.disabled = item.running || item.queued;
|
||||
remove.addEventListener("click", () => {
|
||||
removeUploadItem(item);
|
||||
});
|
||||
|
||||
actions.append(start, pause, remove);
|
||||
header.append(meta, actions);
|
||||
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "upload-progress";
|
||||
|
||||
const progressWrap = document.createElement("div");
|
||||
progressWrap.className = "progress-wrap";
|
||||
progressWrap.setAttribute("aria-label", `${item.file.name} upload progress`);
|
||||
|
||||
const progressBar = document.createElement("div");
|
||||
progressBar.className = "progress-bar";
|
||||
progressBar.style.width = `${progressPercentage(
|
||||
item.completedCount,
|
||||
item.totalChunks,
|
||||
)}%`;
|
||||
|
||||
const progressMeta = document.createElement("div");
|
||||
progressMeta.className = "progress-meta";
|
||||
progressMeta.textContent = `${item.completedCount} of ${item.totalChunks} chunks`;
|
||||
|
||||
progressWrap.append(progressBar);
|
||||
progress.append(progressWrap, progressMeta);
|
||||
row.append(header, progress);
|
||||
uploadList.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderButtons() {
|
||||
const hasRunnable = state.uploadItems.some((item) => canRunItem(item));
|
||||
const hasRunnableResume = state.uploadItems.some(
|
||||
(item) => item.record && canRunItem(item),
|
||||
);
|
||||
const hasRunningOrQueued = state.uploadItems.some((item) => item.running || item.queued);
|
||||
const hasFileSlot = hasAvailableFileSlot();
|
||||
|
||||
startButton.disabled = !hasRunnable || state.schedulerRunning || !hasFileSlot;
|
||||
pauseButton.disabled = !hasRunningOrQueued;
|
||||
resumeButton.disabled = !hasRunnableResume || state.schedulerRunning || !hasFileSlot;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderUploadItems();
|
||||
renderPendingRecords();
|
||||
renderButtons();
|
||||
}
|
||||
|
||||
function progressPercentage(completedCount, totalChunks) {
|
||||
if (totalChunks <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, (completedCount / totalChunks) * 100));
|
||||
}
|
||||
|
||||
function sameFile(record, file) {
|
||||
return (
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.last_modified === file.lastModified
|
||||
);
|
||||
}
|
||||
|
||||
function sameUploadItemFile(item, file) {
|
||||
return (
|
||||
item.file.name === file.name &&
|
||||
item.file.size === file.size &&
|
||||
item.file.lastModified === file.lastModified
|
||||
);
|
||||
}
|
||||
|
||||
function findPendingRecord(file) {
|
||||
return state.pendingRecords.find((record) => sameFile(record, file)) ?? null;
|
||||
}
|
||||
|
||||
function findUploadItem(file, record = null) {
|
||||
return (
|
||||
state.uploadItems.find((item) => {
|
||||
if (item.finished || item.terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record?.upload_id && item.record?.upload_id === record.upload_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return sameUploadItemFile(item, file);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function completedChunkCount(record) {
|
||||
return Math.min(record.completed_chunks ?? 0, record.total_chunks ?? 0);
|
||||
}
|
||||
|
||||
function isReadyToFinish(record) {
|
||||
const totalChunks = record.total_chunks ?? 0;
|
||||
return totalChunks === 0 || completedChunkCount(record) >= totalChunks;
|
||||
}
|
||||
|
||||
function isUploadItemReadyToFinish(item) {
|
||||
return (
|
||||
Boolean(item.record) &&
|
||||
(item.totalChunks === 0 || item.completedCount >= item.totalChunks)
|
||||
);
|
||||
}
|
||||
|
||||
function savedUploadDetail(record) {
|
||||
const totalChunks = record.total_chunks ?? 0;
|
||||
const completedChunks = completedChunkCount(record);
|
||||
|
||||
if (isReadyToFinish(record)) {
|
||||
return `${formatBytes(record.size)} - ready to finish`;
|
||||
}
|
||||
|
||||
if (completedChunks === 0) {
|
||||
return `${formatBytes(record.size)} - not uploaded yet`;
|
||||
}
|
||||
|
||||
return `${formatBytes(record.size)} - ${completedChunks} of ${totalChunks} chunks uploaded`;
|
||||
}
|
||||
|
||||
function uploadItemDetail(item) {
|
||||
return `${formatBytes(item.file.size)} - ${item.statusText}`;
|
||||
}
|
||||
|
||||
function uploadActionLabel(item) {
|
||||
if (item.terminal) {
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
if (item.finished) {
|
||||
return "Done";
|
||||
}
|
||||
|
||||
if (!item.record) {
|
||||
return "Start";
|
||||
}
|
||||
|
||||
return isUploadItemReadyToFinish(item) ? "Finish" : "Resume";
|
||||
}
|
||||
|
||||
function initialUploadStatus(record) {
|
||||
if (!record) {
|
||||
return "Ready to create an upload record.";
|
||||
}
|
||||
|
||||
if (isReadyToFinish(record)) {
|
||||
return "Ready to finish saved upload.";
|
||||
}
|
||||
|
||||
return "Ready to resume upload.";
|
||||
}
|
||||
|
||||
function canRunItem(item) {
|
||||
return (
|
||||
Boolean(item.file) &&
|
||||
!item.running &&
|
||||
!item.queued &&
|
||||
!item.finished &&
|
||||
!item.terminal
|
||||
);
|
||||
}
|
||||
|
||||
function runningFileCount() {
|
||||
return state.uploadItems.filter((item) => item.running).length;
|
||||
}
|
||||
|
||||
function hasAvailableFileSlot() {
|
||||
return runningFileCount() < FILE_CONCURRENCY;
|
||||
}
|
||||
|
||||
function setItemProgress(item, completedCount, totalChunks) {
|
||||
item.totalChunks = Math.max(0, totalChunks);
|
||||
item.completedCount =
|
||||
item.totalChunks === 0
|
||||
? 0
|
||||
: Math.min(Math.max(0, completedCount), item.totalChunks);
|
||||
}
|
||||
|
||||
async function selectFiles(files, fileHandles = []) {
|
||||
const selectedFiles = Array.from(files);
|
||||
if (selectedFiles.length === 0) {
|
||||
log("Choose files to begin.");
|
||||
renderButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeRecord = state.resumeAfterReselect;
|
||||
let matchedResumeRecord = false;
|
||||
let addedCount = 0;
|
||||
|
||||
for (const [index, file] of selectedFiles.entries()) {
|
||||
let record = findPendingRecord(file);
|
||||
if (resumeRecord && sameFile(resumeRecord, file)) {
|
||||
record = resumeRecord;
|
||||
matchedResumeRecord = true;
|
||||
}
|
||||
|
||||
const previousCount = state.uploadItems.length;
|
||||
addUploadItem(file, fileHandles[index] ?? null, record);
|
||||
if (state.uploadItems.length > previousCount) {
|
||||
addedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (resumeRecord && !matchedResumeRecord) {
|
||||
log("Selected files did not include the pending upload file.");
|
||||
}
|
||||
|
||||
state.resumeAfterReselect = null;
|
||||
fileInput.value = "";
|
||||
renderAll();
|
||||
|
||||
if (addedCount === 1) {
|
||||
log("Ready to upload 1 file.");
|
||||
} else if (addedCount > 1) {
|
||||
log(`Ready to upload ${addedCount} files.`);
|
||||
}
|
||||
}
|
||||
|
||||
function addUploadItem(file, fileHandle = null, record = null) {
|
||||
if (record && !sameFile(record, file)) {
|
||||
log("Selected file does not match the pending upload.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingItem = findUploadItem(file, record);
|
||||
if (existingItem) {
|
||||
log(`${file.name} is already selected.`);
|
||||
return existingItem;
|
||||
}
|
||||
|
||||
const item = {
|
||||
abortController: null,
|
||||
completedChunks: new Set(),
|
||||
completedCount: record ? completedChunkCount(record) : 0,
|
||||
file,
|
||||
fileHandle,
|
||||
finished: false,
|
||||
id: nextUploadItemId,
|
||||
queued: false,
|
||||
record,
|
||||
running: false,
|
||||
statusText: initialUploadStatus(record),
|
||||
terminal: false,
|
||||
totalChunks: record?.total_chunks ?? 0,
|
||||
};
|
||||
|
||||
nextUploadItemId += 1;
|
||||
state.uploadItems.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function removeUploadItem(item) {
|
||||
if (item.running || item.queued) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.uploadItems = state.uploadItems.filter((candidate) => candidate.id !== item.id);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if ("showOpenFilePicker" in window) {
|
||||
try {
|
||||
const handles = await window.showOpenFilePicker({ multiple: true });
|
||||
const files = await Promise.all(handles.map((handle) => handle.getFile()));
|
||||
await selectFiles(files, handles);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
log(`File picker failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
void selectFiles(fileInput.files);
|
||||
});
|
||||
|
||||
pickButton.addEventListener("click", () => {
|
||||
void pickFile();
|
||||
});
|
||||
|
||||
startButton.addEventListener("click", () => {
|
||||
void runUploadItems(state.uploadItems);
|
||||
});
|
||||
|
||||
pauseButton.addEventListener("click", () => {
|
||||
pauseUploads();
|
||||
});
|
||||
|
||||
resumeButton.addEventListener("click", () => {
|
||||
void runUploadItems(state.uploadItems.filter((item) => item.record));
|
||||
});
|
||||
|
||||
async function resumePendingRecord(record) {
|
||||
if (state.schedulerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.file_handle) {
|
||||
const granted = await requestFileHandlePermission(record.file_handle);
|
||||
if (granted) {
|
||||
const file = await record.file_handle.getFile();
|
||||
const item = addUploadItem(file, record.file_handle, record);
|
||||
renderAll();
|
||||
if (item) {
|
||||
await runUploadItem(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.resumeAfterReselect = record;
|
||||
log(
|
||||
isReadyToFinish(record)
|
||||
? "Select the same file to finish."
|
||||
: "Select the same file to resume.",
|
||||
);
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
async function requestFileHandlePermission(handle) {
|
||||
if (!("queryPermission" in handle) || !("requestPermission" in handle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const options = { mode: "read" };
|
||||
if ((await handle.queryPermission(options)) === "granted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (await handle.requestPermission(options)) === "granted";
|
||||
}
|
||||
|
||||
async function runUploadItems(items) {
|
||||
if (state.schedulerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runnableItems = items.filter((item) => canRunItem(item));
|
||||
const availableSlots = FILE_CONCURRENCY - runningFileCount();
|
||||
if (runnableItems.length === 0 || availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
state.schedulerAbortController = controller;
|
||||
state.schedulerRunning = true;
|
||||
|
||||
for (const item of runnableItems) {
|
||||
item.queued = true;
|
||||
item.statusText = "Queued.";
|
||||
}
|
||||
renderAll();
|
||||
|
||||
try {
|
||||
await runPool(
|
||||
runnableItems,
|
||||
async (item) => {
|
||||
throwIfAborted(controller.signal);
|
||||
if (!item.queued) {
|
||||
return;
|
||||
}
|
||||
await runUploadItem(item);
|
||||
},
|
||||
availableSlots,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
log(`Upload scheduler failed: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
for (const item of runnableItems) {
|
||||
if (item.queued) {
|
||||
item.queued = false;
|
||||
item.statusText = "Paused.";
|
||||
}
|
||||
}
|
||||
|
||||
if (state.schedulerAbortController === controller) {
|
||||
state.schedulerAbortController = null;
|
||||
}
|
||||
state.schedulerRunning = false;
|
||||
renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
function pauseUploads() {
|
||||
state.schedulerAbortController?.abort();
|
||||
|
||||
for (const item of state.uploadItems) {
|
||||
if (item.running) {
|
||||
item.abortController?.abort();
|
||||
} else if (item.queued) {
|
||||
item.queued = false;
|
||||
item.statusText = "Paused.";
|
||||
}
|
||||
}
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function runUploadItem(item) {
|
||||
if (
|
||||
item.running ||
|
||||
item.finished ||
|
||||
item.terminal ||
|
||||
(!item.queued && !hasAvailableFileSlot()) ||
|
||||
(!item.queued && !canRunItem(item))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
item.abortController = controller;
|
||||
item.queued = false;
|
||||
item.running = true;
|
||||
item.statusText = item.record
|
||||
? "Checking saved progress."
|
||||
: "Creating upload record.";
|
||||
renderAll();
|
||||
|
||||
try {
|
||||
if (!item.record) {
|
||||
await createUploadRecord(item, controller.signal);
|
||||
}
|
||||
|
||||
const progress = await fetchJson(`/api/uploads/${item.record.upload_id}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
item.completedChunks = new Set(progress.completed_chunks);
|
||||
setItemProgress(item, item.completedChunks.size, progress.total_chunks);
|
||||
item.record = await saveRecord({
|
||||
...item.record,
|
||||
completed_chunks: item.completedCount,
|
||||
chunk_size: progress.chunk_size,
|
||||
total_chunks: progress.total_chunks,
|
||||
});
|
||||
renderAll();
|
||||
|
||||
const missingChunks = buildMissingChunkList(progress.total_chunks, item.completedChunks);
|
||||
item.statusText =
|
||||
missingChunks.length === 0
|
||||
? "All chunks already uploaded."
|
||||
: `Uploading ${missingChunks.length} missing chunks.`;
|
||||
renderAll();
|
||||
|
||||
await runPool(
|
||||
missingChunks,
|
||||
(index) =>
|
||||
uploadChunkWithRetry(
|
||||
item,
|
||||
index,
|
||||
progress.chunk_size,
|
||||
progress.total_chunks,
|
||||
controller.signal,
|
||||
),
|
||||
CHUNK_CONCURRENCY,
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.statusText = "Completing upload.";
|
||||
renderAll();
|
||||
const uploadId = item.record.upload_id;
|
||||
const complete = await fetchJson(`/api/uploads/${uploadId}/complete`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setItemProgress(item, progress.total_chunks, progress.total_chunks);
|
||||
item.finished = true;
|
||||
item.statusText = `Complete: ${complete.file_path}`;
|
||||
await deleteRecord(uploadId);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) {
|
||||
item.statusText = "Paused.";
|
||||
} else if (await handleTerminalUploadError(item, error)) {
|
||||
controller.abort();
|
||||
} else {
|
||||
controller.abort();
|
||||
item.statusText = `Upload failed: ${error.message}`;
|
||||
}
|
||||
} finally {
|
||||
if (item.abortController === controller) {
|
||||
item.abortController = null;
|
||||
}
|
||||
item.running = false;
|
||||
renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTerminalUploadError(item, error) {
|
||||
if (typeof error.status !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFileExistsConflict(error)) {
|
||||
if (item.record) {
|
||||
await deleteRecord(item.record.upload_id);
|
||||
}
|
||||
item.record = null;
|
||||
item.completedChunks = new Set();
|
||||
setItemProgress(item, 0, 0);
|
||||
item.terminal = true;
|
||||
item.statusText =
|
||||
"File already exists on the server. Rename the file to upload this copy.";
|
||||
log(`${item.file.name}: file already exists. Rename it to upload this copy.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!item.record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uploadId = item.record.upload_id;
|
||||
|
||||
if (error.status === 404) {
|
||||
await deleteRecord(uploadId);
|
||||
item.record = null;
|
||||
item.completedChunks = new Set();
|
||||
setItemProgress(item, 0, 0);
|
||||
item.statusText = "Saved upload progress no longer exists. Start again.";
|
||||
log(`${item.file.name}: saved upload progress no longer exists on the server.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFileExistsConflict(error) {
|
||||
return error.status === 409 && error.message === FILE_EXISTS_MESSAGE;
|
||||
}
|
||||
|
||||
async function createUploadRecord(item, signal) {
|
||||
const response = await fetchJson("/api/uploads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: item.file.name,
|
||||
size: item.file.size,
|
||||
last_modified: item.file.lastModified,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
const record = {
|
||||
upload_id: response.upload_id,
|
||||
name: item.file.name,
|
||||
size: item.file.size,
|
||||
last_modified: item.file.lastModified,
|
||||
chunk_size: response.chunk_size,
|
||||
total_chunks: response.total_chunks,
|
||||
completed_chunks: 0,
|
||||
file_handle: item.fileHandle,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
item.record = await saveRecord(record);
|
||||
setItemProgress(item, 0, response.total_chunks);
|
||||
item.statusText = "Upload record created.";
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function buildMissingChunkList(totalChunks, completedChunks) {
|
||||
const missing = [];
|
||||
for (let index = 0; index < totalChunks; index += 1) {
|
||||
if (!completedChunks.has(index)) {
|
||||
missing.push(index);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function uploadChunkWithRetry(item, index, chunkSize, totalChunks, signal) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
try {
|
||||
await uploadChunk(item, index, chunkSize, signal);
|
||||
item.completedChunks.add(index);
|
||||
setItemProgress(item, item.completedChunks.size, totalChunks);
|
||||
item.record = await saveRecord({
|
||||
...item.record,
|
||||
completed_chunks: item.completedCount,
|
||||
});
|
||||
renderAll();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || attempt === MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt;
|
||||
item.statusText = `Retrying chunk ${index} after ${delayMs} ms.`;
|
||||
renderAll();
|
||||
await delay(delayMs, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadChunk(item, index, chunkSize, signal) {
|
||||
const start = index * chunkSize;
|
||||
const end = Math.min(item.file.size, start + chunkSize);
|
||||
const body = item.file.slice(start, end);
|
||||
const response = await fetch(
|
||||
`/api/uploads/${item.record.upload_id}/chunks/${index}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(await responseError(response), response.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPool(items, worker, concurrency, signal = null) {
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, items.length) },
|
||||
async () => {
|
||||
while (nextIndex < items.length) {
|
||||
if (signal) {
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
|
||||
const item = items[nextIndex];
|
||||
nextIndex += 1;
|
||||
await worker(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
class ApiRequestError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(await responseError(response), response.status);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function responseError(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return `${response.status} ${response.statusText}`;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text).error ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const id = window.setTimeout(resolve, ms);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
window.clearTimeout(id);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(error) {
|
||||
return error?.name === "AbortError";
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadRecords();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
void initialize();
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>upl</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<section class="upload-panel" aria-labelledby="app-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h1 id="app-title">upl</h1>
|
||||
<p class="subtle">Resumable uploads to this machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-picker">
|
||||
<button id="pick-button" type="button">Choose files</button>
|
||||
<input id="file-input" type="file" multiple>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start-button" type="button" disabled>Start all</button>
|
||||
<button id="pause-button" type="button" disabled>Pause all</button>
|
||||
<button id="resume-button" type="button" disabled>Resume all</button>
|
||||
</div>
|
||||
|
||||
<section class="upload-section" id="upload-section" hidden>
|
||||
<h2>Selected uploads</h2>
|
||||
<ul class="upload-list" id="upload-list"></ul>
|
||||
</section>
|
||||
|
||||
<section class="pending-section" id="pending-section" hidden>
|
||||
<h2>Saved upload progress</h2>
|
||||
<ul class="pending-list" id="pending-list"></ul>
|
||||
</section>
|
||||
|
||||
<ol class="event-log" id="event-log" aria-live="polite">
|
||||
<li>Choose files to begin.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,290 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1d2430;
|
||||
--muted: #667085;
|
||||
--line: #d0d5dd;
|
||||
--accent: #147a73;
|
||||
--accent-strong: #0f5f59;
|
||||
--track: #e4e7ec;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 10px 30px rgb(16 24 40 / 8%);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-picker input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.upload-item-blocked {
|
||||
border-color: #f04438;
|
||||
background: rgb(240 68 56 / 8%);
|
||||
}
|
||||
|
||||
.upload-item-blocked .upload-meta span {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.upload-item-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-meta span {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--track);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.progress-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-item-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 96px;
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: #b42318;
|
||||
color: #b42318;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
color: var(--muted);
|
||||
background: var(--track);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pending-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pending-item strong,
|
||||
.upload-meta strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pending-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pending-meta span {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
min-height: 80px;
|
||||
margin: 0;
|
||||
padding: 14px 14px 14px 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--panel: #1f242b;
|
||||
--text: #f2f4f7;
|
||||
--muted: #b8c0cc;
|
||||
--line: #3d4654;
|
||||
--accent: #35b8aa;
|
||||
--accent-strong: #9ce4dc;
|
||||
--track: #343b46;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.panel-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upload-item-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upload-item-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
use upl::{
|
||||
app::{AppConfig, build_router},
|
||||
model::{CHUNK_SIZE, CreateUploadResponse, UploadProgressResponse},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn stores_chunks_and_reports_progress() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, temp_dir.path(), CHUNK_SIZE + 3).await?;
|
||||
|
||||
let final_chunk = vec![b'z'; 3];
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(&upload.upload_id, 1, final_chunk)?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let progress = get_progress(&app, &upload.upload_id).await?;
|
||||
assert_eq!(progress.completed_chunks, vec![1]);
|
||||
|
||||
let first_chunk = vec![b'a'; usize::try_from(CHUNK_SIZE)?];
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(&upload.upload_id, 0, first_chunk)?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let progress = get_progress(&app, &upload.upload_id).await?;
|
||||
assert_eq!(progress.completed_chunks, vec![0, 1]);
|
||||
|
||||
let upload_dir = temp_dir.path().join("staging").join(&upload.upload_id);
|
||||
assert_eq!(
|
||||
tokio::fs::metadata(upload_dir.join(".upload.tmp"))
|
||||
.await?
|
||||
.len(),
|
||||
CHUNK_SIZE + 3
|
||||
);
|
||||
assert!(upload_dir.join("completed").join("000000.done").is_file());
|
||||
assert!(upload_dir.join("completed").join("000001.done").is_file());
|
||||
assert!(!upload_dir.join("chunks").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_size_non_final_chunk() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, temp_dir.path(), CHUNK_SIZE + 1).await?;
|
||||
|
||||
let response = app
|
||||
.oneshot(chunk_request(&upload.upload_id, 0, b"too short".to_vec())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_out_of_range_chunk_index() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, temp_dir.path(), 4).await?;
|
||||
|
||||
let response = app
|
||||
.oneshot(chunk_request(&upload.upload_id, 1, b"data".to_vec())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_duplicate_completed_chunk() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, temp_dir.path(), 4).await?;
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(&upload.upload_id, 0, b"data".to_vec())?)
|
||||
.await?;
|
||||
let second = app
|
||||
.oneshot(chunk_request(&upload.upload_id, 0, b"data".to_vec())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(first.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(second.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unknown_upload_id() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
|
||||
let response = app
|
||||
.oneshot(chunk_request("missing", 0, b"data".to_vec())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_upload(
|
||||
app: &axum::Router,
|
||||
data_dir: &Path,
|
||||
size: u64,
|
||||
) -> Result<CreateUploadResponse, Box<dyn std::error::Error>> {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
Method::POST,
|
||||
"/api/uploads",
|
||||
&json!({
|
||||
"name": "chunked.bin",
|
||||
"size": size,
|
||||
"last_modified": 1_760_000_000_000_i64
|
||||
}),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(data_dir.join("staging").is_dir());
|
||||
|
||||
decode_body(response).await
|
||||
}
|
||||
|
||||
async fn get_progress(
|
||||
app: &axum::Router,
|
||||
upload_id: &str,
|
||||
) -> Result<UploadProgressResponse, Box<dyn std::error::Error>> {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/api/uploads/{upload_id}"))
|
||||
.body(Body::empty())?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
decode_body(response).await
|
||||
}
|
||||
|
||||
fn test_app(data_dir: &Path) -> axum::Router {
|
||||
build_router(&AppConfig::new(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
|
||||
data_dir,
|
||||
))
|
||||
}
|
||||
|
||||
fn json_request(
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(body)?))?)
|
||||
}
|
||||
|
||||
fn chunk_request(
|
||||
upload_id: &str,
|
||||
index: u64,
|
||||
body: Vec<u8>,
|
||||
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(format!("/api/uploads/{upload_id}/chunks/{index}"))
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from(body))?)
|
||||
}
|
||||
|
||||
async fn decode_body<T>(response: axum::response::Response) -> Result<T, Box<dyn std::error::Error>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let body = response.into_body().collect().await?.to_bytes();
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
use upl::{
|
||||
app::{AppConfig, build_router},
|
||||
model::{CHUNK_SIZE, CompleteUploadResponse, CreateUploadResponse},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn assembles_completed_upload() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, "hello.txt", 11).await?;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(
|
||||
&upload.upload_id,
|
||||
0,
|
||||
b"hello world".to_vec(),
|
||||
)?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", upload.upload_id),
|
||||
)?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let complete: CompleteUploadResponse = decode_body(response).await?;
|
||||
assert_eq!(complete.name, "hello.txt");
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("hello.txt")).await?,
|
||||
b"hello world"
|
||||
);
|
||||
assert!(
|
||||
!temp_dir
|
||||
.path()
|
||||
.join("staging")
|
||||
.join(&upload.upload_id)
|
||||
.exists()
|
||||
);
|
||||
|
||||
let duplicate = app
|
||||
.oneshot(empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", upload.upload_id),
|
||||
)?)
|
||||
.await?;
|
||||
assert_eq!(duplicate.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parallel_uploads_keep_bytes_separate() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let chunk_size = usize::try_from(CHUNK_SIZE)?;
|
||||
let left_upload = create_upload(&app, "left.bin", CHUNK_SIZE + 4).await?;
|
||||
let right_upload = create_upload(&app, "right.bin", CHUNK_SIZE + 5).await?;
|
||||
|
||||
let mut expected_left = vec![b'l'; chunk_size];
|
||||
expected_left.extend_from_slice(b"eft!");
|
||||
let mut expected_right = vec![b'r'; chunk_size];
|
||||
expected_right.extend_from_slice(b"ight!");
|
||||
|
||||
let left_first = chunk_request(
|
||||
&left_upload.upload_id,
|
||||
0,
|
||||
expected_left[..chunk_size].to_vec(),
|
||||
)?;
|
||||
let left_final = chunk_request(
|
||||
&left_upload.upload_id,
|
||||
1,
|
||||
expected_left[chunk_size..].to_vec(),
|
||||
)?;
|
||||
let right_first = chunk_request(
|
||||
&right_upload.upload_id,
|
||||
0,
|
||||
expected_right[..chunk_size].to_vec(),
|
||||
)?;
|
||||
let right_final = chunk_request(
|
||||
&right_upload.upload_id,
|
||||
1,
|
||||
expected_right[chunk_size..].to_vec(),
|
||||
)?;
|
||||
|
||||
let (left_first, right_first, left_final, right_final) = tokio::join!(
|
||||
app.clone().oneshot(left_first),
|
||||
app.clone().oneshot(right_first),
|
||||
app.clone().oneshot(left_final),
|
||||
app.clone().oneshot(right_final),
|
||||
);
|
||||
|
||||
assert_eq!(left_first?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(right_first?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(left_final?.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(right_final?.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let left_complete = empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", left_upload.upload_id),
|
||||
)?;
|
||||
let right_complete = empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", right_upload.upload_id),
|
||||
)?;
|
||||
|
||||
let (left_complete, right_complete) = tokio::join!(
|
||||
app.clone().oneshot(left_complete),
|
||||
app.clone().oneshot(right_complete),
|
||||
);
|
||||
|
||||
assert_eq!(left_complete?.status(), StatusCode::OK);
|
||||
assert_eq!(right_complete?.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("left.bin")).await?,
|
||||
expected_left
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("right.bin")).await?,
|
||||
expected_right
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_incomplete_upload() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, "partial.bin", CHUNK_SIZE + 1).await?;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(&upload.upload_id, 1, b"x".to_vec())?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", upload.upload_id),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert!(
|
||||
!temp_dir
|
||||
.path()
|
||||
.join("complete")
|
||||
.join("partial.bin")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_completion_that_would_replace_file() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, "clash.bin", 8).await?;
|
||||
tokio::fs::write(
|
||||
temp_dir.path().join("complete").join("clash.bin"),
|
||||
b"original",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chunk_request(&upload.upload_id, 0, b"incoming".to_vec())?)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", upload.upload_id),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp_dir.path().join("complete").join("clash.bin")).await?,
|
||||
b"original"
|
||||
);
|
||||
assert!(
|
||||
temp_dir
|
||||
.path()
|
||||
.join("staging")
|
||||
.join(&upload.upload_id)
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tampered_temp_upload_file() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let upload = create_upload(&app, "corrupt.bin", 4).await?;
|
||||
|
||||
let upload_dir = temp_dir.path().join("staging").join(&upload.upload_id);
|
||||
tokio::fs::write(upload_dir.join(".upload.tmp"), b"bad").await?;
|
||||
tokio::fs::write(upload_dir.join("completed").join("000000.done"), b"").await?;
|
||||
|
||||
let response = app
|
||||
.oneshot(empty_request(
|
||||
Method::POST,
|
||||
&format!("/api/uploads/{}/complete", upload.upload_id),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert!(
|
||||
!temp_dir
|
||||
.path()
|
||||
.join("complete")
|
||||
.join("corrupt.bin")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_upload(
|
||||
app: &axum::Router,
|
||||
name: &str,
|
||||
size: u64,
|
||||
) -> Result<CreateUploadResponse, Box<dyn std::error::Error>> {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
Method::POST,
|
||||
"/api/uploads",
|
||||
&json!({
|
||||
"name": name,
|
||||
"size": size,
|
||||
"last_modified": 1_760_000_000_000_i64
|
||||
}),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
decode_body(response).await
|
||||
}
|
||||
|
||||
fn test_app(data_dir: &Path) -> axum::Router {
|
||||
build_router(&AppConfig::new(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
|
||||
data_dir,
|
||||
))
|
||||
}
|
||||
|
||||
fn json_request(
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(body)?))?)
|
||||
}
|
||||
|
||||
fn chunk_request(
|
||||
upload_id: &str,
|
||||
index: u64,
|
||||
body: Vec<u8>,
|
||||
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method(Method::PUT)
|
||||
.uri(format!("/api/uploads/{upload_id}/chunks/{index}"))
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(Body::from(body))?)
|
||||
}
|
||||
|
||||
fn empty_request(method: Method, uri: &str) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(Body::empty())?)
|
||||
}
|
||||
|
||||
async fn decode_body<T>(response: axum::response::Response) -> Result<T, Box<dyn std::error::Error>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let body = response.into_body().collect().await?.to_bytes();
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use axum::{body::Body, http::Request};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
use upl::app::{AppConfig, build_router};
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_index_page() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app = test_app();
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await?.to_bytes();
|
||||
let body = String::from_utf8(body.to_vec())?;
|
||||
|
||||
assert!(body.contains("<title>upl</title>"));
|
||||
assert!(body.contains("Choose files"));
|
||||
assert!(body.contains("Selected uploads"));
|
||||
assert!(body.contains("Saved upload progress"));
|
||||
assert!(!body.contains("Server online"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_health() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app = test_app();
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/healthz").body(Body::empty())?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await?.to_bytes();
|
||||
assert_eq!(body.as_ref(), b"ok");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_app() -> axum::Router {
|
||||
build_router(&AppConfig::new(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-data/static-server"),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
use upl::{
|
||||
app::{AppConfig, build_router},
|
||||
model::{CHUNK_SIZE, CreateUploadResponse, UploadMeta},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_upload_metadata_on_disk() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(
|
||||
"/api/uploads",
|
||||
&json!({
|
||||
"name": "movie:mkv",
|
||||
"size": CHUNK_SIZE + 1,
|
||||
"last_modified": 1_760_000_000_000_i64
|
||||
}),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response_body = response.into_body().collect().await?.to_bytes();
|
||||
let response: CreateUploadResponse = serde_json::from_slice(&response_body)?;
|
||||
|
||||
assert_eq!(response.chunk_size, CHUNK_SIZE);
|
||||
assert_eq!(response.total_chunks, 2);
|
||||
assert!(response.completed_chunks.is_empty());
|
||||
|
||||
let upload_dir = temp_dir.path().join("staging").join(&response.upload_id);
|
||||
let meta_path = upload_dir.join("meta.json");
|
||||
assert!(upload_dir.join(".upload.tmp").is_file());
|
||||
assert!(upload_dir.join("completed").is_dir());
|
||||
assert!(temp_dir.path().join("complete").is_dir());
|
||||
|
||||
let meta: UploadMeta = serde_json::from_slice(&tokio::fs::read(meta_path).await?)?;
|
||||
assert_eq!(meta.id, response.upload_id);
|
||||
assert_eq!(meta.original_name, "movie:mkv");
|
||||
assert_eq!(meta.safe_name, "movie_mkv");
|
||||
assert_eq!(meta.total_chunks, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_upload_name() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(
|
||||
"/api/uploads",
|
||||
&json!({
|
||||
"name": " ",
|
||||
"size": 10,
|
||||
"last_modified": 1_760_000_000_000_i64
|
||||
}),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_upload_name_that_already_exists() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let app = test_app(temp_dir.path());
|
||||
let complete_dir = temp_dir.path().join("complete");
|
||||
tokio::fs::create_dir_all(&complete_dir).await?;
|
||||
tokio::fs::write(complete_dir.join("xyz.foo"), b"original").await?;
|
||||
|
||||
let response = app
|
||||
.oneshot(json_request(
|
||||
"/api/uploads",
|
||||
&json!({
|
||||
"name": "xyz.foo",
|
||||
"size": 10,
|
||||
"last_modified": 1_760_000_000_000_i64
|
||||
}),
|
||||
)?)
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
|
||||
let body = response.into_body().collect().await?.to_bytes();
|
||||
let body: serde_json::Value = serde_json::from_slice(&body)?;
|
||||
assert_eq!(body["error"], "file already exists");
|
||||
assert_eq!(
|
||||
tokio::fs::read(complete_dir.join("xyz.foo")).await?,
|
||||
b"original"
|
||||
);
|
||||
|
||||
let mut staging_entries = tokio::fs::read_dir(temp_dir.path().join("staging")).await?;
|
||||
assert!(staging_entries.next_entry().await?.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_app(data_dir: &Path) -> axum::Router {
|
||||
build_router(&AppConfig::new(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/static"),
|
||||
data_dir,
|
||||
))
|
||||
}
|
||||
|
||||
fn json_request(
|
||||
uri: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<Request<Body>, Box<dyn std::error::Error>> {
|
||||
Ok(Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body)?))?)
|
||||
}
|
||||
Reference in New Issue
Block a user