ShareDownload::$authenticated was a public property without #[Locked],
so a visitor with a share's link but not its password could set it from
the browser and see the file list: names, folder paths, sizes and
whether the share holds a text. The contents stayed protected; the flag
is now locked, and a test tries to set it.
Sessions hold the keys of the password shares a recipient unlocked, and
were stored unencrypted in the sessions table wherever SESSION_ENCRYPT
was not set, which includes every Docker install. They are now
encrypted unless SESSION_ENCRYPT=false. Share keys never live in the
session, so every share stays readable; people sign in again once.
The container generated an APP_KEY when none was set, printed it to the
logs and generated another on the next start, which left the shares
without a password unreadable. It now refuses to start without one and
says how to create it; docker-compose.example.yml requires it for both
services. The command it names, and the README's, now pass
--entrypoint php: the image's entrypoint ignores its arguments, so
"docker run <image> php artisan key:generate --show" never ran it.
Every proxy is trusted, so the password-reset mail built its link from
a host a forged X-Forwarded-Host could choose. It is now built from
APP_URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.
Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.
- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
ciphertext and tag per chunk. Each nonce holds the chunk index and a
last-chunk flag (the STREAM construction), so cut or reordered files
fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
wrapped with Argon2id (sodium, libsodium's interactive limits) in
shares.wrapped_key, which names its parameters. Password shares from
before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
pending share of its own, lists the files with their progress, retries
a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
abort. UploadChunkController only accepts chunks from the session that
started the share: a repeat is acknowledged, a skip gets 409 with the
count stored. Chunks go out as Blobs, which Chromium sends about eight
times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
needed and takes no files. The Docker image gains AUTO_HTTPS, which
serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
ZIP64) instead of decrypting whole files into memory and writing the
archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
endpoint's answers, completing a share, the streamed ZIP, cleanup,
and in Chromium a real chunked upload and the HTTPS warning; the
selected-files overflow test runs again. README, website, CHANGELOG
and .ai/rules follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Laravel 13 upgrade pulled Pint 1.27.1 to 1.29.3, which changed the
laravel preset's default rules. composer lint and the CI lint job fail
without this, so it is required rather than cosmetic.
Formatting only, applied by `vendor/bin/pint`. The rules that fired were
fully_qualified_strict_types, ordered_imports, single_blank_line_at_eof,
unary_operator_spaces, not_operator_with_successor_space, braces_position,
single_line_empty_body, single_line_after_imports and no_extra_blank_lines.
Kept separate from the upgrade commit to keep that diff reviewable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>