Compare commits

..

17 Commits

Author SHA1 Message Date
ddidderr 60663a461c fix: reject duplicate completed upload names
A user could select another local file with the same name as one that already
exists in completed storage. The upload would be allowed to start and only hit
an existing-file conflict late in the flow, which made the UI look like the
file was uploadable.

Reject duplicate sanitized names during upload creation so no staging record or
chunk transfer starts for a file that cannot be completed. Keep the completion
path non-replacing as a second guard by promoting through a no-overwrite file
creation path, with a hard-link fast path and copy fallback for custom temp
locations.

The browser now treats the server's duplicate-name conflict as a terminal row:
it disables the action, marks the item visually, and tells the user to rename
the file if they want to upload that copy.

Test Plan:
- just check

Refs: none
2026-05-30 18:42:55 +02:00
ddidderr 1923ff2a6f feat: support parallel multi-file uploads
The browser upload flow was built around one selected file and one global
upload state. That made the existing chunk pool useful for a single file, but
users could not start several selected files at the same time.

Refactor the browser state into per-file upload items. Each selected file now
has its own upload record, completed-chunk set, abort controller, retry state,
progress row, and saved IndexedDB resume record. The picker accepts multiple
files, `Start all` and `Resume all` use a bounded file-level pool, and each file
keeps the existing bounded chunk pool. This keeps parallel uploads useful
without letting one large selection create unbounded request fan-out.

Keep the server API unchanged. Each file still receives a separate server upload
id, and server-side progress remains authoritative before any missing chunks are
scheduled. Terminal conflicts still stop the affected file without overwriting
completed data.

Update the user-facing markup, styles, project docs, and test checklist for the
multi-file scheduler. Add a server regression test that interleaves two uploads
and verifies the completed files contain exactly their own bytes.

Test Plan:
- just check
- git diff --check
2026-05-30 18:32:29 +02:00
ddidderr a7b3abd54a fix: render fresh upload progress as empty
A newly selected file has no server upload record yet, so the UI calls the
progress renderer with zero completed chunks and zero total chunks. Treating
that zero-total state as complete made the progress bar jump to 100% before
any upload had started.

Render zero-total progress as empty instead. Existing resumable uploads still
show their server-authoritative completed chunk percentage, and completed
non-empty uploads still render as full because their completed count equals a
non-zero total.

Test Plan:
- just static-check
- just test
- git diff --check
2026-05-30 18:21:54 +02:00
ddidderr c072b93726 feat: write chunks directly to temp upload files
Completed uploads used to copy every staged chunk into a second file before
renaming the result into data/complete. That doubled write volume and required
peak disk space for both the chunk set and the final file.

Write each chunk directly into one private temp upload file at its final offset
instead. After a chunk write succeeds, record a tiny durable completion marker
for progress and resume scans. Completion now verifies the temp file length and
all markers, then renames the temp file into the completed upload directory.

Add UPL_TEMP_DIR and --temp-dir so operators can choose where upload metadata,
markers, and temp files live. The default remains data/staging, and docs call
out that the temp directory must be on the same filesystem as data/complete for
atomic promotion. The nginx example now aliases only the completed upload
directory, and the smoke test verifies that final-file alias.

This keeps the existing length-based validation model; it does not add per-chunk
hashing.

Test Plan:
- just check
- just nginx-smoke
- cargo clippy && cargo clippy --benches && cargo clippy --tests
- cargo +nightly fmt --all
- cargo clippy && cargo clippy --benches && cargo clippy --tests

Refs: none
2026-05-30 18:10:05 +02:00
ddidderr 428af52e2f fix: remove staging chunks after completed uploads
Successful completion moved the assembled file into data/complete but left the
upload staging directory behind, including all chunk files. Remove the upload's
staging directory only after the final file has been renamed into place so
incomplete and failed uploads remain resumable.

A repeat complete request for that old upload id now returns 404 because the
temporary upload record has been retired with its chunks.

Test Plan:
- just check

Refs: none
2026-05-30 17:59:21 +02:00
ddidderr 996ad5c4c8 feat: add CLI configuration flags
Add clap-powered --bind, --static-dir, and --data-dir flags for human-run
server configuration. The merge order is now explicit: command-line arguments
win over UPL_* environment variables, which still fall back to the existing
repository-local defaults.

Document the new flags and allow just run to forward arguments to cargo so the
help text can be checked through the normal task runner.

Test Plan:
- just check
- cargo run -- --help

Refs: none
2026-05-30 17:52:00 +02:00
ddidderr 8d81b436e5 fix: clarify saved upload completion UI
The previous page showed a static "Server online" pill even though it did not
track backend liveness. It also left the selected file in an uploadable state
after completion, which made it too easy to start the same file again and then
land in a saved record that could only fail with "complete file already
exists".

Remove the misleading server-status UI and make saved uploads describe their
next action. Records with every chunk uploaded now show a Finish action, stale
server records are cleared, and a terminal "complete file already exists"
response clears the saved browser progress instead of inviting another resume.
A successful completion also clears the active file selection so the primary
actions settle back to idle.

Test Plan:
- just check

Refs: none
2026-05-30 17:39:44 +02:00
ddidderr 547838e285 fmt: improve formatting 2026-05-30 17:27:50 +02:00
ddidderr c3b47047d4 docs: tidy manual test checklist
Clarify that the manual scenarios remain useful for real browser and deployment
retests, and keep the nginx deployment retest item separate from the checksum
command example.

Test Plan:
- just check
2026-05-30 17:22:27 +02:00
ddidderr aeec7a0345 test: cover chunk validation edge cases
Add focused regression coverage for validation rules called out in PLAN.md.
Chunk upload tests now prove that an otherwise valid upload rejects an
out-of-range chunk index through the HTTP API. Completion tests now prove that a
manually corrupted chunk file is not assembled into a final file.

Update TESTS.md so the reusable checklist reflects these automated proofs.

Test Plan:
- just check
- just nginx-smoke

Refs: PLAN.md validation checklist
2026-05-30 17:22:27 +02:00
ddidderr 858f4d949c chore: add nginx deployment smoke test
Add the nginx deployment artifact from PLAN.md. The example config keeps upl
behind nginx, sets client_max_body_size to 64 MiB, disables request buffering for
chunk uploads, forwards standard proxy headers, and leaves explicit placeholders
for TLS certificates and access control before public exposure.

Add just nginx-smoke as a reusable Docker-based verification. The script starts
upl with a temporary data directory, runs nginx as a reverse proxy, uploads a
17 MiB file through nginx, restarts the Rust backend mid-upload, confirms server
progress survives the restart through the proxy, uploads the remaining chunk,
completes the upload, and compares SHA-256 hashes.

Document the production nginx shape, the local Docker smoke-test caveat, and the
manual deployment retest scenario in TESTS.md.

Test Plan:
- bash -n scripts/nginx-smoke.sh
- just check
- just nginx-smoke

Refs: PLAN.md milestone 9
2026-05-30 17:22:26 +02:00
ddidderr 35cd0657bd feat: add browser resumable upload client
Replace the placeholder browser script with the PLAN.md upload flow. The static
UI now creates upload records, slices the selected file into fixed-size chunks,
uploads missing chunks with a concurrency pool of three workers, retries failed
chunks with exponential backoff, pauses via AbortController, and completes the
upload once the server has accepted every chunk.

Persist pending upload records in IndexedDB and render them in the page so a
reload can resume from server-authoritative progress. When the File System
Access API is available, the app stores a file handle and asks for read
permission during resume; when it is unavailable or permission is denied, the
same pending record resumes after the user reselects the matching file. Browser
state is helpful but not trusted: every resume starts by querying the server for
completed chunks.

Add a JavaScript syntax check to the justfile, update the static-page test and
documentation, and extend TESTS.md with the manual resume scenarios that still
need real-browser repetition.

Test Plan:
- just check
- UPL_BIND=127.0.0.1:39123 UPL_DATA_DIR=$(mktemp -d) cargo run
- curl -fsS http://127.0.0.1:39123/healthz
- curl -fsS http://127.0.0.1:39123/ | rg "Choose file|Pending uploads|app.js"
- firefox --headless --screenshot /tmp/upl-page.png http://127.0.0.1:39123/

Refs: PLAN.md milestones 5, 6, and 7
2026-05-30 17:08:02 +02:00
ddidderr 5ca52b5780 feat: assemble completed uploads
Implement POST /api/uploads/{id}/complete. The storage layer now reloads upload
metadata, verifies that every expected chunk exists with the exact expected
length, concatenates chunks in order into a temporary final file, flushes it,
and renames it into data/complete only after assembly succeeds.

The endpoint preserves staging data after completion, rejects incomplete uploads
with a conflict response, and refuses to overwrite an existing completed file.
This keeps failed or duplicate completion attempts explicit rather than silently
clobbering local files.

Extend the model, router, documentation, and test checklist for completion
responses and add integration coverage for successful assembly, incomplete
uploads, staging preservation, and duplicate completion conflicts.

Test Plan:
- just check

Refs: PLAN.md milestone 8
2026-05-30 17:02:59 +02:00
ddidderr 1594c65d89 feat: store raw upload chunks
Add the chunk upload and progress APIs from PLAN.md. PUT
/api/uploads/{id}/chunks/{index} now accepts raw octet-stream bodies, validates
unknown upload ids, out-of-range chunk indexes, and exact chunk lengths, then
writes through a temporary .part.tmp path before renaming the completed chunk
into place. Re-uploading an already-complete chunk is idempotent when the
existing file length matches the expected length.

GET /api/uploads/{id} now reports server-authoritative progress by scanning the
chunk directory and only counting chunk files whose lengths match metadata. The
router also raises Axum's request body limit to 64 MiB so the planned 16 MiB
chunks can reach the handler, matching the nginx deployment guidance.

Document the chunk storage responsibility and extend the reusable test checklist
with the new progress and validation coverage.

Test Plan:
- just check

Refs: PLAN.md milestones 3 and 4
2026-05-30 17:00:42 +02:00
ddidderr 24ecdbd251 feat: persist upload creation metadata
Add the first upload API endpoint from PLAN.md. POST /api/uploads now
validates the requested file name, generates a server-owned upload id, creates
the staging and complete directory layout, and writes durable meta.json before
returning chunk scheduling details to the browser.

Keep filesystem layout knowledge in storage.rs so later chunk upload and
completion work can reuse the same boundary. API handlers translate storage
errors into JSON HTTP responses without leaking layout details into the router.

Document the new modules and UPL_DATA_DIR configuration, and extend TESTS.md
with the automated creation coverage.

Test Plan:
- just check

Refs: PLAN.md milestone 2
2026-05-30 16:57:48 +02:00
ddidderr a3f369f437 feat: serve static upload app from axum
Introduce the first PLAN.md milestone: replace the hello-world binary with
an Axum server that binds to localhost by default, exposes a health endpoint,
and serves the static browser UI from the repository's static directory. The
router is available through the library crate so integration tests can exercise
server behavior without opening a network listener.

Add a justfile for routine validation and document the initial project shape,
configuration knobs, and reusable test checklist. The rustfmt config now uses
only stable options so the new formatting recipe runs without nightly warnings.

The upload API and resumable chunk behavior are intentionally left for later
milestones; the UI currently handles file selection only.

Test Plan:
- just check

Refs: PLAN.md milestone 1
2026-05-30 16:54:42 +02:00
ddidderr 4527e23b8b docs: add resumable upload plan
Document the minimal design for a personal large-file upload app that can
resume after browser, network, or server interruptions. The plan keeps the
first version intentionally small: one Rust server, one static browser UI,
filesystem-backed upload metadata, raw chunk uploads, and no database or
third-party resumable upload protocol.

The deployment notes include nginx as the external TLS and access-control
layer, with the Rust server bound behind it and upload-specific proxy settings
called out.

Test Plan:
- git diff --cached --check

Refs: user request
2026-05-30 16:46:27 +02:00
22 changed files with 5003 additions and 2 deletions
Generated
+1196
View File
@@ -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
View File
@@ -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 }
+357
View File
@@ -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.
+102
View File
@@ -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.
+68
View File
@@ -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
```
+45
View 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;
}
}
+24
View File
@@ -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
View File
@@ -1,3 +1,4 @@
edition = "2024"
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
+169
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod api;
pub mod app;
pub mod model;
pub mod storage;
+14 -2
View File
@@ -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(())
}
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+47
View File
@@ -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>
+290
View File
@@ -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;
}
}
+204
View File
@@ -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)?)
}
+311
View File
@@ -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)?)
}
+55
View File
@@ -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"),
))
}
+132
View File
@@ -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)?))?)
}