Multiple files per transfer
- The UI accepted several files but only files[0] was ever uploaded, so
recipients saw a list and received one file. Chunks now carry a
fileIndex, the upload session tracks each file separately, and finalize
assembles one encrypted payload and one File row per file.
- The download page lists real files with per-file download buttons; the
previously unused isFileDownloading/isFileDownloaded helpers now drive
that state. /api/file/[id] accepts a fileId, scoped to the transfer so
an id from another transfer cannot be fetched.
Large downloads
- Replace res.blob() with a helper that streams the response body to disk
via the File System Access API where available, keeping peak memory at
roughly one chunk instead of the whole file. Falls back to the blob
path elsewhere.
Password brute force
- Rate limit /api/verify and /api/file/[id] to 10 attempts per transfer,
per client, per 15 minutes; a correct password clears the counter.
Per-process state, matching the existing local-disk storage model.
Disk reclamation
- Nothing ever deleted payloads, so every transfer stayed on disk
regardless of expiresAt. Add a cleanup routine that marks lapsed
transfers EXPIRED, deletes payloads for expired and soft-deleted
transfers, and sweeps abandoned chunk directories. It refuses to unlink
anything outside UPLOAD_DIR. Exposed as POST /api/cron/cleanup behind
CRON_SECRET, failing closed when that is unset.
Build hygiene
- Stop ignoring ESLint during builds and fix all 70 resulting violations:
unused imports and state, unescaped JSX entities, explicit any, and a
missing useEffect dependency. Seven catch blocks discarded their error
silently and now log it.
- Delete dead code: two unused send components, the two legacy upload
endpoints they called, the unused whole-file WebCrypto helpers, and a
duplicate separator component differing only by a typo.
Add .env.example documenting configuration, including CRON_SECRET.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createWriteStream.write() returns false once its internal buffer is full;
both reassembly loops ignored that and kept queueing chunks, so the queue
grew in memory instead of flushing to disk. Measured over a 1.5 GiB
reassembly in 10 MB chunks, this buffered 170 MB versus 10 MB when the
drain event is awaited.
Also fail loudly on a missing chunk in the v1 loop, matching v2, rather
than silently writing a corrupt file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The application source was untracked, so this commit brings it under
version control together with fixes for the issues found while auditing
it. Notable fixes:
Authorization
- Require a session and scope to senderEmail on /api/transfers/[id]
(GET, DELETE) and .../resend. These were unauthenticated over an
autoincrement id, so the ids could be walked to soft-delete any
transfer, read sender/recipient metadata, or make the app mail
arbitrary recipients.
- Require a session on the legacy /api/send and /api/chunk-upload
endpoints, and take the sender from the session rather than a request
field so transfers cannot be posted as another user.
Encryption
- Replace the chunk encryption scheme. Every chunk was encrypted under
one shared session IV with its auth tag discarded, which reuses the
AES-GCM keystream (XORing two ciphertexts recovers plaintext without
the key) and left the stored file undecryptable, surfacing to users as
a wrong-password error. Chunks are now self-contained frames carrying
their own random IV and auth tag, behind a magic+salt header.
- Files written by the previous format now report UNSUPPORTED_FORMAT
instead of a misleading password error.
Download
- Verify the password against the stored bcrypt hash before serving a
file, and enforce expiresAt and DELETED/EXPIRED status.
- Move the password from the query string into a POST body so it stays
out of access logs and Referer headers.
- Record a download only after successful authentication.
- Decrypt frame by frame through a stream instead of buffering the whole
file, and encode the Content-Disposition filename per RFC 5987.
Data exposure
- /api/download ran before the password prompt and returned the full
transfer row, including absolute server file paths. It now returns
only what the pre-password screen renders; filenames, message and
recipient are withheld until /api/verify succeeds.
Correctness
- Fix BigInt handling that made /api/transfers and /api/transfers/[id]
fail unconditionally (JSON.stringify cannot serialize BigInt, and
seeding a BigInt reduce with 0 throws).
- Fail loudly on a missing chunk during reassembly rather than silently
writing a corrupt file.
- Meter plan usage in plaintext bytes rather than on-disk encrypted size.
Ignore /uploads: it holds runtime transfer payloads, not source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>