Encrypt uploads in the browser and send them in chunks
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
504971ad7f
commit
40e35bab0e
@@ -16,13 +16,13 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
|
||||
## Features
|
||||
|
||||
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
|
||||
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress; large files go up in chunks, each retried on its own if the connection drops
|
||||
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
|
||||
- **Encryption at Rest** — Files are encrypted on the server as they arrive, with AES-256-GCM (chunked, streaming); with a share password the key is derived from it and never stored. It is not end-to-end encryption: the server handles the files unencrypted while they are uploaded and downloaded
|
||||
- **Encryption at Rest** — Files are encrypted in the uploader's browser, chunk by chunk with AES-256-GCM, before they are sent, and are stored only in encrypted form; with a share password the share's key is wrapped with a key derived from it (Argon2id) and never stored as it is. It is not end-to-end encryption: the server issues the key, checks each chunk, and decrypts the files for downloads
|
||||
- **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link
|
||||
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
|
||||
- **Download Limits** — Set a maximum number of downloads per share
|
||||
- **ZIP Downloads** — Download all files in a share as a single ZIP archive
|
||||
- **ZIP Downloads** — Download all files in a share as a single ZIP archive, streamed as it is built, whatever the files' size
|
||||
- **Auto-Cleanup** — Expired shares and files are automatically deleted (hourly)
|
||||
- **Admin Dashboard** — View, manage, and delete all shares
|
||||
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
||||
@@ -42,8 +42,8 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
| **Application Server** | FrankenPHP (via Laravel Octane) |
|
||||
| **Frontend** | Livewire 4, Tailwind CSS 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) |
|
||||
| **Authentication** | Laravel Fortify |
|
||||
| **Encryption** | Chunked AES-256-GCM with PBKDF2-SHA256 key derivation |
|
||||
| **ZIP Downloads** | Native PHP ZipArchive |
|
||||
| **Encryption** | Chunked AES-256-GCM (WebCrypto in the browser), keys wrapped with Argon2id |
|
||||
| **ZIP Downloads** | [ZipStream-PHP](https://packagist.org/packages/maennchen/zipstream-php) |
|
||||
| **Testing** | Pest 5 with browser tests (Playwright) |
|
||||
| **Code Style** | Laravel Pint |
|
||||
| **Build Tool** | Vite |
|
||||
@@ -75,7 +75,7 @@ cp docker-compose.example.yml docker-compose.yml
|
||||
# Generate an app key and paste it into docker-compose.yml
|
||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||
|
||||
# Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME
|
||||
# Edit docker-compose.yml — set APP_KEY and APP_URL, and choose how HTTPS is served (below)
|
||||
# Then start:
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -88,7 +88,11 @@ Migrations run automatically on startup. Open your configured domain — the Set
|
||||
|----------|----------|-------------|
|
||||
| `APP_KEY` | Yes | Laravel encryption key |
|
||||
| `APP_URL` | Yes | Full URL (e.g. `https://share.example.com`) |
|
||||
| `SERVER_NAME` | Yes | Domain for auto-TLS (e.g. `share.example.com`) |
|
||||
| `AUTO_HTTPS` | No | `true` to fetch a Let's Encrypt certificate for `SERVER_NAME` and serve HTTPS on port 443 (port 80 redirects); default `false`, plain HTTP on port 80 for a reverse proxy |
|
||||
| `SERVER_NAME` | With `AUTO_HTTPS` | The domain to fetch the certificate for (e.g. `share.example.com`) |
|
||||
| `UPLOAD_CHUNK_SIZE_MB` | No | Size of each encrypted chunk the browser sends; default `16` |
|
||||
|
||||
**HTTPS is required for uploads.** Files are encrypted in the uploader's browser with WebCrypto, which browsers only offer over HTTPS or on `localhost`; over plain HTTP the upload page says so and takes no files (downloads keep working). Either set `AUTO_HTTPS: "true"` with `SERVER_NAME` — ports 80 and 443 must be reachable from the internet — or put a reverse proxy that terminates TLS in front of port 80.
|
||||
|
||||
**Volumes:**
|
||||
|
||||
@@ -101,16 +105,15 @@ Migrations run automatically on startup. Open your configured domain — the Set
|
||||
|
||||
**Large files:**
|
||||
|
||||
Uploads beyond the defaults need these limits raised together:
|
||||
Files go up in chunks of `UPLOAD_CHUNK_SIZE_MB`, one request each, so PHP's upload limits and a proxy's request timeout do not limit a file's size. What does:
|
||||
|
||||
| Limit | Where | Default |
|
||||
|-------|-------|---------|
|
||||
| `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` | Environment | `4G` — hard cap per file / per upload batch |
|
||||
| Max file size / Max size per share | Admin → Settings | 100 MB / 2 GB |
|
||||
| `LIVEWIRE_MAX_UPLOAD_TIME` | Environment | 30 minutes per upload |
|
||||
| `OCTANE_MAX_EXECUTION_TIME` / `PHP_MAX_EXECUTION_TIME` | Environment | 300 seconds — encrypting a large file takes a while |
|
||||
| Storage quota | Admin → Settings | 20 GB — files still uploading count towards it |
|
||||
| `UPLOAD_CHUNK_SIZE_MB` | Environment | `16` |
|
||||
|
||||
Behind a reverse proxy, raise its request body limit and read timeout as well (nginx: `client_max_body_size`, `proxy_read_timeout`).
|
||||
Behind a reverse proxy, its request body limit must be a little larger than a chunk (nginx: `client_max_body_size 32m;`), and `proxy_request_buffering off;` keeps nginx from writing each chunk to its own temporary files. `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` (default `64M`) only apply to the admin's logo upload. An upload no chunk reached for 4 hours is deleted by the hourly cleanup.
|
||||
|
||||
### Manual (without Docker)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user