English | 简体中文
A pure-browser, HTTP-based large-file transfer server. No client tools needed — just open a browser and start uploading.
tl;dr: Browser-based
rsyncfor transferring multi-GB files, folders, and resuming after interruption. Zero dependencies on the client side.
A browser-side rsync purpose-built for Kubernetes + shared storage — no client install, no central session store, no plugin runtime; authentication is injected at compile time through a Rust trait.
- mtime-based session lease — upload sessions live in the
.partfile's mtime on the shared storage itself: zero coordination between replicas, no session store, no external lock service. - Zero-build embedded debug UI — the complete single-page frontend is baked into the binary via
rust-embed; no Node/npm toolchain anywhere. - Rust-trait extensibility — auth/path mapping (
RequestHook) and response headers (ResponseHook) compile into your own binary: no FFI, no GC runtime, no hot-reload plugin machinery. - Small and safe — zero
unsafein the codebase, distroless container image, ~3 MB stripped release binary.
filedrop solves transport only. Product business logic — authentication, tenancy, quota, rate limiting — is implemented by your deployment through the hook traits, never inside filedrop.
- GB-scale uploads — chunked at 2 MiB, serial chunks per file (the built-in UI adds 3-file client-side concurrency)
- Resume after interruption —
.parttemp files + chunk-aligned offsets; next scan picks up where it left off - Incremental skip — SHA-256 hash match → skip unchanged files entirely
- Folder transfer — drag a folder, directory structure is preserved
- Directory listing —
GETa directory to list its first level as JSON - Real-time speed — per-file live speed plus total up/down rate (
U 1.2 MB/s · D 3.4 MB/s), in the toolbar and the floating widget - Background transfer — minimize to a draggable floating widget; keep browsing
- Bulk cancel + resume — cancel all, resume later; cancel one file, others continue
- Range download — RFC 7233 single-range support; compatible with
curl -C -,wget -c,aria2c -c - Delete —
DELETEa file, or recursively delete a directory; the dev UI adds checkboxes + a delete-selected action - Partial-upload cleanup — interrupted uploads show up in the listing (
partial:true, with progress); deletable unless an active session is still feeding them (409, replica-safe via shared-storage mtime); deliberate cancels release the session immediately (PATCH) - RESTful API — resource path in the URL, operation selected by HTTP method
- Compile-time pluggable auth + path mapping — implement one
RequestHooktrait (JWT, SSO, tenant path prefixes, …) in your own crate and build your own binary against thefiledroplibrary - Graceful shutdown — waits for in-flight requests, 10-second fallback
- Zero client install — single HTML file embedded in the binary
Built for AI/ML platforms where algorithm engineers move multi-GB datasets and model weights onto cluster storage:
- They are not systems operators — mastering
rsync/sftp(keys, flags, retries) is not their job. - Dev machines are personal laptops running different OSes; the browser is the one client that is always there — zero extra tooling.
- Storage is a shared filesystem (CephFS, NFS, GlusterFS, … behind Kubernetes PVCs) mounted by training/inference pods. A
RequestHookresolves the target PVC per request — e.g. a query parameter names the PVC, a JWT yields the user/tenant, and the hook maps namespace → PVC → PV → a path relative to the filedrop root — so browser uploads (the dominant direction) land directly inside the PVC the pods mount.
# Clone and build (requires Rust 1.87+ — `rust-version` in Cargo.toml)
git clone https://github.com/infrawaves-venus/filedrop.git
cd filedrop
cargo build --release
# Run (development mode — built-in allow-all auth, URL-path passthrough)
FILEDROP_ROOT=./data ./target/release/filedrop
# Open browser: http://localhost:8080Drag files or folders into the browser, click Start Transfer, and you're done.
All configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
FILEDROP_ADDR |
127.0.0.1:8080 |
Listen address. Loopback-only by default — set 0.0.0.0:8080 explicitly to expose the server beyond the local machine (the Dockerfile does this; the default PassthroughHook logs a warning on non-loopback binds) |
FILEDROP_ROOT |
./data |
Storage root directory (production: mount the shared filesystem here). The server refuses to start if it resolves to a filesystem root (/, C:\, …) or the home directory — filedrop serves and deletes everything under the root |
FILEDROP_CHUNK_SIZE |
2097152 |
Upload chunk size in bytes (must match frontend; frontend reads it from GET /config) |
FILEDROP_UPLOAD_ACTIVE_TTL_SECS |
60 |
mtime freshness window that classifies a partial upload as active (listed as active:true, protected from DELETE with 409) |
FILEDROP_CORS_ALLOW_ORIGINS |
(unset) | Comma-separated allowed origins, or * for any. Unset/empty leaves CORS off — no CORS layer is mounted and every other FILEDROP_CORS_* variable is ignored. When set, filedrop handles CORS itself (preflight included), so you don't have to terminate it at an ingress/gateway |
FILEDROP_CORS_ALLOW_METHODS |
(filedrop's methods) | Comma-separated allowed methods, or *. Empty falls back to GET, HEAD, PUT, POST, PATCH, DELETE |
FILEDROP_CORS_ALLOW_HEADERS |
(mirror request) | Comma-separated allowed request headers, or *. Empty mirrors the preflight's Access-Control-Request-Headers |
FILEDROP_CORS_EXPOSE_HEADERS |
(none) | Comma-separated response headers exposed to browser JS, or *. Set this (e.g. upload-offset) if a browser client must read filedrop's non-safelisted response headers |
FILEDROP_CORS_ALLOW_CREDENTIALS |
false |
true/1/yes/on to send Access-Control-Allow-Credentials: true. Requires explicit (non-*) origins/methods/headers — combining it with * is refused at startup (Fetch standard) |
FILEDROP_CORS_MAX_AGE_SECS |
(unset) | Access-Control-Max-Age in seconds (preflight cache lifetime); omitted when unset |
FILEDROP_CHUNK_SIZE and FILEDROP_UPLOAD_ACTIVE_TTL_SECS fall back to their
defaults (with an INFO log) when set to an unparseable value or 0 — a zero
chunk size or TTL would break chunk alignment / the lease check.
CORS is applied as the outermost layer, ahead of the /files request hook, so
a browser preflight (which carries no credentials) is answered before auth can
reject it. Leave FILEDROP_CORS_ALLOW_ORIGINS unset if you terminate CORS at an
ingress or gateway.
Request → request_hook → validate_path → track_requests → handler → response_hook
- Request Hook (
RequestHook): a single combined authn/authz + path-mapping stage. It verifies the request (token, claims, …) and maps the URL path (the part after/files/, percent-decoded) to a storage-relative path, which is stored on the request context (RawPath) for the stages below. A rejection terminates the stack with the hook-supplied HTTP status code (401, 403, …) and error message. Default:PassthroughHookallows everything and uses the URL path verbatim. - Validate Path: lexically cleans the path, rejects
..that escapes the storage root, and additionally walks the existing components of the cleaned path to reject any symlink (on Windows: any reparse point, junctions included) — a link planted inside the root pointing outside it is invisible to lexical cleaning alone. The result is stored asValidatedPathfor handlers. This is not pluggable — it's a hard requirement, never left to hook logic. - Track Requests: counts active requests for graceful shutdown.
- Response Hook (
ResponseHook): the outermost stage, invoked once per response (UI, assets, config, probes and/filesalike) with the request path, method and final status. It may add/replace/remove response headers only — the streaming/filesbody is never touched. Default:DefaultSecurityHooksendsX-Content-Type-Options: nosniffandX-Frame-Options: DENYon every response, plusContent-Security-Policy: default-src 'none'; sandboxonGET /files/*(so downloaded content can never execute), while leaving the debug UI's inline handlers working.
Auth and path mapping share one hook deliberately: the mapping usually depends on the identity extracted during auth (user id, tenant claim), so any intermediate context stays inside the hook implementation — no cross-plugin context passing.
The resource (a file or directory) is identified by its path in the URL under
/files/. The HTTP method selects the operation.
| Method | Path | Operation |
|---|---|---|
GET |
/files/{path} |
Download a file (Range), or list a directory |
HEAD |
/files/{path} |
Metadata / resumable-upload progress |
PUT |
/files/{path} |
Upload one chunk |
PATCH |
/files/{path} |
Release the upload session on a partial (mark idle) |
DELETE |
/files/{path} |
Delete a file, or recursively delete a directory |
POST |
/files/{base} |
Reconcile an upload manifest under {base} (scan) |
Auxiliary endpoints: GET /config (frontend config + version info), GET /healthz, GET /readyz.
# Full file
curl -O -J http://localhost:8080/files/models/llama3.bin
# Resume from byte 1048576
curl -C - -O -J http://localhost:8080/files/models/llama3.bin \
-H "Range: bytes=1048576-"Every file response carries Content-Disposition: attachment; filename="..."
plus the RFC 5987 filename*=UTF-8''... form (percent-encoded UTF-8, the same
convention as nginx and S3), so non-ASCII filenames survive the round-trip in
every browser. A Range that cannot be satisfied (malformed, or start beyond
EOF) returns 416 Range Not Satisfiable with Content-Range: bytes */<size>.
A zero-byte file downloads as a plain 200 with Content-Length: 0; any
Range request against it returns 416 with Content-Range: bytes */0 — an
empty file has no satisfiable byte.
When {path} is a directory the response is a JSON listing of its first
level (non-recursive):
curl http://localhost:8080/files/models
# → [{"name":"sub","dir":true,"size":0,"mtime":1700000000},{"name":"llama3.bin","dir":false,"size":1073741824,"mtime":1700000000}]GET /files lists the storage root.
Interrupted uploads staged under .filedrop_tmp/ appear as extra entries with
"partial":true, listed under their original file name so the space they hold
is visible and reclaimable:
{"name":"big.bin","dir":false,"size":314572800,"mtime":1700000000,
"partial":true,"active":false,"total":2147483648}size— bytes received so far;total— expected final size (from the.metasidecar), so a client can render a percentage.active— the.partmtime is fresher thanFILEDROP_UPLOAD_ACTIVE_TTL_SECS, meaning some replica is still receiving chunks for it. mtime comes from the shared storage, so the judgement is consistent across all replicas.
curl -I http://localhost:8080/files/models/llama3.binResponse headers:
Upload-Offset— chunk-aligned bytes already received (resume point)X-Upload-Complete—trueif the final file exists,falsefor a.part- complete files also return
Content-LengthandAccept-Ranges: bytes 404if neither the final file nor a.partexists
curl -X PUT http://localhost:8080/files/models/llama3.bin \
-H "Content-Range: bytes 0-2097151/1073741824" \
-H "X-SHA256: a1b2c3..." \
--data-binary @chunk.binContent-Range: bytes {start}-{end}/{total} gives the offset and total file
size; X-SHA256 is the expected digest of the whole file (verified when the
last chunk lands, before the .part is renamed into place). The server
maintains an incremental hash midstate in the .meta sidecar as chunks
arrive — shared-storage-resident, so it works across replicas — and an
uninterrupted upload finalises without re-reading the staged file. A restart
from byte 0 rebuilds the midstate as the bytes arrive again; only a
mid-stream rewrite of already-hashed bytes degrades finalise to the full
re-read fallback.
Response: {"ok":true,"offset":0,"complete":false,"next_offset":2097152},
plus an Upload-Offset header carrying the same value as next_offset —
the chunk-aligned resume point after this write (identical to what HEAD
would report), so a client that lost track of its position can self-correct
mid-stream. On the final chunk complete is true and next_offset equals
the total size.
Don't guess the starting offset. Before the first PUT — and after any
interruption — ask the server where to start: HEAD returns Upload-Offset
and a scan returns offset; continue from there, and follow the
Upload-Offset echoed by each PUT response.
A chunk body larger than the configured FILEDROP_CHUNK_SIZE is rejected
with 413 Payload Too Large; the error message names the configured limit.
The Content-Range header must satisfy start <= end < total and the body
must carry exactly the end - start + 1 bytes it announces — anything else
is 400 Bad Request, checked before a single byte is staged, so a truncated
chunk can't leave a hole that surfaces as a hash mismatch much later.
The Content-Range total must stay constant across all chunks of one file:
a chunk announcing the same X-SHA256 but a different total is rejected
with 400 Bad Request. (A chunk announcing a different hash is a competing
upload instead: 409 Conflict while the existing staging session is active.)
If the underlying filesystem write fails because storage is exhausted —
EDQUOT (disk quota exceeded) or ENOSPC (no space left on device) — the
server responds 507 Insufficient Storage. Other handler errors return
500 with {"error": "<message>"}.
Check which files need uploading, skipping, or resuming, relative to {base}
(omit it to scan from the root):
curl -X POST http://localhost:8080/files \
-H "Content-Type: application/json" \
-d '[{"rel_path":"models/llama3.bin","size":1073741824,"sha256":"a1b2c3..."}]'Response: [{"rel_path":"models/llama3.bin","action":"skip|full|partial","offset":0}]
The manifest body is capped at 8 MiB (SCAN_BODY_LIMIT); a larger manifest is
rejected with 413 and a message naming the limit and suggesting smaller
batches. A row costs ~200 bytes, so one scan admits tens of thousands of
files. The built-in UI stays under the cap by posting the manifest in batches
of 5,000 entries and merging the per-batch results.
Delete a file, or recursively delete a directory and everything under it:
curl -X DELETE http://localhost:8080/files/models/llama3.bin
curl -X DELETE http://localhost:8080/files/models # recursiveReturns 204 No Content. Apart from the active-upload guard described below,
the server performs no confirmation — the caller is trusted to have confirmed
intent (the web UI prompts first). Deleting a missing path still returns 204
(idempotent). Only /files/{path} accepts DELETE;
the storage root cannot be deleted.
There is deliberately no batch-delete endpoint: it would break the RESTful
mapping (the URL names one resource, the HTTP method is the operation), and the
expected flow is a paginated product UI where a human ticks a handful of
checkboxes — at that scale a client-side loop issuing one DELETE per path is
entirely adequate (the dev UI does exactly that).
Deleting a path also removes its staged partial upload (.part + .meta), so
DELETE is how the space held by an interrupted upload is reclaimed. If the
partial is still being fed by an active upload session (fresh mtime within
FILEDROP_UPLOAD_ACTIVE_TTL_SECS — valid across replicas, since all replicas
see the same shared-storage mtime), the server refuses with 409 Conflict.
A client that has just cancelled its own upload can bypass the freshness
guard with the X-Force-Delete: 1 header (the web UI does this when you
cancel an upload and confirm deleting the partial data), or first PATCH the
path to release the session and then delete normally.
X-Force-Delete is honoured on the client's word — the server cannot verify
that the requester really owns the active session. If that trust is too broad
for your deployment, gate it in your RequestHook: the header is visible in
the hook's headers argument, so the hook can reject force-deletes (e.g.
403) unless the caller is an admin or holds a specific permission.
Announce that the upload session feeding {path}'s partial is over (the user
cancelled deliberately but keeps the data for a later resume):
curl -X PATCH http://localhost:8080/files/models/llama3.binReturns 204 (released) or 404 (no partial exists). The server backdates
the .part mtime past the activity window — the mtime is the session lease,
chunk writes renew it, PATCH expires it — so every replica immediately
lists the partial as active:false and allows a plain DELETE, instead of
showing it as an active session for up to another TTL window. Unannounced
interruptions (closed tab, crash, network loss) still age out via the TTL.
Resuming later is unaffected (the next chunk write renews the lease). Note
the listed mtime of a released partial shifts back by the TTL accordingly.
Kubernetes probes. readyz returns 503 during graceful shutdown.
Server configuration for the built-in frontend:
curl http://localhost:8080/config
# → {"chunk_size":2097152,"upload_active_ttl_secs":60,"git_commit":"bb5ed26...","build_time":"2026-07-09T05:34:27Z"}The web UI reads chunk_size (so its chunk slicing matches the server) and
upload_active_ttl_secs (so its activity wording matches the server's TTL)
before uploading. git_commit / build_time are stamped at build time
(Makefile/Dockerfile); a plain cargo build reports unknown. The web UI
shows the short commit next to the logo, with the full stamp in a tooltip.
filedrop is a library crate with a default binary. The default binary runs
PassthroughHook (no auth, URL path used verbatim) and is suitable for
development and trusted single-tenant use; it logs a WARN at startup when the
server listens on a non-loopback address. A production deployment keeps its
business logic (JWT secrets handling, tenant path layout, …) in its own
private crate, depends on filedrop, implements RequestHook, and builds its
own binary:
use axum::http::{HeaderMap, StatusCode};
use filedrop::{Config, DefaultSecurityHook, Rejection, RequestHook};
use std::sync::Arc;
struct MyHook;
impl RequestHook for MyHook {
fn handle(
&self,
url_path: &str, // percent-decoded path after `/files/`
headers: &HeaderMap,
query: &str, // URL query string, without leading `?`
) -> Result<String, Rejection> {
// 1. Auth: verify the token; reject with any status + message.
let user = verify_token(headers)
.map_err(|_| Rejection::new(StatusCode::UNAUTHORIZED, "invalid token"))?;
// 2. Path mapping: identity feeds directly into the mapping.
// Return the raw storage-relative path; the host validates it.
Ok(format!("{}/{}", user.tenant, url_path))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
filedrop::serve(
Config::from_env()?,
Arc::new(MyHook),
Arc::new(DefaultSecurityHook),
)
.await
}A runnable version of this pattern lives in examples/custom_hook.rs
(cargo run --example custom_hook).
The hook's returned path is a pure mapping result: the host lexically cleans
it and rejects root escapes in the non-pluggable validation middleware, so
path safety never depends on hook code. Everything else (Config fields,
chunk size, graceful shutdown) works the same as the default binary.
serve() also takes a ResponseHook, the outermost stage run once per
response across every route. It receives a ResponseCtx (request path,
method, and the final status) and a &mut HeaderMap, and may add, replace
or remove response headers only — the streaming /files body is off
limits by contract, so downloads keep their range/resume semantics.
The shipped DefaultSecurityHook is a sane baseline:
X-Content-Type-Options: nosniffandX-Frame-Options: DENYon every response (MIME-sniffing and clickjacking guards).Content-Security-Policy: default-src 'none'; sandboxonGET /files/*only — neutralizes any script/embed inside downloaded user content. It is intentionally omitted from the debug UI, whose inlineonclickhandlers need a laxer policy.
A deployment classifies routes by inspecting ctx.path (starts_with "/files", "/assets", == "/", …) and can swap in its own policy (custom
CSP, HSTS, cache headers, …):
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
use filedrop::{ResponseCtx, ResponseHook};
struct MySecurityHook;
impl ResponseHook for MySecurityHook {
fn handle(&self, ctx: &ResponseCtx, headers: &mut HeaderMap) {
headers.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
if ctx.path.starts_with("/files") {
headers.insert(
"strict-transport-security",
HeaderValue::from_static("max-age=63072000"),
);
}
}
}Pass it as the third argument: filedrop::serve(config, request_hook, Arc::new(MySecurityHook)).
# Toolchain: Rust 1.87+ (rust-version in Cargo.toml — older toolchains refuse to build)
# Development
cargo build
FILEDROP_ROOT=./data cargo run
# Example deployment binary with a custom RequestHook
cargo run --example custom_hook
# Production (smallest binary, LTO)
cargo build --release
# Production run (shared filesystem mounted at the storage root)
FILEDROP_ADDR=0.0.0.0:8080 \
FILEDROP_ROOT=/mnt/shared-fs \
./target/release/filedrop # or your own binary built against the filedrop crate
# Container image (COPY-based build → distroless cc image, version-stamped)
make # or: docker build .The frontend lives in ui/ — a thin HTML shell (index.html) plus split assets (assets/styles.css, assets/i18n.js, assets/app.js, the vendored assets/hash-wasm.js, and assets/favicon.svg). The whole directory is baked into the binary at compile time via rust-embed and served at / and /assets/*. No external static file server is needed.
The top bar has a + button to add custom request headers: each click
adds a key/value input pair with a − button to remove it. One row is
pre-created with the key Authorization (fill in your token, e.g.,
Bearer eyJ...). Every pair with a non-empty key and value is sent as-is on
every /files/* request; rows with an empty key or value send nothing, and
duplicate names overwrite earlier ones.
Next to the header inputs, a + button adds custom query params: each
click adds a key/value input pair with a − button to remove it. All pairs
with a non-empty key are URL-encoded and appended to every /files/* request
(and to the generated curl/wget/aria2c commands). Duplicate keys are allowed
(names=a&names=b). By default no params are added. Server-side, the query
string reaches the RequestHook via its query argument.
Upload pipeline details: hashing streams each file and polls the cancel flags between chunks, so Cancel works even mid-hash of a very large file; the scan manifest is posted in batches of 5,000 entries to stay under the server's 8 MiB scan body cap; a failed chunk is retried up to 3 times with exponential backoff plus jitter (capped at 8 s).
filedrop is a transfer component, not a standalone site. In production:
- Always implement your own
RequestHook— never deploy stock filedrop. The default binary'sPassthroughHookperforms no authentication; production runs your own binary built against thefiledropcrate (see Custom Deployments). - Expose only the
/filesAPI (plus/healthz,/readyz) through your Ingress / Gateway API routes. Do not expose/and/assets/*— the embedded UI is a debugging convenience. Shipping a product UI built on the/filesAPI is fine, but that UI belongs to another service; it is not filedrop's component. - Same-origin, or configure CORS. The simplest setup routes the
/filesAPI under the same domain (or a subdomain) as the frontend that calls it, so no cross-origin request occurs. When the frontend lives on a different origin, setFILEDROP_CORS_ALLOW_ORIGINS(see Configuration) and filedrop applies CORS itself, preflight included — no ingress/gateway CORS rules needed. If a browser client must read filedrop's non-safelisted response headers (e.g.Upload-Offset,Content-Rangefor resumable uploads), add them toFILEDROP_CORS_EXPOSE_HEADERS. TLS and domain names remain the deployment's responsibility (terminate TLS at the ingress/gateway). - Quota lives in the storage backend, not in filedrop — size the PVCs / set filesystem quotas (e.g. CephFS quotas). When a write hits
EDQUOT/ENOSPC, the server surfaces507 Insufficient Storage.
CSI drivers generally provision each PVC as a subpath of one shared
filesystem, so this pattern is not CephFS-specific — CephFS is just the
worked example here. Its CSI driver creates every PVC as a subvolume under
a single filesystem root (e.g. /volumes/csi), which turns "one filedrop
deployment serving every PVC in the cluster" into a pure path-mapping
exercise:
- Mount the CephFS root as the filedrop root. Statically provision a
PV that points at the CSI root (
staticVolume: "true",rootPath: /volumes/csi), bind a PVC to it, and mount that PVC in the filedrop pod at the identical path, withFILEDROP_ROOT=/volumes/csi. - Resolve the target PVC to a root-relative path in your
RequestHook. Given a PVC (e.g. named by a query parameter), follow PVC → bound PV →spec.csi.volumeAttributes.subvolumePath(a path like/volumes/csi/csi-vol-<uuid>/…), strip the root prefix, and return the remainder joined with the request path. Because the root is mounted at the identical path, no further translation is needed — and watch-backed caches (informers) make the lookup a pure in-memory operation inside the synchronous hook.
Browser uploads then land directly inside the PVC that training/inference
pods mount. Quota still comes free: CephFS enforces each PVC's size on its
subvolume regardless of which mount the write arrives through, so
exhaustion surfaces as 507 Insufficient Storage.
- Browser SHA-256: hashing uses a vendored
hash-wasmstreaming SHA-256 (ui/assets/hash-wasm.js, no CDN), reading the file viafile.stream()so memory stays O(1) and files > 4 GB hash without loading the whole file. It also works over plain HTTP (no secure-context requirement). - Browser-side download buffering: the built-in UI's streaming download accumulates chunks in memory before triggering the save, so very large browser downloads are memory-bound. External tools (curl/wget/aria2c) stream to disk and have no such limit.
- Chunk size: configurable via
FILEDROP_CHUNK_SIZE(default 2 MiB). The built-in frontend fetches it fromGET /configbefore uploading so frontend and backend stay in sync. - Hooks are compiled in: swapping the
RequestHookmeans rebuilding and redeploying the binary (by design — no dynamic loading). - TLS recommended: hashing no longer needs a secure context, but production should still terminate TLS at the ingress/gateway for transport security.
- The storage root must be private to filedrop: cross-replica coordination (the mtime lease, the SHA-256 midstate in
.metasidecars) trusts the shared filesystem as-is. A process that writes.filedrop_tmpdirectly can forge staged bytes that still pass final SHA-256 verification via the midstate. Mount the root only into filedrop replicas; never share it with other writers — verification guards against transport corruption, not hostile co-writers on the same filesystem.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Vendored third-party content (the ui/DESIGN.md design reference and
ui/assets/hash-wasm.js) is listed in THIRD-PARTY-NOTICES.md.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in filedrop by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.