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
@@ -8,5 +8,6 @@ Before planning or editing, find the row whose globs match the file's path and r
|
||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||
| tests/Screenshots/** | .ai/rules/screenshots.md |
|
||||
| app/Services/** | .ai/rules/services.md |
|
||||
| resources/views/** | .ai/rules/views.md |
|
||||
| website/** | .ai/rules/website.md |
|
||||
|
||||
@@ -6,4 +6,4 @@ paths:
|
||||
# Screenshots
|
||||
|
||||
## Screenshots come from composer screenshots, before a release
|
||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; Livewire's temporary-upload cleanup must stay off under the frozen clock or it deletes the selected files; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it; upload_max_filesize/post_max_size are set to 4G by the script so the admin settings hint does not show the machine's PHP limit.
|
||||
Run `composer screenshots` whenever the interface changes and before a release; it builds assets and runs tests/Screenshots (not part of any test suite or CI), publishing WebP files to website/img/screenshots. Demo data (DemoData) and the clock are fixed so runs are reproducible. Traps: Pest only starts its browser for a test whose body calls `visit(` after whitespace; the upload shot's files are registered through the page's `registerFiles` and their encrypted chunks stored server-side (the in-process server takes request bodies up to 128 KB only), then the list refreshed; the in-process server's random port is shown as https://files.example.com and the QR redrawn for it.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'app/Services/**'
|
||||
---
|
||||
|
||||
# Services
|
||||
|
||||
## Uploads are encrypted in the browser, never on the server
|
||||
Share files are encrypted chunk by chunk in the uploader's browser (resources/js/share-uploader.js, WebCrypto) in the SEALCHK2 format and PUT to UploadChunkController, which verifies each chunk in memory and writes it once. Never add a server-side upload path that puts plaintext on disk (Livewire temp uploads, multipart spooling): PHP spools every request body to upload_tmp_dir. ShareService::createShare() exists only for tests and demo data. Send chunk bodies as a Blob, not an ArrayBuffer: Chromium uploads an ArrayBuffer about 8x slower.
|
||||
@@ -69,5 +69,8 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
# OCTANE_HTTPS=false
|
||||
# OCTANE_MAX_EXECUTION_TIME=300
|
||||
|
||||
# Uploads: each encrypted chunk the browser sends, in MB
|
||||
# UPLOAD_CHUNK_SIZE_MB=16
|
||||
|
||||
# Docker (used only when deploying with docker-compose.yml)
|
||||
# SERVER_NAME=share.example.com
|
||||
|
||||
@@ -15,14 +15,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Kind: random characters (length 12–64; uppercase, lowercase, numbers and symbols; look-alike characters left out if chosen) or a passphrase (4–10 words from EFF's large word list, with a chosen separator).
|
||||
- An example with its estimated entropy shows before saving.
|
||||
- Defaults: on request, 20 letters and numbers without look-alikes.
|
||||
- `AUTO_HTTPS` for the Docker image: with `AUTO_HTTPS: "true"` and `SERVER_NAME` set to the domain, the container fetches a Let's Encrypt certificate, serves HTTPS on port 443 and redirects port 80. Without it the container serves plain HTTP on port 80, as before, for a reverse proxy in front.
|
||||
- `UPLOAD_CHUNK_SIZE_MB` (default 16) sets the size of each encrypted chunk the browser sends.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking: uploads need HTTPS.** Files are now 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. Serve SealShare with `AUTO_HTTPS` or behind a reverse proxy that terminates TLS.
|
||||
- Uploads are encrypted in the browser and sent in chunks of 16 MB, each written to disk once, already encrypted. A large file no longer waits on "Processing files..." or on "Create Share Link": before, the server wrote every upload three times (PHP's temporary file, Livewire's temporary copy, the encrypted file). The server checks every chunk as it arrives. A chunk that fails is retried automatically, then the file offers Retry; each file in the list shows its progress.
|
||||
- A share's files are encrypted with a random key of its own; with a share password, that key is wrapped with a key derived from the password with Argon2id instead of PBKDF2. Shares created before keep working as they are.
|
||||
- PHP's upload limits no longer cap a share's file size: "Max file size" in Admin settings can be set beyond them, and `PHP_UPLOAD_MAX_FILESIZE` / `PHP_POST_MAX_SIZE` default to `64M` (they only apply to the logo upload). `LIVEWIRE_MAX_UPLOAD_TIME` is no longer needed.
|
||||
- Files still uploading count towards the storage quota. An upload no chunk reached for 4 hours is deleted by the hourly cleanup.
|
||||
- The interface moves to [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) 2.0.0, which aligns every component with Material 3 Expressive as Google documents it. SealShare keeps the pages, the arrangement and the flow it had — rebuilt on the new components — and no longer ships Tailwind CSS.
|
||||
- The colour scheme is regenerated with Material 3's 2025 colour rules, at all three contrast levels. The colour profile an admin chose, and each visitor's light or dark setting, carry over unchanged.
|
||||
- The settings pages — Profile, Update password, Two Factor Authentication and Appearance — are shown as cards, the way Admin settings already were. Deleting the account and the two-factor recovery codes each sit in a card of their own beside the page's.
|
||||
- A form field now fills the card that holds it instead of stopping short of its edge.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Download all" works for large shares: the ZIP is streamed as it is built, file by file, instead of every file being decrypted into memory and the archive written unencrypted to a temporary file.
|
||||
- Unencrypted copies of uploads no longer stay behind in Livewire's temporary folder after a share is created; the hourly cleanup also removes those left by earlier versions.
|
||||
- The Docker image's `docker/Caddyfile` was never used and is removed; `SERVER_NAME` now only matters with `AUTO_HTTPS`.
|
||||
|
||||
### Security
|
||||
|
||||
- An encrypted file whose trailing chunks were cut off, or whose chunks were reordered, now fails to decrypt: each chunk's nonce carries its index and whether it is the last one.
|
||||
|
||||
## [2.0.1] - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
+3
-6
@@ -71,9 +71,6 @@ ENV APP_NAME="SealShare" \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy Caddyfile
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
# Copy PHP ini for upload limits
|
||||
COPY docker/php/uploads.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
||||
|
||||
@@ -98,12 +95,12 @@ RUN rm -rf node_modules tests .gitea docker/dev.Dockerfile docker/dev-entrypoint
|
||||
RUN touch database/database.sqlite \
|
||||
&& chmod 666 database/database.sqlite
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x docker/entrypoint.sh
|
||||
# Make entrypoint and healthcheck executable
|
||||
RUN chmod +x docker/entrypoint.sh docker/healthcheck.sh
|
||||
|
||||
EXPOSE 80 443 443/udp
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl --silent --fail http://localhost/up || exit 1
|
||||
CMD /app/docker/healthcheck.sh
|
||||
|
||||
ENTRYPOINT ["docker/entrypoint.sh"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace App\Console\Commands;
|
||||
use App\Models\Share;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Console\Command;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
|
||||
class CleanupExpiredShares extends Command
|
||||
{
|
||||
/**
|
||||
* How long an upload or a temporary upload file may sit untouched before it is deleted.
|
||||
*/
|
||||
private const ABANDONED_AFTER_HOURS = 4;
|
||||
|
||||
protected $signature = 'shares:cleanup';
|
||||
|
||||
protected $description = 'Delete expired shares and shares that have reached their download limit';
|
||||
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
|
||||
|
||||
public function handle(ShareService $shareService): int
|
||||
{
|
||||
@@ -21,14 +27,49 @@ class CleanupExpiredShares extends Command
|
||||
})
|
||||
->get();
|
||||
|
||||
$count = $expiredShares->count();
|
||||
|
||||
foreach ($expiredShares as $share) {
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
$this->info("Cleaned up {$count} expired share(s).");
|
||||
$this->info("Cleaned up {$expiredShares->count()} expired share(s).");
|
||||
|
||||
// A page that stopped sending chunks: closed, crashed or left behind.
|
||||
$abandonedUploads = Share::query()
|
||||
->whereNull('completed_at')
|
||||
->where('updated_at', '<', now()->subHours(self::ABANDONED_AFTER_HOURS))
|
||||
->get();
|
||||
|
||||
foreach ($abandonedUploads as $share) {
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
$this->info("Cleaned up {$abandonedUploads->count()} abandoned upload(s).");
|
||||
$this->info('Cleaned up '.$this->deleteOldTemporaryUploads().' temporary upload file(s).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Livewire's temporary uploads past the same age: the admin logo's, and the unencrypted
|
||||
* copies uploads left there before files were encrypted in the browser.
|
||||
*/
|
||||
private function deleteOldTemporaryUploads(): int
|
||||
{
|
||||
if (FileUploadConfiguration::isUsingS3()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$storage = FileUploadConfiguration::storage();
|
||||
$cutoff = now()->subHours(self::ABANDONED_AFTER_HOURS)->getTimestamp();
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($storage->allFiles(FileUploadConfiguration::path()) as $path) {
|
||||
if ($storage->exists($path) && $storage->lastModified($path) < $cutoff) {
|
||||
$storage->delete($path);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use GuzzleHttp\Psr7\PumpStream;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipArchive;
|
||||
use ZipStream\CompressionMethod;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadController extends Controller
|
||||
{
|
||||
@@ -20,41 +21,53 @@ class DownloadController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Download all files as a ZIP archive.
|
||||
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
|
||||
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
|
||||
*/
|
||||
public function download(Share $share): BinaryFileResponse
|
||||
public function download(Share $share): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
|
||||
$share->load('files');
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'sealshare_');
|
||||
return new StreamedResponse(function () use ($share, $key): void {
|
||||
$zip = new ZipStream(
|
||||
defaultCompressionMethod: CompressionMethod::STORE,
|
||||
defaultEnableZeroHeader: true,
|
||||
sendHttpHeaders: false,
|
||||
flushOutput: true,
|
||||
);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
foreach ($share->files as $file) {
|
||||
$chunks = $this->encryptionService->decryptedChunks(
|
||||
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
||||
$key,
|
||||
);
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path));
|
||||
$content = $this->encryptionService->decryptFile($encryptedPath, $key);
|
||||
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
|
||||
while ($chunks->valid() && $chunks->current() === '') {
|
||||
$chunks->next();
|
||||
}
|
||||
|
||||
$filename = $file->relative_path ?: $file->original_name;
|
||||
$filename = str_replace('\\', '/', $filename);
|
||||
if (! $chunks->valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||
$filename = basename($filename);
|
||||
$chunk = $chunks->current();
|
||||
$chunks->next();
|
||||
|
||||
return $chunk;
|
||||
}));
|
||||
}
|
||||
|
||||
$zip->addFromString($filename, $content);
|
||||
}
|
||||
$zip->finish();
|
||||
|
||||
$zip->close();
|
||||
|
||||
$this->shareService->recordDownload($share);
|
||||
|
||||
return response()->download($tempPath, 'share-'.$share->token.'.zip', [
|
||||
$this->shareService->recordDownload($share);
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
])->deleteFileAfterSend(true);
|
||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +75,7 @@ class DownloadController extends Controller
|
||||
*/
|
||||
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
|
||||
{
|
||||
abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
|
||||
abort_if($shareFile->share_id !== $share->id, 404);
|
||||
|
||||
$key = $this->resolveDecryptionKey($share);
|
||||
@@ -90,6 +103,21 @@ class DownloadController extends Controller
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's path inside the archive: its folder path when it came from a dropped folder, never
|
||||
* one that could reach outside the archive.
|
||||
*/
|
||||
private function archiveName(ShareFile $file): string
|
||||
{
|
||||
$filename = str_replace('\\', '/', $file->relative_path ?: $file->original_name);
|
||||
|
||||
if (str_starts_with($filename, '/') || str_contains($filename, '..')) {
|
||||
return basename($filename);
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the decryption key from session or share.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class UploadChunkController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ShareService $shareService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Store one encrypted chunk of a file the uploader's page registered.
|
||||
*
|
||||
* Only the session that started the pending share may add to it. A chunk the server already
|
||||
* has is acknowledged without being written again; one that skips ahead gets a 409 with the
|
||||
* number of chunks stored, so the browser can continue from there.
|
||||
*/
|
||||
public function store(Request $request, ShareFile $shareFile, int $index): JsonResponse
|
||||
{
|
||||
$share = $shareFile->share;
|
||||
|
||||
abort_if($share->isCompleted() || ! in_array($share->token, $request->session()->get('pending_shares', []), true), 404);
|
||||
|
||||
if ($index !== $shareFile->uploaded_chunks) {
|
||||
return response()->json(
|
||||
['uploaded_chunks' => $shareFile->uploaded_chunks],
|
||||
$index < $shareFile->uploaded_chunks ? 200 : 409,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$uploadedChunks = $this->shareService->storeChunk($shareFile, $index, $request->getContent());
|
||||
} catch (InvalidArgumentException) {
|
||||
abort(422, 'The chunk is invalid.');
|
||||
}
|
||||
|
||||
return response()->json(['uploaded_chunks' => $uploadedChunks]);
|
||||
}
|
||||
}
|
||||
@@ -43,18 +43,20 @@ class AdminDashboard extends Component
|
||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
||||
$shares = Share::query()
|
||||
->whereNotNull('completed_at')
|
||||
->withCount('files')
|
||||
->orderBy($column, $direction)
|
||||
->paginate(15);
|
||||
|
||||
return view('livewire.admin.admin-dashboard', [
|
||||
'shares' => $shares,
|
||||
'totalShares' => Share::query()->count(),
|
||||
'activeShares' => Share::query()->where(function ($q) {
|
||||
'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
|
||||
'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count(),
|
||||
'totalFiles' => ShareFile::query()->count(),
|
||||
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
|
||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||
]);
|
||||
|
||||
@@ -70,10 +70,7 @@ class AdminSettings extends Component
|
||||
{
|
||||
$this->colorProfile = Scheme::profile() ?? '';
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
self::phpMaxUploadMb(),
|
||||
);
|
||||
$this->maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024);
|
||||
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
|
||||
@@ -91,34 +88,11 @@ class AdminSettings extends Component
|
||||
$this->passphraseSeparator = $passwordOptions['separator'];
|
||||
}
|
||||
|
||||
public static function phpMaxUploadMb(): int
|
||||
{
|
||||
$parse = function (string $value): int {
|
||||
$value = trim($value);
|
||||
$last = strtolower($value[strlen($value) - 1]);
|
||||
$num = (int) $value;
|
||||
|
||||
return match ($last) {
|
||||
'g' => $num * 1024,
|
||||
'm' => $num,
|
||||
'k' => max(1, (int) ($num / 1024)),
|
||||
default => max(1, (int) ($num / (1024 * 1024))),
|
||||
};
|
||||
};
|
||||
|
||||
$upload = $parse(ini_get('upload_max_filesize') ?: '2M');
|
||||
$post = $parse(ini_get('post_max_size') ?: '8M');
|
||||
|
||||
return min($upload, $post);
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$validated = $this->validate([
|
||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1'],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
'maxSizePerShare' => ['required', 'integer', 'min:1'],
|
||||
@@ -127,7 +101,6 @@ class AdminSettings extends Component
|
||||
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
|
||||
...$this->passwordGeneratorRules(),
|
||||
], [
|
||||
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
|
||||
...$this->passwordGeneratorMessages(),
|
||||
]);
|
||||
|
||||
@@ -276,7 +249,6 @@ class AdminSettings extends Component
|
||||
return view('livewire.admin.admin-settings', [
|
||||
'hasSystemPassword' => (bool) Setting::get('system_password'),
|
||||
'currentLogo' => Setting::get('site_logo'),
|
||||
'phpMaxUploadMb' => self::phpMaxUploadMb(),
|
||||
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
|
||||
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
|
||||
]);
|
||||
|
||||
+96
-118
@@ -3,26 +3,27 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\PasswordGeneratorService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* The upload page. The browser encrypts each file chunk by chunk and sends the chunks to
|
||||
* UploadChunkController (resources/js/share-uploader.js); this component registers the files
|
||||
* into a pending share, lists them and completes the share with its options.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class FileUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $files = [];
|
||||
|
||||
/** @var array<int, string|null> */
|
||||
public array $relativePaths = [];
|
||||
/** The pending share this page uploads into: created with the first file, one per page load. */
|
||||
#[Locked]
|
||||
public ?string $pendingToken = null;
|
||||
|
||||
public bool $usePassword = false;
|
||||
|
||||
@@ -40,67 +41,70 @@ class FileUploader extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an upload the temporary upload endpoint did not accept.
|
||||
* Register the files a visitor chose and hand the browser what it encrypts and sends them
|
||||
* with. A file an admin limit refuses gets `null` in its place and the reason under `files`.
|
||||
*
|
||||
* Validation errors (a 422) mean the whole file reached the server and was
|
||||
* rejected there, so the real reason is logged for the administrator rather
|
||||
* than guessed at in front of the user. Anything else is a transport failure.
|
||||
* @param array<int, array{name?: mixed, size?: mixed, path?: mixed}> $files
|
||||
* @return array<int, array{id: int, url: string, key: string, noncePrefix: string, chunkSize: int, chunkCount: int}|null>
|
||||
*/
|
||||
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
||||
public function registerFiles(array $files, ShareService $shareService): array
|
||||
{
|
||||
$this->dispatch('upload:errored', name: $name)->self();
|
||||
$this->resetErrorBag('files');
|
||||
|
||||
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
|
||||
$targets = [];
|
||||
|
||||
if ($errors) {
|
||||
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$shareFile = $shareService->registerFile(
|
||||
$this->pendingShare(),
|
||||
(string) ($file['name'] ?? ''),
|
||||
(int) ($file['size'] ?? -1),
|
||||
isset($file['path']) ? (string) $file['path'] : null,
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
if (! $this->getErrorBag()->has('files')) {
|
||||
$this->addError('files', $e->errors()['files'][0]);
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
||||
]);
|
||||
$targets[] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->pendingToken !== $shareFile->share->token) {
|
||||
$this->pendingToken = $shareFile->share->token;
|
||||
session()->push('pending_shares', $this->pendingToken);
|
||||
}
|
||||
|
||||
$header = $shareService->readHeader($shareFile);
|
||||
|
||||
$targets[] = [
|
||||
'id' => $shareFile->id,
|
||||
'url' => Str::beforeLast(route('upload.chunk', ['shareFile' => $shareFile, 'index' => 0]), '/'),
|
||||
'key' => $shareFile->share->encryption_key,
|
||||
'noncePrefix' => bin2hex($header['noncePrefix']),
|
||||
'chunkSize' => $header['chunkSize'],
|
||||
'chunkCount' => $header['chunkCount'],
|
||||
];
|
||||
}
|
||||
|
||||
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
||||
]);
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a freshly uploaded batch of files.
|
||||
* Take files out of the pending share, whether or not their upload finished.
|
||||
*
|
||||
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
||||
* This runs for every batch, including additional files added to an existing
|
||||
* selection, which a one-off `x-init` on the file list cannot cover.
|
||||
* @param array<int, mixed> $fileIds
|
||||
*/
|
||||
public function updatedFiles(): void
|
||||
public function removeFiles(array $fileIds, ShareService $shareService): void
|
||||
{
|
||||
$this->dispatch('files-processed')->self();
|
||||
$files = $this->pendingShare()?->files()->whereIn('id', array_map('intval', $fileIds))->get() ?? [];
|
||||
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
foreach ($files as $file) {
|
||||
$shareService->removeFile($file);
|
||||
}
|
||||
|
||||
$this->resetErrorBag('files');
|
||||
|
||||
if (count($this->files) > $maxFilesPerShare) {
|
||||
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->files as $file) {
|
||||
if ($file->getSize() > $maxFileSize) {
|
||||
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'size' => round($file->getSize() / (1024 * 1024), 1),
|
||||
'max' => (int) $maxFileSizeMb,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,27 +130,11 @@ class FileUploader extends Component
|
||||
$this->resetErrorBag('password');
|
||||
}
|
||||
|
||||
public function removeFile(int $index): void
|
||||
{
|
||||
unset($this->files[$index], $this->relativePaths[$index]);
|
||||
$this->files = array_values($this->files);
|
||||
$this->relativePaths = array_values($this->relativePaths);
|
||||
}
|
||||
|
||||
public function createShare(ShareService $shareService): void
|
||||
{
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$rules = [];
|
||||
|
||||
$rules = [
|
||||
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
|
||||
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
|
||||
];
|
||||
|
||||
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
||||
|
||||
if (! $allowNeverExpire) {
|
||||
if (! Setting::get('allow_never_expire', false)) {
|
||||
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
||||
}
|
||||
|
||||
@@ -154,61 +142,36 @@ class FileUploader extends Component
|
||||
$rules['password'] = ['required', 'string', 'min:8'];
|
||||
}
|
||||
|
||||
$this->validate($rules, [
|
||||
'expiration.required' => __('An expiration time is required.'),
|
||||
'files.required' => __('Please select at least one file to upload.'),
|
||||
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
||||
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
||||
]);
|
||||
if ($rules !== []) {
|
||||
$this->validate($rules, [
|
||||
'expiration.required' => __('An expiration time is required.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($shareService->isStorageFull()) {
|
||||
$this->addError('files', __('Storage is full. Please contact the administrator.'));
|
||||
$pendingShare = $this->pendingShare();
|
||||
|
||||
if ($pendingShare === null) {
|
||||
$this->addError('files', __('Please select at least one file to upload.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
|
||||
|
||||
if ($totalSize > $maxSizePerShare) {
|
||||
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fileData = [];
|
||||
foreach ($this->files as $index => $file) {
|
||||
$relativePath = $this->relativePaths[$index] ?? null;
|
||||
|
||||
if ($relativePath !== null) {
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||
$relativePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
$fileData[] = [
|
||||
'file' => $file,
|
||||
'relativePath' => $relativePath,
|
||||
];
|
||||
}
|
||||
|
||||
$expiresAt = match ($this->expiration) {
|
||||
'1h' => now()->addHour(),
|
||||
'24h' => now()->addDay(),
|
||||
'48h' => now()->addDays(2),
|
||||
'7d' => now()->addWeek(),
|
||||
'14d' => now()->addDays(14),
|
||||
'30d' => now()->addMonth(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$share = $shareService->createShare($fileData, [
|
||||
$share = $shareService->completeShare($pendingShare, [
|
||||
'password' => $this->usePassword ? $this->password : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_at' => match ($this->expiration) {
|
||||
'1h' => now()->addHour(),
|
||||
'24h' => now()->addDay(),
|
||||
'48h' => now()->addDays(2),
|
||||
'7d' => now()->addWeek(),
|
||||
'14d' => now()->addDays(14),
|
||||
'30d' => now()->addMonth(),
|
||||
default => null,
|
||||
},
|
||||
'max_downloads' => $this->maxDownloads ?: null,
|
||||
]);
|
||||
|
||||
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
|
||||
|
||||
// The page the upload leads to offers the password once more, next to the link; it is
|
||||
// never stored in the clear, so this flash is the only way it gets there.
|
||||
if ($this->usePassword) {
|
||||
@@ -224,8 +187,11 @@ class FileUploader extends Component
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
$pendingFiles = $this->pendingShare()?->files()->orderBy('id')->get() ?? collect();
|
||||
|
||||
return view('livewire.file-uploader', [
|
||||
'pendingFiles' => $pendingFiles,
|
||||
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
||||
'isStorageFull' => $shareService->isStorageFull(),
|
||||
'siteTitle' => Setting::get('site_title'),
|
||||
'siteDescription' => Setting::get('site_description'),
|
||||
@@ -234,4 +200,16 @@ class FileUploader extends Component
|
||||
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This page's pending share, while it is still pending and this session started it.
|
||||
*/
|
||||
private function pendingShare(): ?Share
|
||||
{
|
||||
if ($this->pendingToken === null || ! in_array($this->pendingToken, session('pending_shares', []), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Share::query()->where('token', $this->pendingToken)->whereNull('completed_at')->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ class ShareCreated extends Component
|
||||
|
||||
public function mount(Share $share): void
|
||||
{
|
||||
abort_unless($share->isCompleted(), 404);
|
||||
|
||||
$this->share = $share;
|
||||
|
||||
$flashedPassword = session('share_password');
|
||||
|
||||
@@ -24,7 +24,7 @@ class ShareDownload extends Component
|
||||
{
|
||||
$this->share = $share->load('files');
|
||||
|
||||
if ($share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ class Share extends Model
|
||||
'password',
|
||||
'encryption_key',
|
||||
'encryption_salt',
|
||||
'wrapped_key',
|
||||
'expires_at',
|
||||
'max_downloads',
|
||||
'download_count',
|
||||
'total_size',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ class Share extends Model
|
||||
'download_count' => 'integer',
|
||||
'total_size' => 'integer',
|
||||
'encryption_key' => 'encrypted',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,6 +46,15 @@ class Share extends Model
|
||||
return $this->hasMany(ShareFile::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the share was created: until then its files are still being uploaded and nobody
|
||||
* but the uploader's page may reach it.
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->completed_at !== null;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
|
||||
@@ -17,6 +17,8 @@ class ShareFile extends Model
|
||||
'stored_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'uploaded_chunks',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,8 @@ class ShareFile extends Model
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
'uploaded_chunks' => 'integer',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,30 @@ namespace App\Services;
|
||||
|
||||
use Generator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* The encrypted file formats and the keys behind them.
|
||||
*
|
||||
* New files are `SEALCHK2`, written chunk by chunk as the uploader's browser sends them:
|
||||
*
|
||||
* [8 bytes: "SEALCHK2" magic]
|
||||
* [4 bytes: chunk size S, uint32 big-endian]
|
||||
* [7 bytes: random nonce prefix]
|
||||
* Per chunk i: [ciphertext (S bytes, fewer on the last chunk)][16 bytes: GCM tag]
|
||||
*
|
||||
* Chunk i's nonce is the prefix, i as uint32 big-endian and a byte that is 1 on the last chunk
|
||||
* and 0 on every other (the STREAM construction), so dropping, reordering or appending chunks
|
||||
* fails authentication. The browser encrypts with the same layout (resources/js/share-uploader.js).
|
||||
*
|
||||
* `SEALCHK1` (a tag before each chunk, the index XORed into a 12-byte nonce, no last-chunk flag)
|
||||
* and the single-block legacy format are still read for shares created before.
|
||||
*/
|
||||
class FileEncryptionService
|
||||
{
|
||||
public const HEADER_LENGTH = 19;
|
||||
|
||||
public const TAG_LENGTH = 16;
|
||||
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
private const PBKDF2_ITERATIONS = 100000;
|
||||
@@ -17,14 +36,17 @@ class FileEncryptionService
|
||||
|
||||
private const NONCE_LENGTH = 12;
|
||||
|
||||
private const TAG_LENGTH = 16;
|
||||
private const NONCE_PREFIX_LENGTH = 7;
|
||||
|
||||
private const MAGIC_HEADER = 'SEALCHK1';
|
||||
private const MAGIC = 'SEALCHK2';
|
||||
|
||||
private const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
private const LEGACY_CHUNKED_MAGIC = 'SEALCHK1';
|
||||
|
||||
private const WRAPPED_KEY_ALGORITHM = 'argon2id';
|
||||
|
||||
/**
|
||||
* Derive an encryption key from a password and salt using PBKDF2-SHA256.
|
||||
* Derive a key from a password and salt using PBKDF2-SHA256, as shares created before
|
||||
* envelope encryption were keyed.
|
||||
*/
|
||||
public function deriveKey(string $password, string $salt): string
|
||||
{
|
||||
@@ -48,17 +70,147 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file using chunked AES-256-GCM.
|
||||
* Wrap a share's data key with a key derived from its password (Argon2id).
|
||||
*
|
||||
* Output format:
|
||||
* [8 bytes: "SEALCHK1" magic]
|
||||
* [4 bytes: chunk size, uint32 big-endian]
|
||||
* [12 bytes: base nonce]
|
||||
* Per chunk:
|
||||
* [16 bytes: GCM auth tag]
|
||||
* [N bytes: ciphertext (up to chunk_size)]
|
||||
* The result names its algorithm and parameters, so they can be raised later without breaking
|
||||
* shares wrapped before: `argon2id$<opslimit>$<memlimit>$<salt>$<nonce>$<box>`, in hex.
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key): void
|
||||
public function wrapKey(string $dataKeyHex, string $password): string
|
||||
{
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE;
|
||||
$memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
|
||||
|
||||
$wrappingKey = $this->deriveWrappingKey($password, $salt, $opslimit, $memlimit);
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$box = sodium_crypto_secretbox(hex2bin($dataKeyHex), $nonce, $wrappingKey);
|
||||
|
||||
sodium_memzero($wrappingKey);
|
||||
|
||||
return implode('$', [self::WRAPPED_KEY_ALGORITHM, $opslimit, $memlimit, bin2hex($salt), bin2hex($nonce), bin2hex($box)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a share's data key with its password; returns the key as hex.
|
||||
*/
|
||||
public function unwrapKey(string $wrappedKey, string $password): string
|
||||
{
|
||||
$parts = explode('$', $wrappedKey);
|
||||
|
||||
if (count($parts) !== 6 || $parts[0] !== self::WRAPPED_KEY_ALGORITHM) {
|
||||
throw new RuntimeException('Unsupported wrapped key');
|
||||
}
|
||||
|
||||
[, $opslimit, $memlimit, $salt, $nonce, $box] = $parts;
|
||||
|
||||
$wrappingKey = $this->deriveWrappingKey($password, hex2bin($salt), (int) $opslimit, (int) $memlimit);
|
||||
$dataKey = sodium_crypto_secretbox_open(hex2bin($box), hex2bin($nonce), $wrappingKey);
|
||||
|
||||
sodium_memzero($wrappingKey);
|
||||
|
||||
if ($dataKey === false) {
|
||||
throw new RuntimeException('Unwrapping failed - wrong password or corrupted key');
|
||||
}
|
||||
|
||||
return bin2hex($dataKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header a new encrypted file starts with, with a fresh random nonce prefix.
|
||||
*/
|
||||
public function createHeader(int $chunkSize): string
|
||||
{
|
||||
return self::MAGIC.pack('N', $chunkSize).random_bytes(self::NONCE_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `SEALCHK2` header.
|
||||
*
|
||||
* @return array{chunkSize: int, noncePrefix: string}
|
||||
*/
|
||||
public function parseHeader(string $header): array
|
||||
{
|
||||
if (strlen($header) !== self::HEADER_LENGTH || ! str_starts_with($header, self::MAGIC)) {
|
||||
throw new RuntimeException('Invalid encrypted file header');
|
||||
}
|
||||
|
||||
return [
|
||||
'chunkSize' => unpack('N', substr($header, 8, 4))[1],
|
||||
'noncePrefix' => substr($header, 12, self::NONCE_PREFIX_LENGTH),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many chunks a file of this size is sent in; an empty file is one empty chunk.
|
||||
*/
|
||||
public function chunkCount(int $size, int $chunkSize): int
|
||||
{
|
||||
return max(1, intdiv($size + $chunkSize - 1, $chunkSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where chunk `$index` starts in the encrypted file.
|
||||
*/
|
||||
public function chunkOffset(int $index, int $chunkSize): int
|
||||
{
|
||||
return self::HEADER_LENGTH + $index * ($chunkSize + self::TAG_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt one chunk: its ciphertext followed by its tag, as WebCrypto returns it.
|
||||
*/
|
||||
public function encryptChunk(string $plaintext, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH,
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed at chunk '.$index);
|
||||
}
|
||||
|
||||
return $ciphertext.$tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt one chunk, which fails unless its index and last-chunk flag are the ones it was
|
||||
* encrypted with.
|
||||
*/
|
||||
public function decryptChunk(string $chunk, string $key, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
if (strlen($chunk) < self::TAG_LENGTH) {
|
||||
throw new RuntimeException('Invalid encrypted file: truncated chunk '.$index);
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
substr($chunk, 0, -self::TAG_LENGTH),
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
$this->chunkNonce($noncePrefix, $index, $isLast),
|
||||
substr($chunk, -self::TAG_LENGTH),
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a file on the server in the `SEALCHK2` format.
|
||||
*/
|
||||
public function encryptFile(string $sourcePath, string $destPath, string $key, int $chunkSize): void
|
||||
{
|
||||
$source = fopen($sourcePath, 'rb');
|
||||
|
||||
@@ -75,45 +227,16 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
try {
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$baseNonce = random_bytes(self::NONCE_LENGTH);
|
||||
$chunkSize = self::DEFAULT_CHUNK_SIZE;
|
||||
$header = $this->createHeader($chunkSize);
|
||||
$noncePrefix = $this->parseHeader($header)['noncePrefix'];
|
||||
$chunkCount = $this->chunkCount((int) filesize($sourcePath), $chunkSize);
|
||||
|
||||
// Write header
|
||||
fwrite($dest, self::MAGIC_HEADER);
|
||||
fwrite($dest, pack('N', $chunkSize));
|
||||
fwrite($dest, $baseNonce);
|
||||
fwrite($dest, $header);
|
||||
|
||||
$chunkIndex = 0;
|
||||
for ($index = 0; $index < $chunkCount; $index++) {
|
||||
$plaintext = (string) fread($source, $chunkSize);
|
||||
|
||||
while (! feof($source)) {
|
||||
$plaintext = fread($source, $chunkSize);
|
||||
|
||||
if ($plaintext === false || $plaintext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH,
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
fwrite($dest, $tag);
|
||||
fwrite($dest, $ciphertext);
|
||||
$chunkIndex++;
|
||||
fwrite($dest, $this->encryptChunk($plaintext, $key, $noncePrefix, $index, $index === $chunkCount - 1));
|
||||
}
|
||||
} catch (RuntimeException $e) {
|
||||
fclose($source);
|
||||
@@ -127,74 +250,33 @@ class FileEncryptionService
|
||||
fclose($dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and return the plaintext content.
|
||||
*/
|
||||
public function decryptFile(string $encryptedPath, string $key): string
|
||||
{
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
$parts = [];
|
||||
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
|
||||
return implode('', $parts);
|
||||
}
|
||||
|
||||
return $this->decryptLegacy($encryptedPath, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and stream the response.
|
||||
*/
|
||||
public function decryptFileStream(string $encryptedPath, string $key, string $filename, string $mimeType, ?int $fileSize = null): StreamedResponse
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type' => $mimeType ?: 'application/octet-stream',
|
||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', $filename, 'download'),
|
||||
];
|
||||
|
||||
if ($fileSize !== null) {
|
||||
$headers['Content-Length'] = $fileSize;
|
||||
}
|
||||
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
$content = $this->decryptLegacy($encryptedPath, $key);
|
||||
|
||||
if (! isset($headers['Content-Length'])) {
|
||||
$headers['Content-Length'] = strlen($content);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($content): void {
|
||||
echo $content;
|
||||
}, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream decrypted file content directly to output (echo).
|
||||
* Use this when you need to add post-streaming logic inside a StreamedResponse callback.
|
||||
*/
|
||||
public function streamDecryptedFile(string $encryptedPath, string $key): void
|
||||
{
|
||||
if ($this->isChunkedFormat($encryptedPath)) {
|
||||
foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
|
||||
return;
|
||||
foreach ($this->decryptedChunks($encryptedPath, $key) as $chunk) {
|
||||
echo $chunk;
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
echo $this->decryptLegacy($encryptedPath, $key);
|
||||
/**
|
||||
* The decrypted content of a file in any of the three formats, chunk by chunk.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
public function decryptedChunks(string $encryptedPath, string $key): Generator
|
||||
{
|
||||
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||
|
||||
if ($magic === self::MAGIC) {
|
||||
yield from $this->decryptChunks($encryptedPath, $key);
|
||||
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
||||
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
||||
} else {
|
||||
yield $this->decryptLegacy($encryptedPath, $key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,73 +287,29 @@ class FileEncryptionService
|
||||
return strlen($key) === 64 ? hex2bin($key) : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique nonce for a chunk by XORing the chunk index into the last 4 bytes.
|
||||
*/
|
||||
private function deriveChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||
private function deriveWrappingKey(string $password, string $salt, int $opslimit, int $memlimit): string
|
||||
{
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $chunkIndex);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file uses the chunked encryption format.
|
||||
*/
|
||||
private function isChunkedFormat(string $path): bool
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$magic = fread($handle, 8);
|
||||
fclose($handle);
|
||||
|
||||
return $magic === self::MAGIC_HEADER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a legacy single-block encrypted file.
|
||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||
*/
|
||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||
{
|
||||
$data = file_get_contents($encryptedPath);
|
||||
|
||||
if ($data === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
$binaryKey = $this->normalizeToBinaryKey($key);
|
||||
$nonce = substr($data, 0, self::NONCE_LENGTH);
|
||||
$tag = substr($data, self::NONCE_LENGTH, self::TAG_LENGTH);
|
||||
$ciphertext = substr($data, self::NONCE_LENGTH + self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
return sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||
$password,
|
||||
$salt,
|
||||
$opslimit,
|
||||
$memlimit,
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator that yields decrypted plaintext chunks from a chunked encrypted file.
|
||||
* A `SEALCHK2` chunk's nonce: the file's prefix, the chunk index and the last-chunk flag.
|
||||
*/
|
||||
private function chunkNonce(string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
return $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `SEALCHK2` file; the chunk count comes from the file's length, so a file cut short
|
||||
* at a chunk boundary fails on its new last chunk.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
@@ -284,17 +322,44 @@ class FileEncryptionService
|
||||
}
|
||||
|
||||
try {
|
||||
// Read header
|
||||
$magic = fread($handle, 8);
|
||||
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->parseHeader((string) fread($handle, self::HEADER_LENGTH));
|
||||
|
||||
if ($magic !== self::MAGIC_HEADER) {
|
||||
throw new RuntimeException('Invalid chunked file format');
|
||||
$storedChunkSize = $chunkSize + self::TAG_LENGTH;
|
||||
$payloadLength = (int) filesize($encryptedPath) - self::HEADER_LENGTH;
|
||||
$chunkCount = intdiv($payloadLength + $storedChunkSize - 1, $storedChunkSize);
|
||||
|
||||
if ($chunkCount === 0) {
|
||||
throw new RuntimeException('Invalid encrypted file: no chunks');
|
||||
}
|
||||
|
||||
$chunkSizeData = fread($handle, 4);
|
||||
$chunkSize = unpack('N', $chunkSizeData)[1];
|
||||
for ($index = 0; $index < $chunkCount; $index++) {
|
||||
$chunk = (string) fread($handle, $storedChunkSize);
|
||||
|
||||
$baseNonce = fread($handle, self::NONCE_LENGTH);
|
||||
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a `SEALCHK1` file.
|
||||
*
|
||||
* @return Generator<int, string>
|
||||
*/
|
||||
private function decryptLegacyChunks(string $encryptedPath, string $key): Generator
|
||||
{
|
||||
$handle = fopen($encryptedPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
fread($handle, 8);
|
||||
|
||||
$chunkSize = unpack('N', (string) fread($handle, 4))[1];
|
||||
$baseNonce = (string) fread($handle, self::NONCE_LENGTH);
|
||||
|
||||
if (strlen($baseNonce) !== self::NONCE_LENGTH) {
|
||||
throw new RuntimeException('Invalid chunked file: truncated header');
|
||||
@@ -320,14 +385,12 @@ class FileEncryptionService
|
||||
throw new RuntimeException('Invalid chunked file: missing ciphertext at chunk '.$chunkIndex);
|
||||
}
|
||||
|
||||
$nonce = $this->deriveChunkNonce($baseNonce, $chunkIndex);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$binaryKey,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$this->legacyChunkNonce($baseNonce, $chunkIndex),
|
||||
$tag,
|
||||
);
|
||||
|
||||
@@ -342,4 +405,47 @@ class FileEncryptionService
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `SEALCHK1` chunk's nonce: the chunk index XORed into the last 4 bytes of the base nonce.
|
||||
*/
|
||||
private function legacyChunkNonce(string $baseNonce, int $chunkIndex): string
|
||||
{
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $chunkIndex);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[self::NONCE_LENGTH - 4 + $i] = $nonce[self::NONCE_LENGTH - 4 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a legacy single-block encrypted file.
|
||||
* Format: [12-byte nonce][16-byte auth tag][ciphertext]
|
||||
*/
|
||||
private function decryptLegacy(string $encryptedPath, string $key): string
|
||||
{
|
||||
$data = file_get_contents($encryptedPath);
|
||||
|
||||
if ($data === false) {
|
||||
throw new RuntimeException("Cannot read encrypted file: {$encryptedPath}");
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
substr($data, self::NONCE_LENGTH + self::TAG_LENGTH),
|
||||
self::CIPHER,
|
||||
$this->normalizeToBinaryKey($key),
|
||||
OPENSSL_RAW_DATA,
|
||||
substr($data, 0, self::NONCE_LENGTH),
|
||||
substr($data, self::NONCE_LENGTH, self::TAG_LENGTH),
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed - wrong key or corrupted data');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
+276
-48
@@ -5,12 +5,20 @@ namespace App\Services;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A share's life: files registered into a pending share, their encrypted chunks stored as the
|
||||
* uploader's browser sends them, and the share completed with its options.
|
||||
*/
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
@@ -18,67 +26,236 @@ class ShareService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new share with encrypted files.
|
||||
* Register a file the uploader's browser is about to send, in the given pending share or in a
|
||||
* new one, and write its encrypted file's header.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||
* @param array{password?: string|null, expires_at?: string|null, max_downloads?: int|null} $options
|
||||
* @throws ValidationException when the file breaks an admin limit
|
||||
*/
|
||||
public function createShare(array $files, array $options = []): Share
|
||||
public function registerFile(?Share $pendingShare, string $name, int $size, ?string $relativePath): ShareFile
|
||||
{
|
||||
$token = $this->generateUniqueToken();
|
||||
$salt = $this->encryptionService->generateSalt();
|
||||
$password = $options['password'] ?? null;
|
||||
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
|
||||
if ($password) {
|
||||
$encryptionKey = $this->encryptionService->deriveKey($password, $salt);
|
||||
$encryptionKeyHex = bin2hex($encryptionKey);
|
||||
$storedEncryptionKey = null;
|
||||
} else {
|
||||
$encryptionKeyHex = $this->encryptionService->generateRandomKey();
|
||||
$storedEncryptionKey = $encryptionKeyHex;
|
||||
if ($name === '' || mb_strlen($name) > 255 || $size < 0) {
|
||||
$this->rejectFile(__('The file could not be added.'));
|
||||
}
|
||||
|
||||
$share = Share::query()->create([
|
||||
'token' => $token,
|
||||
'password' => $password ? Hash::make($password) : null,
|
||||
'encryption_key' => $storedEncryptionKey,
|
||||
'encryption_salt' => $salt,
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'max_downloads' => $options['max_downloads'] ?? null,
|
||||
if ($size > $maxFileSize) {
|
||||
$this->rejectFile(__('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
||||
'name' => $name,
|
||||
'size' => round($size / (1024 * 1024), 1),
|
||||
'max' => intdiv($maxFileSize, 1024 * 1024),
|
||||
]));
|
||||
}
|
||||
|
||||
if ($pendingShare && $pendingShare->files()->count() >= $maxFilesPerShare) {
|
||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
}
|
||||
|
||||
if (($pendingShare?->total_size ?? 0) + $size > $maxSizePerShare) {
|
||||
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||
}
|
||||
|
||||
if ($this->getTotalUsedSpace() + $size > $this->getMaxStorageQuota()) {
|
||||
$this->rejectFile(__('Storage is full. Please contact the administrator.'));
|
||||
}
|
||||
|
||||
$share = $pendingShare ?? Share::query()->create([
|
||||
'token' => $this->generateUniqueToken(),
|
||||
'encryption_key' => $this->encryptionService->generateRandomKey(),
|
||||
'total_size' => 0,
|
||||
]);
|
||||
|
||||
$totalSize = 0;
|
||||
$storedName = Str::uuid().'.enc';
|
||||
|
||||
foreach ($files as $fileData) {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $fileData['file'];
|
||||
$relativePath = $fileData['relativePath'] ?? null;
|
||||
$storedName = Str::uuid().'.enc';
|
||||
$storedPath = 'shares/'.$share->token.'/'.$storedName;
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
Storage::disk('shares')->put($share->token.'/'.$storedName, $this->encryptionService->createHeader((int) config('uploads.chunk_size')));
|
||||
|
||||
$tempPath = $file->getRealPath();
|
||||
$destPath = Storage::disk('shares')->path($share->token.'/'.$storedName);
|
||||
$file = $share->files()->create([
|
||||
'original_name' => $name,
|
||||
'relative_path' => $this->sanitizeRelativePath($relativePath),
|
||||
'stored_path' => 'shares/'.$share->token.'/'.$storedName,
|
||||
'file_size' => $size,
|
||||
]);
|
||||
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
$share->increment('total_size', $size);
|
||||
|
||||
$this->encryptionService->encryptFile($tempPath, $destPath, $encryptionKeyHex);
|
||||
return $file;
|
||||
}
|
||||
|
||||
ShareFile::query()->create([
|
||||
'share_id' => $share->id,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'relative_path' => $relativePath,
|
||||
'stored_path' => $storedPath,
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
]);
|
||||
/**
|
||||
* Verify the encrypted chunk that comes next for a file and write it into place; returns how
|
||||
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
|
||||
*
|
||||
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication
|
||||
* @throws ModelNotFoundException when the file was removed meanwhile
|
||||
*/
|
||||
public function storeChunk(ShareFile $file, int $index, string $chunk): int
|
||||
{
|
||||
$share = $file->share;
|
||||
$handle = @fopen($this->storedFilePath($file), 'r+b');
|
||||
|
||||
$totalSize += $file->getSize();
|
||||
if ($handle === false) {
|
||||
throw (new ModelNotFoundException)->setModel(ShareFile::class, [$file->id]);
|
||||
}
|
||||
|
||||
$share->update(['total_size' => $totalSize]);
|
||||
try {
|
||||
['chunkSize' => $chunkSize, 'noncePrefix' => $noncePrefix] = $this->encryptionService->parseHeader(
|
||||
(string) fread($handle, FileEncryptionService::HEADER_LENGTH),
|
||||
);
|
||||
|
||||
return $share->fresh();
|
||||
$chunkCount = $this->encryptionService->chunkCount($file->file_size, $chunkSize);
|
||||
$isLast = $index === $chunkCount - 1;
|
||||
|
||||
if ($index >= $chunkCount) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' is beyond the end of the file');
|
||||
}
|
||||
|
||||
$plaintextLength = $isLast ? $file->file_size - $index * $chunkSize : $chunkSize;
|
||||
|
||||
if (strlen($chunk) !== $plaintextLength + FileEncryptionService::TAG_LENGTH) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
|
||||
}
|
||||
|
||||
try {
|
||||
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
|
||||
} catch (RuntimeException) {
|
||||
throw new InvalidArgumentException('Chunk '.$index.' failed authentication');
|
||||
}
|
||||
|
||||
$mimeType = $index === 0
|
||||
? ((new FinfoMimeTypeDetector)->detectMimeType($file->original_name, $plaintext) ?? 'application/octet-stream')
|
||||
: $file->mime_type;
|
||||
|
||||
unset($plaintext);
|
||||
|
||||
if (fseek($handle, $this->encryptionService->chunkOffset($index, $chunkSize)) !== 0
|
||||
|| fwrite($handle, $chunk) !== strlen($chunk)
|
||||
|| ! fflush($handle)) {
|
||||
throw new RuntimeException('Cannot write chunk '.$index.' of file '.$file->id);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
// Counted only once, even when a retry of the same chunk raced this request.
|
||||
$stored = ShareFile::query()
|
||||
->whereKey($file->id)
|
||||
->where('uploaded_chunks', $index)
|
||||
->update([
|
||||
'uploaded_chunks' => $index + 1,
|
||||
'mime_type' => $mimeType,
|
||||
'completed_at' => $isLast ? now() : null,
|
||||
]);
|
||||
|
||||
if ($stored === 0) {
|
||||
return ShareFile::query()->findOrFail($file->id)->uploaded_chunks;
|
||||
}
|
||||
|
||||
$share->touch();
|
||||
|
||||
return $index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from a pending share, whether or not its upload finished.
|
||||
*/
|
||||
public function removeFile(ShareFile $file): void
|
||||
{
|
||||
Storage::disk('shares')->delete($file->share->token.'/'.basename($file->stored_path));
|
||||
|
||||
$file->share->decrement('total_size', $file->file_size);
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a pending share once every file has arrived: with a password the data key is
|
||||
* wrapped and no longer stored as it is.
|
||||
*
|
||||
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||
*
|
||||
* @throws ValidationException when files are missing, unfinished or break an admin limit
|
||||
*/
|
||||
public function completeShare(Share $share, array $options = []): Share
|
||||
{
|
||||
$files = $share->files()->get();
|
||||
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
||||
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||
}
|
||||
|
||||
if ($files->contains(fn (ShareFile $file): bool => $file->completed_at === null)) {
|
||||
$this->rejectFile(__('Wait until every file has finished uploading, or remove the ones that failed.'));
|
||||
}
|
||||
|
||||
if ($files->count() > $maxFilesPerShare) {
|
||||
$this->rejectFile(__('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
||||
}
|
||||
|
||||
if ($files->sum('file_size') > $maxSizePerShare) {
|
||||
$this->rejectFile(__('Total file size exceeds the maximum allowed per share.'));
|
||||
}
|
||||
|
||||
$password = $options['password'] ?? null;
|
||||
|
||||
$share->update([
|
||||
'password' => $password ? Hash::make($password) : null,
|
||||
'wrapped_key' => $password ? $this->encryptionService->wrapKey($share->encryption_key, $password) : null,
|
||||
'encryption_key' => $password ? null : $share->encryption_key,
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'max_downloads' => $options['max_downloads'] ?? null,
|
||||
'total_size' => $files->sum('file_size'),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return $share;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a share from files already on the server, through the same steps an upload from the
|
||||
* browser takes. Used by tests and demo data.
|
||||
*
|
||||
* @param array<int, array{file: UploadedFile, relativePath: string|null}> $files
|
||||
* @param array{password?: string|null, expires_at?: mixed, max_downloads?: int|null} $options
|
||||
*/
|
||||
public function createShare(array $files, array $options = []): Share
|
||||
{
|
||||
$share = null;
|
||||
|
||||
foreach ($files as $fileData) {
|
||||
$file = $fileData['file'];
|
||||
$shareFile = $this->registerFile($share, $file->getClientOriginalName(), $file->getSize(), $fileData['relativePath'] ?? null);
|
||||
$share = $shareFile->share;
|
||||
|
||||
$header = $this->readHeader($shareFile);
|
||||
$source = fopen($file->getRealPath(), 'rb');
|
||||
|
||||
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||
$plaintextLength = min($header['chunkSize'], $shareFile->file_size - $index * $header['chunkSize']);
|
||||
|
||||
// A fake upload reports a size its content does not have: zeros make up the rest.
|
||||
$chunk = $this->encryptionService->encryptChunk(
|
||||
str_pad($plaintextLength > 0 ? (string) fread($source, $plaintextLength) : '', $plaintextLength, "\0"),
|
||||
$share->encryption_key,
|
||||
$header['noncePrefix'],
|
||||
$index,
|
||||
$index === $header['chunkCount'] - 1,
|
||||
);
|
||||
|
||||
$this->storeChunk($shareFile->refresh(), $index, $chunk);
|
||||
}
|
||||
|
||||
fclose($source);
|
||||
}
|
||||
|
||||
if ($share === null) {
|
||||
$this->rejectFile(__('Please select at least one file to upload.'));
|
||||
}
|
||||
|
||||
return $this->completeShare($share, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +285,8 @@ class ShareService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decryption key for a share.
|
||||
* Get the decryption key for a share: unwrapped with the password, derived from it for shares
|
||||
* created before key wrapping, or stored for shares without a password.
|
||||
*/
|
||||
public function getDecryptionKey(Share $share, ?string $password = null): string
|
||||
{
|
||||
@@ -117,6 +295,10 @@ class ShareService
|
||||
throw new RuntimeException('Password required for this share');
|
||||
}
|
||||
|
||||
if ($share->wrapped_key !== null) {
|
||||
return $this->encryptionService->unwrapKey($share->wrapped_key, $password);
|
||||
}
|
||||
|
||||
return bin2hex($this->encryptionService->deriveKey($password, $share->encryption_salt));
|
||||
}
|
||||
|
||||
@@ -148,7 +330,7 @@ class ShareService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total used space in bytes.
|
||||
* Get total used space in bytes, files still being uploaded included.
|
||||
*/
|
||||
public function getTotalUsedSpace(): int
|
||||
{
|
||||
@@ -160,9 +342,7 @@ class ShareService
|
||||
*/
|
||||
public function isStorageFull(): bool
|
||||
{
|
||||
$maxQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
|
||||
return $this->getTotalUsedSpace() >= $maxQuota;
|
||||
return $this->getTotalUsedSpace() >= $this->getMaxStorageQuota();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,4 +352,52 @@ class ShareService
|
||||
{
|
||||
return (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* A registered file's chunk size, nonce prefix and chunk count, from its encrypted file's header.
|
||||
*
|
||||
* @return array{chunkSize: int, noncePrefix: string, chunkCount: int}
|
||||
*/
|
||||
public function readHeader(ShareFile $file): array
|
||||
{
|
||||
$header = $this->encryptionService->parseHeader(
|
||||
(string) file_get_contents($this->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH),
|
||||
);
|
||||
|
||||
return [...$header, 'chunkCount' => $this->encryptionService->chunkCount($file->file_size, $header['chunkSize'])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a file's encrypted content is stored on disk.
|
||||
*/
|
||||
public function storedFilePath(ShareFile $file): string
|
||||
{
|
||||
return Storage::disk('shares')->path($file->share->token.'/'.basename($file->stored_path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative path from a dropped folder, or null when it could reach outside the share.
|
||||
*/
|
||||
private function sanitizeRelativePath(?string $relativePath): ?string
|
||||
{
|
||||
if ($relativePath === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function rejectFile(string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages(['files' => $message]);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"laravel/octane": "^2.13",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.0",
|
||||
"maennchen/zipstream-php": "^3.2",
|
||||
"nonameweb/livewire-material": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -88,7 +89,7 @@
|
||||
"screenshots": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run build",
|
||||
"@php -d upload_max_filesize=4G -d post_max_size=4G vendor/bin/pest tests/Screenshots"
|
||||
"@php vendor/bin/pest tests/Screenshots"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
||||
Generated
+79
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9ff0b0e3df0932a1f20bd2169d8c071f",
|
||||
"content-hash": "ac00b1ec9288209410d7a370eabfb8ef",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -2572,6 +2572,84 @@
|
||||
],
|
||||
"time": "2026-09-07T16:02:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.12.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Upload Chunk Size
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The uploader's browser encrypts every file in chunks of this many bytes
|
||||
| and sends each chunk as a request of its own. The size is written into
|
||||
| each file's header, so changing it never affects files already stored.
|
||||
| A reverse proxy in front must accept request bodies a little larger.
|
||||
|
|
||||
*/
|
||||
|
||||
'chunk_size' => (int) env('UPLOAD_CHUNK_SIZE_MB', 16) * 1024 * 1024,
|
||||
|
||||
];
|
||||
@@ -27,9 +27,22 @@ class ShareFactory extends Factory
|
||||
'max_downloads' => null,
|
||||
'download_count' => 0,
|
||||
'total_size' => 0,
|
||||
'completed_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A share whose files are still being uploaded.
|
||||
*/
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'encryption_key' => bin2hex(random_bytes(32)),
|
||||
'encryption_salt' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withPassword(string $password = 'secret'): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -25,6 +25,20 @@ class ShareFileFactory extends Factory
|
||||
'stored_path' => 'shares/'.fake()->uuid().'.enc',
|
||||
'file_size' => fake()->numberBetween(1024, 10485760),
|
||||
'mime_type' => 'text/plain',
|
||||
'uploaded_chunks' => 1,
|
||||
'completed_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A file whose chunks have not all arrived yet.
|
||||
*/
|
||||
public function uploading(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'mime_type' => null,
|
||||
'uploaded_chunks' => 0,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Shares and files that exist already were complete when they were created.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shares', function (Blueprint $table) {
|
||||
$table->text('wrapped_key')->nullable()->after('encryption_salt');
|
||||
$table->timestamp('completed_at')->nullable()->after('total_size');
|
||||
});
|
||||
|
||||
Schema::table('share_files', function (Blueprint $table) {
|
||||
$table->unsignedInteger('uploaded_chunks')->default(0)->after('mime_type');
|
||||
$table->timestamp('completed_at')->nullable()->after('uploaded_chunks');
|
||||
});
|
||||
|
||||
DB::table('shares')->update(['completed_at' => DB::raw('created_at')]);
|
||||
DB::table('share_files')->update(['completed_at' => DB::raw('created_at')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shares', function (Blueprint $table) {
|
||||
$table->dropColumn(['wrapped_key', 'completed_at']);
|
||||
});
|
||||
|
||||
Schema::table('share_files', function (Blueprint $table) {
|
||||
$table->dropColumn(['uploaded_chunks', 'completed_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -24,8 +24,8 @@ services:
|
||||
LOG_CHANNEL: stack
|
||||
LOG_LEVEL: debug
|
||||
OCTANE_MAX_EXECUTION_TIME: "300"
|
||||
PHP_UPLOAD_MAX_FILESIZE: "4G"
|
||||
PHP_POST_MAX_SIZE: "4G"
|
||||
PHP_UPLOAD_MAX_FILESIZE: "64M"
|
||||
PHP_POST_MAX_SIZE: "64M"
|
||||
PHP_MAX_EXECUTION_TIME: "300"
|
||||
PHP_MAX_INPUT_TIME: "300"
|
||||
PHP_MEMORY_LIMIT: "512M"
|
||||
|
||||
+19
-11
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Quick start:
|
||||
# 1. Copy this file: cp docker-compose.example.yml docker-compose.yml
|
||||
# 2. Edit the settings below (APP_URL and SERVER_NAME are required)
|
||||
# 2. Edit the settings below (APP_URL is required; uploads need HTTPS, see below)
|
||||
# 3. Start: docker compose up -d
|
||||
# 4. Open your browser to your configured domain
|
||||
#
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # HTTP
|
||||
- "443:443" # HTTPS (auto TLS via Let's Encrypt when SERVER_NAME is a real domain)
|
||||
- "443:443" # HTTPS (a Let's Encrypt certificate with AUTO_HTTPS)
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
volumes:
|
||||
- sealshare_storage:/app/storage/app # Uploaded & encrypted files
|
||||
@@ -32,9 +32,15 @@ services:
|
||||
environment:
|
||||
# --- REQUIRED ---
|
||||
APP_URL: # Your full URL, e.g. https://share.example.com
|
||||
SERVER_NAME: # Your domain for auto-TLS, e.g. share.example.com (use "localhost" for local testing)
|
||||
# APP_KEY: # Auto-generated if not set. Copy from logs to persist across restarts.
|
||||
|
||||
# --- HTTPS ---
|
||||
# Files are encrypted in the uploader's browser, which browsers only allow over HTTPS (or on
|
||||
# localhost). Either let this container fetch a Let's Encrypt certificate (ports 80 and 443
|
||||
# reachable from the internet), or put a reverse proxy that terminates TLS in front of port 80.
|
||||
# AUTO_HTTPS: "true"
|
||||
# SERVER_NAME: share.example.com # The domain to fetch the certificate for (only with AUTO_HTTPS)
|
||||
|
||||
# --- Optional: Application ---
|
||||
# APP_ENV: production
|
||||
# APP_DEBUG: "false"
|
||||
@@ -53,15 +59,17 @@ services:
|
||||
# OCTANE_HTTPS: "false" # Set to "true" when using HTTPS
|
||||
# OCTANE_MAX_EXECUTION_TIME: 300 # Max request execution time (seconds)
|
||||
|
||||
# --- Optional: PHP upload limits ---
|
||||
# PHP_UPLOAD_MAX_FILESIZE: "4G" # Max single file size
|
||||
# PHP_POST_MAX_SIZE: "4G" # Max total request size
|
||||
# PHP_MAX_EXECUTION_TIME: "300" # Upload timeout in seconds
|
||||
# PHP_MAX_INPUT_TIME: "300" # Input processing timeout
|
||||
# PHP_MEMORY_LIMIT: "512M" # PHP memory limit
|
||||
# LIVEWIRE_MAX_UPLOAD_TIME: "30" # Minutes a single upload may take (raise for large files on slow links)
|
||||
# --- Optional: Uploads ---
|
||||
# UPLOAD_CHUNK_SIZE_MB: "16" # Each encrypted chunk the browser sends; a reverse proxy must accept a little more
|
||||
|
||||
# --- Optional: PHP limits ---
|
||||
# PHP_UPLOAD_MAX_FILESIZE: "64M" # Only for the admin's logo upload: shares upload in chunks
|
||||
# PHP_POST_MAX_SIZE: "64M"
|
||||
# PHP_MAX_EXECUTION_TIME: "300"
|
||||
# PHP_MAX_INPUT_TIME: "300"
|
||||
# PHP_MEMORY_LIMIT: "512M"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
||||
test: ["CMD", "/app/docker/healthcheck.sh"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
|
||||
+5
-4
@@ -19,6 +19,7 @@ services:
|
||||
APP_URL: ${APP_URL:-http://localhost}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
APP_DEBUG: ${APP_DEBUG:-false}
|
||||
AUTO_HTTPS: ${AUTO_HTTPS:-false}
|
||||
SERVER_NAME: ${SERVER_NAME:-localhost}
|
||||
DB_CONNECTION: ${DB_CONNECTION:-sqlite}
|
||||
DB_HOST: ${DB_HOST:-}
|
||||
@@ -33,14 +34,14 @@ services:
|
||||
CACHE_STORE: ${CACHE_STORE:-database}
|
||||
OCTANE_HTTPS: ${OCTANE_HTTPS:-false}
|
||||
OCTANE_MAX_EXECUTION_TIME: ${OCTANE_MAX_EXECUTION_TIME:-300}
|
||||
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
||||
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-4G}
|
||||
UPLOAD_CHUNK_SIZE_MB: ${UPLOAD_CHUNK_SIZE_MB:-16}
|
||||
PHP_UPLOAD_MAX_FILESIZE: ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||
PHP_POST_MAX_SIZE: ${PHP_POST_MAX_SIZE:-64M}
|
||||
PHP_MAX_EXECUTION_TIME: ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
PHP_MAX_INPUT_TIME: ${PHP_MAX_INPUT_TIME:-300}
|
||||
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
|
||||
LIVEWIRE_MAX_UPLOAD_TIME: ${LIVEWIRE_MAX_UPLOAD_TIME:-30}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--silent", "--fail", "http://localhost/up"]
|
||||
test: ["CMD", "/app/docker/healthcheck.sh"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
frankenphp
|
||||
order php_server before file_server
|
||||
admin off
|
||||
}
|
||||
|
||||
{$SERVER_NAME:localhost} {
|
||||
root * /app/public
|
||||
encode zstd gzip
|
||||
request_body {
|
||||
max_size 4gb
|
||||
}
|
||||
php_server
|
||||
}
|
||||
@@ -6,8 +6,8 @@ cd /app
|
||||
# Generate PHP ini from environment variables (with defaults)
|
||||
echo "[dev] Configuring PHP settings..."
|
||||
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
|
||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
|
||||
+15
-3
@@ -15,8 +15,8 @@ fi
|
||||
# Generate PHP ini from environment variables (with defaults)
|
||||
echo "[entrypoint] Configuring PHP settings..."
|
||||
cat > /usr/local/etc/php/conf.d/99-uploads.ini <<EOF
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-4G}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-4G}
|
||||
upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE:-64M}
|
||||
post_max_size = ${PHP_POST_MAX_SIZE:-64M}
|
||||
max_execution_time = ${PHP_MAX_EXECUTION_TIME:-300}
|
||||
max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
@@ -33,5 +33,17 @@ php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
|
||||
echo "[entrypoint] Starting Octane (FrankenPHP)..."
|
||||
# Uploads are encrypted in the browser, which browsers only allow over HTTPS: either this container
|
||||
# fetches a certificate for SERVER_NAME itself, or a reverse proxy in front terminates TLS.
|
||||
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
|
||||
if [ -z "$SERVER_NAME" ]; then
|
||||
echo "[entrypoint] AUTO_HTTPS=true needs SERVER_NAME, the domain to fetch a certificate for." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting Octane (FrankenPHP) with automatic HTTPS for $SERVER_NAME..."
|
||||
exec php artisan octane:frankenphp --host="$SERVER_NAME" --port=443 --https --http-redirect
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting Octane (FrankenPHP) on HTTP..."
|
||||
exec php artisan octane:frankenphp --host=0.0.0.0 --port=80
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Healthy when the application answers /up: over HTTP on port 80, or over HTTPS for SERVER_NAME when
|
||||
# AUTO_HTTPS is on (port 80 then only redirects). The certificate is not checked, so a container
|
||||
# still waiting for Let's Encrypt is judged by the application, not by its certificate.
|
||||
if [ "${AUTO_HTTPS:-false}" = "true" ]; then
|
||||
exec curl --silent --fail --insecure --resolve "$SERVER_NAME:443:127.0.0.1" "https://$SERVER_NAME/up"
|
||||
fi
|
||||
|
||||
exec curl --silent --fail http://localhost/up
|
||||
@@ -2,8 +2,8 @@
|
||||
; These are default values — overridden at runtime by the entrypoint
|
||||
; when PHP_UPLOAD_MAX_FILESIZE / PHP_POST_MAX_SIZE / etc. env vars are set.
|
||||
|
||||
upload_max_filesize = 4G
|
||||
post_max_size = 4G
|
||||
upload_max_filesize = 64M
|
||||
post_max_size = 64M
|
||||
max_execution_time = 300
|
||||
max_input_time = 300
|
||||
memory_limit = 512M
|
||||
|
||||
@@ -122,8 +122,8 @@
|
||||
/*
|
||||
* resources/views/livewire/file-uploader.blade.php: the drop zone's dashed outline and its
|
||||
* primary tint while dragging. `data-dragging` is Alpine's, not the package's, since no
|
||||
* component tracks a native drag over an arbitrary drop target; disabled while an upload runs
|
||||
* blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
|
||||
* component tracks a native drag over an arbitrary drop target; disabled where uploads cannot run
|
||||
* (no secure context) blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
|
||||
* busy (.settings-recovery-code--loading).
|
||||
*/
|
||||
.upload-drop-zone {
|
||||
@@ -203,12 +203,6 @@
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
/* resources/views/livewire/file-uploader.blade.php: the "Processing files..." indicator at 1.x's smaller size, beside its label. */
|
||||
.upload-processing-indicator {
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
}
|
||||
|
||||
/* resources/views/livewire/file-uploader.blade.php: the selected-files list scrolls on its own past 1.x's cap instead of pushing the options and the submit button down the page. */
|
||||
.upload-file-list {
|
||||
max-block-size: 18rem;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
import './share-created.js'
|
||||
import './share-uploader.js'
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* `shareUploader`: the upload page's queue. Files are registered with the Livewire component in one
|
||||
* batch per selection, then sent one at a time: each chunk is sliced from the file, encrypted here
|
||||
* with AES-256-GCM and PUT on its own, so the server writes it once, already encrypted.
|
||||
*
|
||||
* The encrypted format is App\Services\FileEncryptionService's SEALCHK2: chunk i's nonce is the
|
||||
* file's 7-byte prefix, i as a big-endian uint32 and a byte that is 1 on the last chunk; WebCrypto
|
||||
* appends the 16-byte tag to the ciphertext, which is how the server stores it.
|
||||
*
|
||||
* A failed request is retried after 1, 2, 4, 8 and 16 seconds; after that the file waits for its
|
||||
* Retry button, which picks up from the chunk the server last confirmed. The server answers 409
|
||||
* with its own count when a chunk skips ahead, and acknowledges a chunk it already has.
|
||||
*/
|
||||
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('shareUploader', ({ csrfToken, messages }) => ({
|
||||
secure: window.isSecureContext && Boolean(window.crypto?.subtle),
|
||||
|
||||
dragging: false,
|
||||
|
||||
busy: false,
|
||||
|
||||
/** Files waiting to be sent, in order: { id, file, target, nextIndex }. */
|
||||
queue: [],
|
||||
|
||||
/** Every file this page registered, by id: { state: 'queued'|'uploading'|'uploaded'|'failed', sent, size }. */
|
||||
uploads: {},
|
||||
|
||||
/** The files that failed, by id, kept for their Retry button. */
|
||||
failed: {},
|
||||
|
||||
/** The request on its way, so Cancel and Remove can abort it. */
|
||||
request: null,
|
||||
|
||||
get progress() {
|
||||
const unfinished = Object.values(this.uploads).filter((upload) => upload.state !== 'failed')
|
||||
const size = unfinished.reduce((total, upload) => total + upload.size, 0)
|
||||
|
||||
return size === 0 ? 0 : (unfinished.reduce((total, upload) => total + upload.sent, 0) / size) * 100
|
||||
},
|
||||
|
||||
choose(event) {
|
||||
this.add([...event.target.files].map((file) => ({ file, path: null })))
|
||||
|
||||
event.target.value = ''
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
this.dragging = false
|
||||
|
||||
if (! this.secure) {
|
||||
return
|
||||
}
|
||||
|
||||
const items = event.dataTransfer.items
|
||||
const files = []
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.()
|
||||
|
||||
if (entry) {
|
||||
this.traverseEntry(entry, '', files)
|
||||
} else if (items[i].kind === 'file') {
|
||||
files.push({ file: items[i].getAsFile(), path: null })
|
||||
}
|
||||
}
|
||||
|
||||
// Directory entries are read asynchronously.
|
||||
setTimeout(() => this.add(files), 500)
|
||||
},
|
||||
|
||||
traverseEntry(entry, path, files) {
|
||||
if (entry.isFile) {
|
||||
entry.file((file) => files.push({ file, path: path ? `${path}/${file.name}` : null }))
|
||||
} else if (entry.isDirectory) {
|
||||
entry.createReader().readEntries((entries) => {
|
||||
entries.forEach((child) => this.traverseEntry(child, path ? `${path}/${entry.name}` : entry.name, files))
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async add(selection) {
|
||||
if (! this.secure || selection.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const targets = await this.$wire.registerFiles(selection.map(({ file, path }) => ({ name: file.name, size: file.size, path })))
|
||||
|
||||
;(targets ?? []).forEach((target, position) => {
|
||||
if (! target) {
|
||||
return
|
||||
}
|
||||
|
||||
this.uploads[target.id] = { state: 'queued', sent: 0, size: selection[position].file.size }
|
||||
this.queue.push({ id: target.id, file: selection[position].file, target, nextIndex: 0 })
|
||||
})
|
||||
|
||||
this.run()
|
||||
},
|
||||
|
||||
async run() {
|
||||
if (this.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
this.busy = true
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const item = this.queue[0]
|
||||
const uploaded = await this.upload(item)
|
||||
|
||||
if (this.queue[0] === item) {
|
||||
this.queue.shift()
|
||||
}
|
||||
|
||||
if (uploaded) {
|
||||
await this.$wire.$refresh()
|
||||
}
|
||||
}
|
||||
|
||||
this.busy = false
|
||||
},
|
||||
|
||||
/**
|
||||
* Send one file's remaining chunks; true once the server has them all.
|
||||
*/
|
||||
async upload(item) {
|
||||
const { id, file, target } = item
|
||||
const upload = this.uploads[id]
|
||||
|
||||
if (! upload) {
|
||||
return false
|
||||
}
|
||||
|
||||
upload.state = 'uploading'
|
||||
|
||||
try {
|
||||
const key = await crypto.subtle.importKey('raw', bytesFromHex(target.key), 'AES-GCM', false, ['encrypt'])
|
||||
const noncePrefix = bytesFromHex(target.noncePrefix)
|
||||
|
||||
while (item.nextIndex < target.chunkCount) {
|
||||
const index = item.nextIndex
|
||||
const start = index * target.chunkSize
|
||||
const plaintext = await file.slice(start, start + target.chunkSize).arrayBuffer()
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: chunkNonce(noncePrefix, index, index === target.chunkCount - 1), tagLength: 128 },
|
||||
key,
|
||||
plaintext,
|
||||
)
|
||||
|
||||
item.nextIndex = await this.send(upload, `${target.url}/${index}`, ciphertext, start, plaintext.byteLength)
|
||||
upload.sent = Math.min(item.nextIndex * target.chunkSize, upload.size)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return false
|
||||
}
|
||||
|
||||
upload.state = 'failed'
|
||||
this.failed[id] = item
|
||||
|
||||
if (error?.status === 419) {
|
||||
window.materialToast(messages.sessionExpired, { type: 'error' })
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
upload.state = 'uploaded'
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT one encrypted chunk, retrying transient failures; resolves with the number of chunks
|
||||
* the server holds for the file.
|
||||
*/
|
||||
async send(upload, url, body, offset, plaintextLength) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await this.put(url, body, (loaded) => {
|
||||
upload.sent = Math.min(offset + (loaded / body.byteLength) * plaintextLength, upload.size)
|
||||
})
|
||||
|
||||
if ((response.status === 200 || response.status === 409) && Number.isInteger(response.uploadedChunks)) {
|
||||
return response.uploadedChunks
|
||||
}
|
||||
|
||||
if (response.status === 404 || response.status === 419 || attempt === RETRY_DELAYS.length) {
|
||||
throw Object.assign(new Error('Upload failed'), { status: response.status })
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
|
||||
}
|
||||
},
|
||||
|
||||
put(url, body, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken)
|
||||
xhr.upload.onprogress = (event) => onProgress(event.loaded)
|
||||
xhr.onload = () => {
|
||||
this.request = null
|
||||
resolve({ status: xhr.status, uploadedChunks: parseUploadedChunks(xhr.responseText) })
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
this.request = null
|
||||
resolve({ status: 0 })
|
||||
}
|
||||
xhr.onabort = () => {
|
||||
this.request = null
|
||||
reject(new DOMException('Upload cancelled', 'AbortError'))
|
||||
}
|
||||
|
||||
this.request = xhr
|
||||
// Chromium sends a Blob body about eight times faster than the same ArrayBuffer.
|
||||
xhr.send(new Blob([body]))
|
||||
})
|
||||
},
|
||||
|
||||
retry(id) {
|
||||
const item = this.failed[id]
|
||||
|
||||
if (! item) {
|
||||
return
|
||||
}
|
||||
|
||||
delete this.failed[id]
|
||||
this.uploads[id].state = 'queued'
|
||||
this.queue.push(item)
|
||||
this.run()
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
this.forget([id])
|
||||
this.$wire.removeFiles([id])
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop everything still to send and take those files out of the share.
|
||||
*/
|
||||
cancel() {
|
||||
const unfinished = Object.entries(this.uploads)
|
||||
.filter(([, upload]) => upload.state !== 'uploaded')
|
||||
.map(([id]) => Number(id))
|
||||
|
||||
this.forget(unfinished)
|
||||
this.$wire.removeFiles(unfinished)
|
||||
},
|
||||
|
||||
forget(ids) {
|
||||
const current = this.queue[0]
|
||||
|
||||
this.queue = this.queue.filter((item) => ! ids.includes(item.id))
|
||||
|
||||
ids.forEach((id) => {
|
||||
delete this.uploads[id]
|
||||
delete this.failed[id]
|
||||
})
|
||||
|
||||
if (current && ids.includes(current.id)) {
|
||||
this.request?.abort()
|
||||
}
|
||||
},
|
||||
|
||||
statusOf(id, uploaded) {
|
||||
const upload = this.uploads[id]
|
||||
|
||||
if (uploaded || upload?.state === 'uploaded') {
|
||||
return messages.uploaded
|
||||
}
|
||||
|
||||
if (upload?.state === 'uploading') {
|
||||
return `${Math.round((upload.sent / Math.max(upload.size, 1)) * 100)}%`
|
||||
}
|
||||
|
||||
return upload?.state === 'failed' ? messages.failed : messages.queued
|
||||
},
|
||||
|
||||
warnBeforeLeaving(event) {
|
||||
if (this.busy) {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
function bytesFromHex(hex) {
|
||||
return Uint8Array.from(hex.match(/.{2}/g), (pair) => parseInt(pair, 16))
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk `index`'s 12-byte nonce: the file's prefix, the index as a big-endian uint32 and the
|
||||
* last-chunk flag.
|
||||
*/
|
||||
function chunkNonce(prefix, index, isLast) {
|
||||
const nonce = new Uint8Array(12)
|
||||
|
||||
nonce.set(prefix, 0)
|
||||
new DataView(nonce.buffer).setUint32(7, index)
|
||||
nonce[11] = isLast ? 1 : 0
|
||||
|
||||
return nonce
|
||||
}
|
||||
|
||||
function parseUploadedChunks(responseText) {
|
||||
try {
|
||||
return JSON.parse(responseText).uploaded_chunks
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -179,9 +179,7 @@
|
||||
:label="__('Max file size (MB)')"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="$phpMaxUploadMb"
|
||||
suffix="MB"
|
||||
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
|
||||
/>
|
||||
|
||||
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||
|
||||
@@ -12,85 +12,33 @@
|
||||
</x-stack>
|
||||
</x-stack>
|
||||
|
||||
@if ($isStorageFull)
|
||||
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
||||
@if ($isStorageFull && $pendingFiles->isEmpty())
|
||||
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
||||
@else
|
||||
<x-form
|
||||
wire:submit="createShare"
|
||||
x-data="{
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
dragging: false,
|
||||
handleDrop(e) {
|
||||
this.dragging = false;
|
||||
const items = e.dataTransfer.items;
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
this.traverseEntry(entry, '', files);
|
||||
} else if (items[i].kind === 'file') {
|
||||
files.push({ file: items[i].getAsFile(), path: null });
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (! files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = new DataTransfer();
|
||||
const paths = [];
|
||||
files.forEach(f => {
|
||||
dt.items.add(f.file);
|
||||
paths.push(f.path);
|
||||
});
|
||||
|
||||
$wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
|
||||
|
||||
this.uploading = true;
|
||||
this.progress = 0;
|
||||
|
||||
$wire.uploadMultiple(
|
||||
'files',
|
||||
dt.files,
|
||||
() => this.progress = 100,
|
||||
() => this.resetUpload(),
|
||||
(event) => this.progress = event.detail.progress,
|
||||
() => this.resetUpload(),
|
||||
);
|
||||
}, 500);
|
||||
},
|
||||
resetUpload() {
|
||||
this.uploading = false;
|
||||
this.progress = 0;
|
||||
},
|
||||
traverseEntry(entry, path, files) {
|
||||
if (entry.isFile) {
|
||||
entry.file(file => {
|
||||
files.push({ file, path: path ? path + '/' + file.name : null });
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
reader.readEntries(entries => {
|
||||
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
|
||||
});
|
||||
}
|
||||
}
|
||||
}"
|
||||
x-init="$wire.$on('files-processed', () => resetUpload())"
|
||||
x-on:livewire-upload-start="uploading = true; progress = 0"
|
||||
x-on:livewire-upload-finish="progress = 100"
|
||||
x-on:livewire-upload-cancel="resetUpload()"
|
||||
x-on:livewire-upload-error="resetUpload()"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
x-data="shareUploader({
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
messages: {{ \Illuminate\Support\Js::from([
|
||||
'queued' => __('Waiting'),
|
||||
'uploaded' => __('Uploaded'),
|
||||
'failed' => __('Upload failed'),
|
||||
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
||||
]) }},
|
||||
})"
|
||||
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||
>
|
||||
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
||||
<div x-show="! secure" x-cloak data-test="insecure-context">
|
||||
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
||||
</div>
|
||||
|
||||
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
||||
<div
|
||||
class="upload-drop-zone"
|
||||
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
||||
x-bind:aria-disabled="uploading ? 'true' : 'false'"
|
||||
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
||||
x-on:dragover.prevent="dragging = true"
|
||||
x-on:dragleave.prevent="dragging = false"
|
||||
x-on:drop.prevent="handleDrop($event)"
|
||||
@@ -108,25 +56,21 @@
|
||||
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
||||
</x-stack>
|
||||
|
||||
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the upload. --}}
|
||||
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="uploading" />
|
||||
<input type="file" wire:model="files" multiple hidden x-ref="picker" x-bind:disabled="uploading" />
|
||||
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
||||
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
||||
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
||||
</x-stack>
|
||||
</div>
|
||||
|
||||
{{-- Upload progress --}}
|
||||
<div x-show="uploading" x-cloak data-test="upload-progress">
|
||||
<x-stack x-show="progress < 100" gap="space100">
|
||||
{{-- Upload progress, over every file still to send --}}
|
||||
<div x-show="busy" x-cloak data-test="upload-progress">
|
||||
<x-stack gap="space100">
|
||||
<x-row justify="between">
|
||||
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||
<x-button :label="__('Cancel')" size="xs" x-on:click="$wire.cancelUpload('files')" />
|
||||
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
||||
</x-row>
|
||||
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
||||
</x-stack>
|
||||
<x-row x-show="progress >= 100" gap="space100">
|
||||
<x-loading class="upload-processing-indicator" :label="false" />
|
||||
<span class="md-type-label-lg">{{ __('Processing files...') }}</span>
|
||||
</x-row>
|
||||
</div>
|
||||
|
||||
@error('files')
|
||||
@@ -134,21 +78,28 @@
|
||||
@enderror
|
||||
|
||||
{{-- Selected files --}}
|
||||
@if (count($files))
|
||||
@if ($pendingFiles->isNotEmpty())
|
||||
<x-stack gap="space100">
|
||||
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ count($files) }})</h2>
|
||||
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
||||
|
||||
<div class="upload-file-list">
|
||||
<x-list segmented :label="__('Selected Files')">
|
||||
@foreach ($files as $index => $file)
|
||||
@foreach ($pendingFiles as $file)
|
||||
<x-list-item
|
||||
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
|
||||
:title="$file->relative_path ?? $file->original_name"
|
||||
icon="description"
|
||||
wire:key="selected-file-{{ $index }}"
|
||||
wire:key="selected-file-{{ $file->id }}"
|
||||
data-test="selected-file"
|
||||
>
|
||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->getSize()) }}</span></x-slot:description>
|
||||
<x-slot:description>
|
||||
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
||||
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
||||
</x-slot:description>
|
||||
<x-slot:end>
|
||||
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
|
||||
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
||||
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
||||
</span>
|
||||
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
@@ -217,7 +168,7 @@
|
||||
size="md"
|
||||
icon="link"
|
||||
spinner="createShare"
|
||||
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
||||
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
||||
data-test="create-share"
|
||||
/>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\DownloadController;
|
||||
use App\Http\Controllers\UploadChunkController;
|
||||
use App\Livewire\Admin\AdminDashboard;
|
||||
use App\Livewire\Admin\AdminSettings;
|
||||
use App\Livewire\FileUploader;
|
||||
@@ -20,6 +21,7 @@ Route::livewire('system-password', SystemPasswordPrompt::class)->name('system-pa
|
||||
|
||||
Route::middleware(['system.password'])->group(function () {
|
||||
Route::livewire('upload', FileUploader::class)->name('upload');
|
||||
Route::put('upload/files/{shareFile}/chunks/{index}', [UploadChunkController::class, 'store'])->whereNumber('index')->name('upload.chunk');
|
||||
Route::livewire('share/{share:token}/created', ShareCreated::class)->name('share.created');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
@@ -28,8 +29,28 @@ test('files dragged over the drop zone turn its shape into a burst', function ()
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
|
||||
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
|
||||
test('a chosen file is encrypted in the browser, sent in chunks and shared with its exact content', function () {
|
||||
// Pest's in-process server takes request bodies up to 128 KB: 64 KB chunks send this file in three.
|
||||
config(['uploads.chunk_size' => 64 * 1024]);
|
||||
$content = random_bytes(150 * 1024);
|
||||
$path = sys_get_temp_dir().'/sealshare-browser-upload-'.uniqid().'.bin';
|
||||
file_put_contents($path, $content);
|
||||
|
||||
$page = ready(visit('/upload'));
|
||||
$page->attach('[data-test="file-input"]', $path)
|
||||
->waitForText('Uploaded')
|
||||
->click('[data-test="create-share"]')
|
||||
->waitForText('Share Created!')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
$file = Share::query()->sole()->files->sole();
|
||||
expect($file->uploaded_chunks)->toBe(3);
|
||||
$stored = app(FileEncryptionService::class)->decryptedChunks(app(ShareService::class)->storedFilePath($file), $file->share->encryption_key);
|
||||
expect(implode('', iterator_to_array($stored, false)))->toBe($content);
|
||||
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
||||
$share = app(ShareService::class)->createShare(
|
||||
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
||||
|
||||
@@ -45,12 +45,37 @@ test('tab reaches Browse Files with its focus ring, and Enter or Space opens the
|
||||
});
|
||||
|
||||
test('the selected files list avoids horizontal overflow once files are chosen', function () {
|
||||
// Pest's in-process browser server never parses the multipart body a real file selection sends
|
||||
// (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php:257, `[], // @TODO
|
||||
// files...`) — Livewire's temporary-upload request has nowhere to land, so a selected file can
|
||||
// never reach this list to be measured. tests/Feature/FileUploadTest.php covers the list's data
|
||||
// through Livewire::test(); driving a real selection through the browser is impractical here.
|
||||
})->skip('the in-process browser server drops multipart uploads (LaravelHttpServer.php:257): a real file selection cannot reach the list');
|
||||
$path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip';
|
||||
file_put_contents($path, 'archive');
|
||||
|
||||
$page = ready(visit('/upload')->resize(393, 852));
|
||||
$page->attach('[data-test="file-input"]', $path)
|
||||
->waitForText('Uploaded');
|
||||
|
||||
$rows = $page->script("(() => {
|
||||
const rows = [...document.querySelectorAll('[data-test=selected-file]')];
|
||||
return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) };
|
||||
})()");
|
||||
|
||||
expect($rows['count'])->toBe(1);
|
||||
expect($rows['allFit'])->toBeTrue();
|
||||
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
test('without a secure context the upload page says HTTPS is needed and takes no files', function () {
|
||||
$page = ready(visit('/upload'));
|
||||
|
||||
$page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")");
|
||||
|
||||
$page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'")
|
||||
->assertSee('Uploads need a secure connection (HTTPS).')
|
||||
->assertScript("document.querySelector('[data-test=file-input]').disabled === true")
|
||||
->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
test('the drop zone hides its burst again once the drag leaves', function () {
|
||||
$page = ready(visit('/upload'));
|
||||
|
||||
@@ -96,3 +96,20 @@ test('without shares the dashboard shows an empty state instead of the table', f
|
||||
->assertSee('No shares yet')
|
||||
->assertDontSee('<table', false);
|
||||
});
|
||||
|
||||
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
||||
ShareFile::factory()->for($completed)->create();
|
||||
$pending = Share::factory()->pending()->create(['token' => 'pendingshare0001', 'total_size' => 500]);
|
||||
ShareFile::factory()->for($pending)->uploading()->create();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminDashboard::class)
|
||||
->assertSee('completedshare01')
|
||||
->assertDontSee('pendingshare0001')
|
||||
->assertViewHas('totalShares', 1)
|
||||
->assertViewHas('activeShares', 1)
|
||||
->assertViewHas('totalFiles', 1)
|
||||
->assertViewHas('usedSpace', 1500);
|
||||
});
|
||||
|
||||
@@ -33,11 +33,9 @@ test('admin can access settings page', function () {
|
||||
test('admin can save settings', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', min(200, $phpMaxMb))
|
||||
->set('maxFileSize', 200)
|
||||
->set('maxStorageQuota', 50)
|
||||
->set('maxFilesPerShare', 100)
|
||||
->set('maxSizePerShare', 5)
|
||||
@@ -46,7 +44,7 @@ test('admin can save settings', function () {
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
|
||||
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (200 * 1024 * 1024));
|
||||
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
|
||||
expect(Setting::get('max_files_per_share'))->toBe('100');
|
||||
expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024));
|
||||
@@ -55,11 +53,9 @@ test('admin can save settings', function () {
|
||||
|
||||
test('admin can set system password', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', $phpMaxMb)
|
||||
->set('maxFileSize', 100)
|
||||
->set('systemPassword', 'new-system-password')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
@@ -87,8 +83,7 @@ test('admin can clear system password', function () {
|
||||
|
||||
test('settings page loads existing values', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
$testSize = min(40, $phpMaxMb);
|
||||
$testSize = 40;
|
||||
|
||||
Setting::set('max_file_size', $testSize * 1024 * 1024);
|
||||
Setting::set('max_files_per_share', 75);
|
||||
@@ -261,3 +256,16 @@ test('switching the password generator off hides its options', function () {
|
||||
->assertDontSeeHtml('wire:model.live="passwordGeneratorType"')
|
||||
->assertDontSeeHtml('data-test="password-example"');
|
||||
});
|
||||
|
||||
test('a max file size far above PHP\'s upload limit loads and saves unchanged', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('maxFileSize', 15000)
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (15000 * 1024 * 1024));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Share;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
|
||||
test('cleanup removes expired shares', function () {
|
||||
Storage::fake('shares');
|
||||
@@ -58,3 +59,35 @@ test('cleanup removes both expired and download-limited shares', function () {
|
||||
expect(Share::query()->find($limitReached->id))->toBeNull();
|
||||
expect(Share::query()->find($active->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cleanup removes uploads no chunk reached for 4 hours, and keeps recent ones and completed shares', function () {
|
||||
Storage::fake('shares');
|
||||
$this->freezeTime();
|
||||
$abandoned = Share::factory()->pending()->create(['updated_at' => now()->subHours(4)->subMinute()]);
|
||||
Storage::disk('shares')->put($abandoned->token.'/file.enc', 'encrypted');
|
||||
$recent = Share::factory()->pending()->create(['updated_at' => now()->subHours(3)]);
|
||||
$completed = Share::factory()->create(['updated_at' => now()->subDays(3)]);
|
||||
|
||||
$this->artisan('shares:cleanup')
|
||||
->expectsOutputToContain('Cleaned up 1 abandoned upload(s)')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertModelMissing($abandoned);
|
||||
$this->assertModelExists($recent);
|
||||
$this->assertModelExists($completed);
|
||||
expect(Storage::disk('shares')->directories())->not->toContain($abandoned->token);
|
||||
});
|
||||
|
||||
test('cleanup removes temporary upload files older than 4 hours and keeps newer ones', function () {
|
||||
$storage = FileUploadConfiguration::storage();
|
||||
$storage->put(FileUploadConfiguration::path('old.pdf'), 'unencrypted leftover');
|
||||
$storage->put(FileUploadConfiguration::path('new.png'), 'a logo being chosen');
|
||||
touch($storage->path(FileUploadConfiguration::path('old.pdf')), now()->subHours(5)->getTimestamp());
|
||||
|
||||
$this->artisan('shares:cleanup')
|
||||
->expectsOutputToContain('Cleaned up 1 temporary upload file(s)')
|
||||
->assertExitCode(0);
|
||||
|
||||
expect($storage->exists(FileUploadConfiguration::path('old.pdf')))->toBeFalse();
|
||||
expect($storage->exists(FileUploadConfiguration::path('new.png')))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -4,13 +4,41 @@ use App\Livewire\FileUploader;
|
||||
use App\Livewire\SystemPasswordPrompt;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Upload files through the page as the browser does: register them in one batch, then store each
|
||||
* one's encrypted content (the chunk endpoint itself is covered by UploadChunkTest).
|
||||
*
|
||||
* @param array<string, string> $files name => content
|
||||
* @return array<int, array<string, mixed>|null> what the page handed the browser
|
||||
*/
|
||||
function uploadThroughPage(Testable $component, array $files): array
|
||||
{
|
||||
$targets = [];
|
||||
|
||||
$component->call('registerFiles', collect($files)->map(fn (string $content, string $name): array => ['name' => $name, 'size' => strlen($content), 'path' => null])->values()->all())
|
||||
->assertReturned(function (array $returned) use (&$targets): bool {
|
||||
$targets = $returned;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
foreach (array_values($files) as $position => $content) {
|
||||
if ($targets[$position] !== null) {
|
||||
$file = ShareFile::query()->findOrFail($targets[$position]['id']);
|
||||
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, $content, 0, true));
|
||||
}
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
test('upload page can be rendered', function () {
|
||||
$response = $this->get(route('upload'));
|
||||
|
||||
@@ -36,28 +64,100 @@ test('upload page accessible after system password verified', function () {
|
||||
|
||||
test('file upload creates share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||
|
||||
$file = UploadedFile::fake()->create('document.pdf', 1024);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->call('createShare')
|
||||
$component->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(Share::query()->count())->toBe(1);
|
||||
$share = Share::query()->sole();
|
||||
expect($share->isCompleted())->toBeTrue();
|
||||
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
|
||||
expect(session('pending_shares'))->not->toContain($share->token);
|
||||
});
|
||||
|
||||
$share = Share::query()->first();
|
||||
expect($share->files)->toHaveCount(1);
|
||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
|
||||
$targets = uploadThroughPage($component, ['a.txt' => 'abc']);
|
||||
|
||||
$file = ShareFile::query()->sole();
|
||||
expect($targets[0])->toMatchArray([
|
||||
'id' => $file->id,
|
||||
'url' => url('upload/files/'.$file->id.'/chunks'),
|
||||
'key' => $file->share->encryption_key,
|
||||
'chunkSize' => 4,
|
||||
'chunkCount' => 1,
|
||||
]);
|
||||
expect($targets[0]['noncePrefix'])->toBe(bin2hex(substr(file_get_contents(app(ShareService::class)->storedFilePath($file)), 12, 7)));
|
||||
expect(session('pending_shares'))->toBe([$file->share->token]);
|
||||
$component->assertSet('pendingToken', $file->share->token)
|
||||
->assertSeeHtml('data-test="selected-file"');
|
||||
});
|
||||
|
||||
test('each upload page gets its own pending share', function () {
|
||||
Storage::fake('shares');
|
||||
$first = Livewire::test(FileUploader::class);
|
||||
$second = Livewire::test(FileUploader::class);
|
||||
|
||||
uploadThroughPage($first, ['one.txt' => 'one']);
|
||||
uploadThroughPage($second, ['two.txt' => 'two']);
|
||||
|
||||
expect($first->get('pendingToken'))->not->toBe($second->get('pendingToken'));
|
||||
expect(Share::query()->pluck('total_size')->all())->toBe([3, 3]);
|
||||
});
|
||||
|
||||
test('a file an admin limit refuses gets no target and shows why, while the rest of the batch is registered', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('max_file_size', 1024 * 1024);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
|
||||
$component->call('registerFiles', [
|
||||
['name' => 'small.txt', 'size' => 3, 'path' => null],
|
||||
['name' => 'large.txt', 'size' => 2 * 1024 * 1024, 'path' => null],
|
||||
]);
|
||||
|
||||
$component->assertReturned(fn (array $targets): bool => $targets[0] !== null && $targets[1] === null);
|
||||
expect($component->errors()->first('files'))->toBe('"large.txt" is too large (2 MB). Maximum file size is 1 MB.');
|
||||
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['small.txt']);
|
||||
});
|
||||
|
||||
test('removing files takes them out of the pending share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
$targets = uploadThroughPage($component, ['keep.txt' => 'keep', 'remove.txt' => 'remove']);
|
||||
|
||||
$component->call('removeFiles', [$targets[1]['id']]);
|
||||
|
||||
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['keep.txt']);
|
||||
expect(Share::query()->sole()->total_size)->toBe(4);
|
||||
});
|
||||
|
||||
test('a share cannot be created while a file is still uploading', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('registerFiles', [['name' => 'unfinished.txt', 'size' => 10, 'path' => null]]);
|
||||
|
||||
$component->call('createShare');
|
||||
|
||||
expect($component->errors()->first('files'))->toBe('Wait until every file has finished uploading, or remove the ones that failed.');
|
||||
expect(Share::query()->sole()->isCompleted())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the page offers a warning for browsers without a secure context', function () {
|
||||
Livewire::test(FileUploader::class)
|
||||
->assertSeeHtml('data-test="insecure-context"')
|
||||
->assertSee('Uploads need a secure connection (HTTPS).');
|
||||
});
|
||||
|
||||
test('file upload with password creates password-protected share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||
|
||||
$file = UploadedFile::fake()->create('secret.txt', 512);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('usePassword', true)
|
||||
->set('password', 'my-password')
|
||||
->call('createShare')
|
||||
@@ -79,9 +179,9 @@ test('switching password protection on leaves the field empty and offers a Gener
|
||||
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('secret.txt', 512)])
|
||||
->set('usePassword', true)
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||
$component->set('usePassword', true)
|
||||
->call('generatePassword');
|
||||
$password = $component->get('password');
|
||||
$component->call('createShare');
|
||||
@@ -96,9 +196,10 @@ test('a generated password protects the share and is flashed, encrypted, for the
|
||||
test('a share without a password flashes no password', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('document.pdf', 100)])
|
||||
->call('createShare')
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||
|
||||
$component->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(session()->has('share_password'))->toBeFalse();
|
||||
@@ -136,10 +237,10 @@ test('a generator switched off offers no Generate button and generates nothing',
|
||||
test('file upload with expiration sets expires_at', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('expiration', '24h')
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
@@ -151,10 +252,10 @@ test('file upload with expiration sets expires_at', function () {
|
||||
test('file upload with max downloads sets limit', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('maxDownloads', 5)
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
@@ -163,69 +264,26 @@ test('file upload with max downloads sets limit', function () {
|
||||
expect($share->max_downloads)->toBe(5);
|
||||
});
|
||||
|
||||
test('every upload batch dispatches files-processed to clear the uploading state', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
||||
->assertDispatched('files-processed')
|
||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
||||
->assertDispatched('files-processed');
|
||||
});
|
||||
|
||||
test('files added in multiple batches end up in the same share', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
||||
->call('createShare')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
$share = Share::query()->first();
|
||||
|
||||
expect($share->files->pluck('original_name')->all())->toBe(['first.txt', 'second.txt']);
|
||||
});
|
||||
|
||||
test('files larger than 4 GB can be shared when within the admin file size limit', function () {
|
||||
test('a file of 6 GB is accepted when the admin limits allow it', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('backup.dump', 6 * 1024 * 1024)])
|
||||
->assertHasNoErrors('files')
|
||||
->call('createShare')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(Share::query()->first()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('a rejected upload logs the real reason instead of blaming the file size limit', function () {
|
||||
Log::spy();
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
|
||||
$errors = ['files.0' => ['The files.0 failed to upload.']];
|
||||
Setting::set('max_storage_quota', 50 * 1024 * 1024 * 1024);
|
||||
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('_uploadErrored', 'files', json_encode(['errors' => $errors]), true)
|
||||
->assertDispatched('upload:errored');
|
||||
->call('registerFiles', [['name' => 'backup.dump', 'size' => 6 * 1024 * 1024 * 1024, 'path' => null]]);
|
||||
|
||||
expect($component->errors()->first('files'))
|
||||
->toBe('Upload failed: the server could not accept the file. Please try again or contact the administrator.');
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context): bool => $context['errors'] === $errors)
|
||||
->once();
|
||||
$component->assertHasNoErrors('files');
|
||||
expect(Share::query()->sole()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
expect(ShareFile::query()->sole()->file_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('file upload requires at least one file', function () {
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [])
|
||||
->call('createShare')
|
||||
->assertHasErrors(['files']);
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('createShare');
|
||||
|
||||
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
|
||||
expect(Share::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('file upload blocks when storage is full', function () {
|
||||
@@ -233,12 +291,11 @@ test('file upload blocks when storage is full', function () {
|
||||
Setting::set('max_storage_quota', 100);
|
||||
Share::factory()->create(['total_size' => 100]);
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 1);
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 1, 'path' => null]]);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->call('createShare')
|
||||
->assertHasErrors(['files']);
|
||||
expect($component->errors()->first('files'))->toBe('Storage is full. Please contact the administrator.');
|
||||
expect(ShareFile::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('system password prompt verifies correct password', function () {
|
||||
|
||||
@@ -4,8 +4,8 @@ use App\Livewire\Admin\AdminSettings;
|
||||
use App\Livewire\FileUploader;
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -94,23 +94,17 @@ test('rate limiter clears after successful password verification', function () {
|
||||
test('share password must be at least 8 characters', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('usePassword', true)
|
||||
->set('password', 'short')
|
||||
->call('createShare')
|
||||
->assertHasErrors(['password']);
|
||||
->assertHasErrors(['password' => 'min']);
|
||||
});
|
||||
|
||||
test('share password of 8 characters is accepted', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('usePassword', true)
|
||||
->set('password', 'longenough')
|
||||
->call('createShare')
|
||||
@@ -151,42 +145,17 @@ test('setup wizard createAdmin is blocked when admin already exists', function (
|
||||
test('content disposition handles special characters in filename', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$service = app(ShareService::class);
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
|
||||
$file = UploadedFile::fake()->create('normal.txt', 100);
|
||||
|
||||
$share = $service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
$share = app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('normal.txt', 'test content'), 'relativePath' => null],
|
||||
]);
|
||||
|
||||
$share->load('files');
|
||||
$shareFile = $share->files->first();
|
||||
$shareFile->update(['original_name' => 'file"with"quotes.txt']);
|
||||
|
||||
$shareFile->original_name = 'file"with"quotes.txt';
|
||||
$shareFile->save();
|
||||
$response = $this->get(route('share.download.file', [$share, $shareFile]));
|
||||
|
||||
$encryptedDir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($encryptedDir)) {
|
||||
mkdir($encryptedDir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $encryptedDir.'/'.basename($shareFile->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, 'test content');
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $share->encryption_key);
|
||||
unlink($tempSource);
|
||||
|
||||
$response = $encryptionService->decryptFileStream(
|
||||
$encryptedPath,
|
||||
$share->encryption_key,
|
||||
'file"with"quotes.txt',
|
||||
'text/plain',
|
||||
12,
|
||||
);
|
||||
|
||||
$contentDisposition = $response->headers->get('Content-Disposition');
|
||||
expect($contentDisposition)->not->toContain('file"with"quotes.txt');
|
||||
expect($contentDisposition)->toContain('attachment');
|
||||
expect($response->headers->get('Content-Disposition'))
|
||||
->toContain('attachment')
|
||||
->not->toContain('file"with"quotes.txt');
|
||||
});
|
||||
|
||||
// --- SVG upload rejected ---
|
||||
@@ -194,11 +163,9 @@ test('content disposition handles special characters in filename', function () {
|
||||
test('svg upload is rejected for site logo', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', $phpMaxMb)
|
||||
->set('maxFileSize', 100)
|
||||
->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml'))
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['siteLogo']);
|
||||
@@ -206,52 +173,26 @@ test('svg upload is rejected for site logo', function () {
|
||||
|
||||
// --- Relative path validation (Zip Slip prevention) ---
|
||||
|
||||
test('relative paths with directory traversal are sanitized', function () {
|
||||
test('relative paths from a dropped folder that could reach outside the share are dropped', function (string $relativePath) {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['../../etc/passwd'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => $relativePath]]);
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBeNull();
|
||||
});
|
||||
|
||||
test('relative paths with absolute paths are sanitized', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['/etc/passwd'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBeNull();
|
||||
});
|
||||
expect(ShareFile::query()->sole()->relative_path)->toBeNull();
|
||||
})->with([
|
||||
'directory traversal' => '../../etc/passwd',
|
||||
'absolute path' => '/etc/passwd',
|
||||
'windows traversal' => '..\\..\\windows\\system.ini',
|
||||
]);
|
||||
|
||||
test('valid relative paths are preserved', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['folder/subfolder/file.txt'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => 'folder/subfolder/file.txt']]);
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBe('folder/subfolder/file.txt');
|
||||
expect(ShareFile::query()->sole()->relative_path)->toBe('folder/subfolder/file.txt');
|
||||
});
|
||||
|
||||
// --- Security headers ---
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -81,102 +82,79 @@ test('non-password share shows files directly', function () {
|
||||
|
||||
test('download counter increments on zip download', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'test content';
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
|
||||
$response = $this->withSession(['share_password_'.$share->token => null])
|
||||
->get(route('share.download.all', $share));
|
||||
|
||||
$response->assertDownload();
|
||||
$response = $this->get(route('share.download.all', $share));
|
||||
$response->streamedContent();
|
||||
|
||||
$response->assertDownload('share-'.$share->token.'.zip');
|
||||
expect($share->fresh()->download_count)->toBe(1);
|
||||
});
|
||||
|
||||
test('zip download produces a valid archive', function () {
|
||||
test('zip download streams a valid archive with every file\'s original content', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 1000]);
|
||||
$binary = random_bytes(2500);
|
||||
$share = app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello zip content'), 'relativePath' => null],
|
||||
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
|
||||
]);
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'hello zip content';
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
|
||||
$response = $this->get(route('share.download.all', $share));
|
||||
$response->assertDownload();
|
||||
|
||||
$zipPath = $response->getFile()->getPathname();
|
||||
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$result = $zip->open($zipPath);
|
||||
|
||||
expect($result)->toBe(true);
|
||||
expect($zip->numFiles)->toBe(1);
|
||||
expect($zip->statIndex(0)['size'])->toBe(strlen($content));
|
||||
|
||||
expect($zip->open($zipPath))->toBeTrue();
|
||||
expect($zip->numFiles)->toBe(2);
|
||||
expect($zip->getFromName('notes.txt'))->toBe('hello zip content');
|
||||
expect($zip->getFromName('holiday/photo.bin'))->toBe($binary);
|
||||
$zip->close();
|
||||
unlink($zipPath);
|
||||
});
|
||||
|
||||
test('last download streams successfully before auto-delete', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->update(['max_downloads' => 1]);
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'last download content';
|
||||
$content = $this->get(route('share.download.all', $share))->streamedContent();
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
expect($content)->toStartWith("PK\x03\x04");
|
||||
$this->assertModelMissing($share);
|
||||
});
|
||||
|
||||
$response = $this->get(route('share.download.all', $share));
|
||||
$response->assertDownload();
|
||||
test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) {
|
||||
Storage::fake('shares');
|
||||
$share = Share::factory()->pending()->create();
|
||||
$file = ShareFile::factory()->for($share)->uploading()->create();
|
||||
|
||||
// Share was deleted after download
|
||||
expect(Share::query()->find($share->id))->toBeNull();
|
||||
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $file]));
|
||||
|
||||
$response->assertNotFound();
|
||||
})->with([
|
||||
'download page' => 'share.download',
|
||||
'download all' => 'share.download.all',
|
||||
'download one file' => 'share.download.file',
|
||||
'share created page' => 'share.created',
|
||||
]);
|
||||
|
||||
test('a password share created before key wrapping still unlocks and downloads', function () {
|
||||
Storage::fake('shares');
|
||||
$salt = str_repeat('cd', 32);
|
||||
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
||||
$file = ShareFile::factory()->for($share)->create(['stored_path' => 'shares/'.$share->token.'/old.enc', 'file_size' => 11]);
|
||||
$source = tempnam(sys_get_temp_dir(), 'old');
|
||||
file_put_contents($source, 'old content');
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
|
||||
unlink($source);
|
||||
|
||||
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||
->set('password', 'old-password')
|
||||
->call('verifyPassword')
|
||||
->assertSet('authenticated', true);
|
||||
|
||||
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
|
||||
});
|
||||
|
||||
test('share auto-deletes after reaching download limit', function () {
|
||||
@@ -199,13 +177,10 @@ test('share auto-deletes after reaching download limit', function () {
|
||||
/**
|
||||
* Helper to create a share with an actual encrypted file.
|
||||
*/
|
||||
function createShareWithFile(?string $password = null): Share
|
||||
function createShareWithFile(?string $password = null, string $content = 'test content'): Share
|
||||
{
|
||||
$service = app(ShareService::class);
|
||||
$file = UploadedFile::fake()->create('testfile.txt', 100);
|
||||
|
||||
return $service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
return app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null],
|
||||
], [
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* PUT a chunk's bytes as the uploader's page does, with the given pending shares in the session.
|
||||
*
|
||||
* @param array<int, string> $pendingShares
|
||||
*/
|
||||
function putChunk(mixed $test, ShareFile $file, int $index, string $chunk, array $pendingShares): mixed
|
||||
{
|
||||
return $test->withSession(['pending_shares' => $pendingShares])->call(
|
||||
'PUT',
|
||||
route('upload.chunk', ['shareFile' => $file, 'index' => $index]),
|
||||
server: ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'],
|
||||
content: $chunk,
|
||||
);
|
||||
}
|
||||
|
||||
test('a chunk for a pending share this session started is stored', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
|
||||
|
||||
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('a chunk for a pending share another session started returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), ['someOtherToken12']);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk for a share that is already complete returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
$file->share->update(['completed_at' => now()]);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('a chunk for a removed file returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$service = app(ShareService::class);
|
||||
$file = $service->registerFile(null, 'notes.txt', 4, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||
$token = $file->share->token;
|
||||
$service->removeFile($file);
|
||||
|
||||
$response = putChunk($this, $file, 0, $chunk, [$token]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('a chunk that skips ahead returns 409 with the number of chunks stored', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$response = putChunk($this, $file, 1, encryptedChunk($file, 'ef', 1, true), [$file->share->token]);
|
||||
|
||||
$response->assertConflict()->assertExactJson(['uploaded_chunks' => 0]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk sent again after it was stored is acknowledged without storing it twice', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
putChunk($this, $file, 0, $chunk, [$file->share->token]);
|
||||
|
||||
$response = putChunk($this, $file->refresh(), 0, $chunk, [$file->share->token]);
|
||||
|
||||
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('an invalid chunk returns 422 and is not stored', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, str_repeat("\0", 20), [$file->share->token]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk is refused until the system password was entered', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||
|
||||
$response->assertRedirect(route('system-password'));
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ShareFile;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -63,3 +66,14 @@ function ready(mixed $page): mixed
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk of a registered file, encrypted with its share's key and its header's nonce prefix.
|
||||
*/
|
||||
function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $isLast): string
|
||||
{
|
||||
$encryption = app(FileEncryptionService::class);
|
||||
$header = $encryption->parseHeader(file_get_contents(app(ShareService::class)->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH));
|
||||
|
||||
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Tests\Screenshots\DemoData;
|
||||
use Tests\Screenshots\Publisher;
|
||||
|
||||
@@ -19,14 +18,9 @@ beforeEach(function () {
|
||||
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
||||
config(['session.driver' => 'file']);
|
||||
Storage::fake('shares');
|
||||
Storage::fake('tmp-for-tests');
|
||||
|
||||
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
||||
|
||||
// Livewire deletes temporary uploads a day older than now, by the files' real modification
|
||||
// times: under the frozen clock that is every file this run stores.
|
||||
config(['livewire.temporary_file_upload.cleanup' => false]);
|
||||
|
||||
DemoData::shares();
|
||||
});
|
||||
|
||||
@@ -71,27 +65,36 @@ function shoot(mixed $page, string $device, string $theme, string $name): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
|
||||
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
|
||||
* the file input, because the in-process server does not store a multipart upload.
|
||||
* Put files into the uploader the way a finished upload does: registered through the page's own
|
||||
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
|
||||
* the demo size), and the list refreshed to show them uploaded.
|
||||
*
|
||||
* @param array<string, int> $files relative path => size in kilobytes
|
||||
*/
|
||||
function selectFiles(mixed $page, array $files): void
|
||||
{
|
||||
$signed = [];
|
||||
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
|
||||
'name' => basename($path),
|
||||
'size' => $kilobytes * 1024,
|
||||
'path' => str_contains($path, '/') ? $path : null,
|
||||
])->values();
|
||||
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
|
||||
|
||||
foreach ($files as $path => $kilobytes) {
|
||||
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
|
||||
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
|
||||
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
|
||||
$page->waitForText('Selected Files ('.count($files).')');
|
||||
|
||||
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
|
||||
$header = $shareService->readHeader($file);
|
||||
|
||||
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
|
||||
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
|
||||
}
|
||||
}
|
||||
|
||||
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
|
||||
|
||||
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
|
||||
|
||||
$page->script('(async () => { await '.$wire.'.$refresh() })()');
|
||||
$page->assertSee('Selected Files ('.count($files).')');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Services\FileEncryptionService;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->service = new FileEncryptionService;
|
||||
@@ -16,6 +15,27 @@ afterEach(function () {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole decrypted content of an encrypted file.
|
||||
*/
|
||||
function decryptToString(FileEncryptionService $service, string $path, string $key): string
|
||||
{
|
||||
return implode('', iterator_to_array($service->decryptedChunks($path, $key), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk encrypted the way the uploader's browser does, built here without the service: the
|
||||
* nonce is prefix, index and last-chunk flag; the tag follows the ciphertext.
|
||||
*/
|
||||
function browserChunk(string $plaintext, string $keyHex, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
$tag = '';
|
||||
$nonce = $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($keyHex), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
|
||||
return $ciphertext.$tag;
|
||||
}
|
||||
|
||||
test('encrypt and decrypt round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
@@ -25,14 +45,10 @@ test('encrypt and decrypt round-trip works', function () {
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect(file_exists($encryptedPath))->toBeTrue();
|
||||
expect(file_get_contents($encryptedPath))->not->toBe($content);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(file_get_contents($encryptedPath))->not->toContain($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt with wrong key fails', function () {
|
||||
@@ -41,12 +57,9 @@ test('decrypt with wrong key fails', function () {
|
||||
|
||||
file_put_contents($sourcePath, 'Secret data');
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('derive key produces consistent results', function () {
|
||||
@@ -98,78 +111,50 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$password = 'user-password';
|
||||
$salt = $this->service->generateSalt();
|
||||
$key = bin2hex($this->service->deriveKey($password, $salt));
|
||||
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt()));
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt file stream returns streamed response', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Streamed content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
||||
expect($response->headers->get('Content-Type'))->toBe('text/plain');
|
||||
expect($response->headers->get('Content-Disposition'))->toContain('test.txt');
|
||||
});
|
||||
|
||||
test('chunked file has SEALCHK1 magic header', function () {
|
||||
test('an encrypted file starts with the SEALCHK2 header and its chunk size', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
|
||||
file_put_contents($sourcePath, 'test content');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
$header = file_get_contents($encryptedPath, false, null, 0, 8);
|
||||
|
||||
expect($header)->toBe('SEALCHK1');
|
||||
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
|
||||
});
|
||||
|
||||
test('multi-chunk round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/large.bin';
|
||||
$encryptedPath = $this->tempDir.'/large.enc';
|
||||
|
||||
// Create a file larger than one 4 MB chunk (5 MB)
|
||||
$chunkSize = 4 * 1024 * 1024;
|
||||
$content = random_bytes($chunkSize + (1024 * 1024));
|
||||
$content = random_bytes(2500);
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('exact chunk boundary round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/exact.bin';
|
||||
$encryptedPath = $this->tempDir.'/exact.enc';
|
||||
|
||||
// Create a file exactly equal to one chunk (4 MB)
|
||||
$content = random_bytes(4 * 1024 * 1024);
|
||||
$content = random_bytes(2000);
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('empty file round-trip works', function () {
|
||||
@@ -179,58 +164,123 @@ test('empty file round-trip works', function () {
|
||||
file_put_contents($sourcePath, '');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe('');
|
||||
expect(filesize($encryptedPath))->toBe(19 + 16);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
|
||||
});
|
||||
|
||||
test('chunks encrypted the way the browser does decrypt to the original file', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$header = $this->service->createHeader(4);
|
||||
$noncePrefix = substr($header, 12, 7);
|
||||
$encryptedPath = $this->tempDir.'/browser.enc';
|
||||
|
||||
file_put_contents($encryptedPath, $header
|
||||
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||
.browserChunk('ef', $key, $noncePrefix, 1, true));
|
||||
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('a chunk with the wrong last-chunk flag is rejected', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$noncePrefix = random_bytes(7);
|
||||
|
||||
$this->service->decryptChunk(browserChunk('abcd', $key, $noncePrefix, 0, false), $key, $noncePrefix, 0, true);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('a file cut short at a chunk boundary fails to decrypt', function () {
|
||||
$sourcePath = $this->tempDir.'/source.bin';
|
||||
$encryptedPath = $this->tempDir.'/truncated.enc';
|
||||
|
||||
file_put_contents($sourcePath, random_bytes(3000));
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
$handle = fopen($encryptedPath, 'r+b');
|
||||
ftruncate($handle, 19 + 2 * (1000 + 16));
|
||||
fclose($handle);
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $key);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('a file with two chunks swapped fails to decrypt', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$header = $this->service->createHeader(4);
|
||||
$noncePrefix = substr($header, 12, 7);
|
||||
$encryptedPath = $this->tempDir.'/swapped.enc';
|
||||
|
||||
file_put_contents($encryptedPath, $header
|
||||
.browserChunk('efgh', $key, $noncePrefix, 1, false)
|
||||
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||
.browserChunk('ij', $key, $noncePrefix, 2, true));
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $key);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('SEALCHK1 files from before still decrypt', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$encryptedPath = $this->tempDir.'/sealchk1.enc';
|
||||
$baseNonce = random_bytes(12);
|
||||
$file = 'SEALCHK1'.pack('N', 4).$baseNonce;
|
||||
|
||||
foreach (['abcd', 'ef'] as $index => $plaintext) {
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $index);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[8 + $i] = $nonce[8 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
$file .= $tag.$ciphertext;
|
||||
}
|
||||
|
||||
file_put_contents($encryptedPath, $file);
|
||||
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('legacy format backward compatibility', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/legacy.enc';
|
||||
$content = 'Legacy encrypted content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$binaryKey = hex2bin($key);
|
||||
|
||||
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('wrong key on chunked file throws exception', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
|
||||
file_put_contents($sourcePath, 'Chunked secret data');
|
||||
file_put_contents($sourcePath, random_bytes(2500));
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('decrypt file stream with file size sets content-length header', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Content with known size';
|
||||
test('a wrapped key unwraps with its password to the same data key', function () {
|
||||
$dataKey = $this->service->generateRandomKey();
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
$wrapped = $this->service->wrapKey($dataKey, 'correct horse battery');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain', strlen($content));
|
||||
|
||||
expect($response->headers->get('Content-Length'))->toBe((string) strlen($content));
|
||||
expect($wrapped)->toStartWith('argon2id$')->not->toContain($dataKey);
|
||||
expect($this->service->unwrapKey($wrapped, 'correct horse battery'))->toBe($dataKey);
|
||||
});
|
||||
|
||||
test('a wrapped key does not unwrap with a wrong password', function () {
|
||||
$wrapped = $this->service->wrapKey($this->service->generateRandomKey(), 'correct horse battery');
|
||||
|
||||
$this->service->unwrapKey($wrapped, 'wrong horse battery');
|
||||
})->throws(RuntimeException::class, 'Unwrapping failed');
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
@@ -29,7 +32,7 @@ test('create share without password stores encryption key', function () {
|
||||
expect($share->token)->toHaveLength(16);
|
||||
expect($share->password)->toBeNull();
|
||||
expect($share->encryption_key)->not->toBeNull();
|
||||
expect($share->encryption_salt)->not->toBeNull();
|
||||
expect($share->wrapped_key)->toBeNull();
|
||||
expect($share->files)->toHaveCount(1);
|
||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||
});
|
||||
@@ -161,8 +164,8 @@ test('get decryption key returns stored key for non-password share', function ()
|
||||
expect(strlen($key))->toBe(64);
|
||||
});
|
||||
|
||||
test('get decryption key derives key for password share', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
test('get decryption key unwraps the data key of a password share, which decrypts its files', function () {
|
||||
$file = UploadedFile::fake()->createWithContent('file.txt', 'the secret contents');
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
@@ -172,8 +175,17 @@ test('get decryption key derives key for password share', function () {
|
||||
|
||||
$key = $this->service->getDecryptionKey($share, 'test-password');
|
||||
|
||||
expect($key)->not->toBeNull();
|
||||
expect(strlen($key))->toBe(64);
|
||||
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($share->files->first()), $key), false));
|
||||
expect($decrypted)->toBe('the secret contents');
|
||||
});
|
||||
|
||||
test('get decryption key derives the key of a password share created before key wrapping', function () {
|
||||
$salt = str_repeat('ab', 32);
|
||||
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
||||
|
||||
$key = $this->service->getDecryptionKey($share, 'old-password');
|
||||
|
||||
expect($key)->toBe(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 64));
|
||||
});
|
||||
|
||||
test('get decryption key throws for password share without password', function () {
|
||||
@@ -187,3 +199,175 @@ test('get decryption key throws for password share without password', function (
|
||||
|
||||
$this->service->getDecryptionKey($share);
|
||||
})->throws(RuntimeException::class, 'Password required');
|
||||
|
||||
test('registering a file starts a pending share with the file\'s encrypted header on disk', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
|
||||
$file = $this->service->registerFile(null, 'report.pdf', 10, 'reports/report.pdf');
|
||||
|
||||
expect($file->share->isCompleted())->toBeFalse();
|
||||
expect($file->share->total_size)->toBe(10);
|
||||
expect($file->relative_path)->toBe('reports/report.pdf');
|
||||
expect($file->completed_at)->toBeNull();
|
||||
expect(file_get_contents($this->service->storedFilePath($file), false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 4));
|
||||
});
|
||||
|
||||
test('a second registered file joins the same pending share and adds its size', function () {
|
||||
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||
|
||||
$second = $this->service->registerFile($first->share, 'two.txt', 20, null);
|
||||
|
||||
expect($second->share_id)->toBe($first->share_id);
|
||||
expect($second->share->total_size)->toBe(30);
|
||||
});
|
||||
|
||||
test('a file larger than the admin file size limit is rejected', function () {
|
||||
Setting::set('max_file_size', 5 * 1024 * 1024);
|
||||
|
||||
expect(fn () => $this->service->registerFile(null, 'big.iso', 6 * 1024 * 1024, null))
|
||||
->toThrow(ValidationException::class, '"big.iso" is too large (6 MB). Maximum file size is 5 MB.');
|
||||
expect(Share::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a file beyond the admin limit of files per share is rejected', function () {
|
||||
Setting::set('max_files_per_share', 1);
|
||||
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||
|
||||
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||
->toThrow(ValidationException::class, 'Too many files. Maximum 1 files allowed per share.');
|
||||
expect(ShareFile::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a file that takes the share beyond the admin size per share is rejected', function () {
|
||||
Setting::set('max_size_per_share', 25);
|
||||
$first = $this->service->registerFile(null, 'one.txt', 20, null);
|
||||
|
||||
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
|
||||
});
|
||||
|
||||
test('a file that does not fit the storage quota beside files still uploading is rejected', function () {
|
||||
Setting::set('max_storage_quota', 100);
|
||||
Share::factory()->pending()->create(['total_size' => 60]);
|
||||
|
||||
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null))
|
||||
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
|
||||
});
|
||||
|
||||
test('chunks stored in order complete the file', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$afterFirst = $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false));
|
||||
$afterLast = $this->service->storeChunk($file->refresh(), 1, encryptedChunk($file, 'ef', 1, true));
|
||||
|
||||
expect([$afterFirst, $afterLast])->toBe([1, 2]);
|
||||
expect($file->refresh()->completed_at)->not->toBeNull();
|
||||
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($file), $file->share->encryption_key), false));
|
||||
expect($decrypted)->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('a chunk stored a second time is counted once', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
$this->service->storeChunk($file, 0, $chunk);
|
||||
|
||||
$uploadedChunks = $this->service->storeChunk($file, 0, $chunk);
|
||||
|
||||
expect($uploadedChunks)->toBe(1);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('a chunk with the wrong length is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abc', 0, false)))
|
||||
->toThrow(InvalidArgumentException::class, 'wrong length');
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk that fails authentication is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
$chunk[0] = $chunk[0] ^ "\x01";
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 0, $chunk))
|
||||
->toThrow(InvalidArgumentException::class, 'failed authentication');
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk beyond the end of the file is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 1, encryptedChunk($file, 'abcd', 1, true)))
|
||||
->toThrow(InvalidArgumentException::class, 'beyond the end');
|
||||
});
|
||||
|
||||
test('the MIME type is detected from the first chunk\'s content', function () {
|
||||
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
|
||||
$file = $this->service->registerFile(null, 'photo.bin', strlen($png), null);
|
||||
|
||||
$this->service->storeChunk($file, 0, encryptedChunk($file, $png, 0, true));
|
||||
|
||||
expect($file->refresh()->mime_type)->toBe('image/png');
|
||||
});
|
||||
|
||||
test('a chunk for a removed file is rejected', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||
$this->service->removeFile($file);
|
||||
|
||||
$this->service->storeChunk($file, 0, $chunk);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('removing a file deletes it and gives its size back', function () {
|
||||
$keep = $this->service->registerFile(null, 'keep.txt', 10, null);
|
||||
$remove = $this->service->registerFile($keep->share, 'remove.txt', 20, null);
|
||||
$path = $this->service->storedFilePath($remove);
|
||||
|
||||
$this->service->removeFile($remove);
|
||||
|
||||
expect($keep->share->refresh()->total_size)->toBe(10);
|
||||
expect(file_exists($path))->toBeFalse();
|
||||
$this->assertModelMissing($remove);
|
||||
});
|
||||
|
||||
test('completing a share with a file still uploading is rejected', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
expect(fn () => $this->service->completeShare($file->share))
|
||||
->toThrow(ValidationException::class, 'Wait until every file has finished uploading, or remove the ones that failed.');
|
||||
expect($file->share->refresh()->isCompleted())->toBeFalse();
|
||||
});
|
||||
|
||||
test('completing a share without files is rejected', function () {
|
||||
$share = Share::factory()->pending()->create();
|
||||
|
||||
expect(fn () => $this->service->completeShare($share))
|
||||
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
|
||||
});
|
||||
|
||||
test('completing a share with a password wraps its data key instead of storing it', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
$this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, true));
|
||||
$dataKey = $file->share->encryption_key;
|
||||
|
||||
$share = $this->service->completeShare($file->share, ['password' => 'a-long-password']);
|
||||
|
||||
expect($share->refresh()->isCompleted())->toBeTrue();
|
||||
expect($share->encryption_key)->toBeNull();
|
||||
expect(app(FileEncryptionService::class)->unwrapKey($share->wrapped_key, 'a-long-password'))->toBe($dataKey);
|
||||
});
|
||||
|
||||
test('create share stores an empty file', function () {
|
||||
$share = $this->service->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('empty.txt', ''), 'relativePath' => null],
|
||||
]);
|
||||
|
||||
expect($share->files->first()->completed_at)->not->toBeNull();
|
||||
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
|
||||
});
|
||||
|
||||
+5
-5
@@ -273,7 +273,7 @@
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-deployed-code"/></svg></span>
|
||||
<h3>One Docker image</h3>
|
||||
<p>FrankenPHP with Laravel Octane, automatic TLS and SQLite — no separate database.</p>
|
||||
<p>FrankenPHP with Laravel Octane, optional automatic TLS and SQLite — no separate database.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,7 +543,7 @@
|
||||
<h2 class="headline" id="install-title">Running in a few minutes</h2>
|
||||
<p class="lede">All you need is a server with Docker and a domain name.</p>
|
||||
<ul class="install__list">
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>The image includes the web server with automatic TLS certificates, and uses SQLite — no separate database.</span></li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>The image includes the web server, with automatic TLS certificates if you want them, and uses SQLite — no separate database.</span></li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Migrations run on start. Open your domain and the setup wizard creates the first admin account.</span></li>
|
||||
<li><svg class="icon" aria-hidden="true"><use href="#i-check"/></svg><span>Your files and database live in Docker volumes on your server.</span></li>
|
||||
</ul>
|
||||
@@ -558,7 +558,7 @@ cp docker-compose.example.yml docker-compose.yml
|
||||
<span class="comment"># Generate an app key and paste it into docker-compose.yml</span>
|
||||
docker run --rm gitea.nonameweb.ch/nonameweb/sealshare:latest php artisan key:generate --show
|
||||
|
||||
<span class="comment"># Edit docker-compose.yml — set APP_KEY, APP_URL, and SERVER_NAME</span>
|
||||
<span class="comment"># Edit docker-compose.yml — set APP_KEY and APP_URL; AUTO_HTTPS and SERVER_NAME for automatic TLS</span>
|
||||
<span class="comment"># Then start:</span>
|
||||
docker compose up -d</code></pre>
|
||||
</div>
|
||||
@@ -574,8 +574,8 @@ docker compose up -d</code></pre>
|
||||
<details>
|
||||
<summary>Is SealShare end-to-end encrypted?<svg class="icon" aria-hidden="true"><use href="#i-expand-more"/></svg></summary>
|
||||
<div class="faq__answer">
|
||||
<p>No. SealShare encrypts files on your server as they arrive, with AES-256-GCM in chunks, and stores them only in encrypted form. Because the server does the encrypting, it handles the files in the clear while they are uploaded and downloaded — which is why it matters that the server is yours.</p>
|
||||
<p>For a share without a password the key is kept in SealShare's database. With a share password the key is derived from that password and never stored, so the files cannot be decrypted without it.</p>
|
||||
<p>No. The uploader's browser encrypts each file in chunks with AES-256-GCM before sending it, and SealShare stores the files only in encrypted form. But the key comes from your server, which checks every chunk and decrypts the files again for downloads — so the server can read them, which is why it matters that the server is yours.</p>
|
||||
<p>For a share without a password the key is kept in SealShare's database. With a share password the key is locked with that password and never stored as it is, so the files cannot be decrypted without it.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
|
||||
Reference in New Issue
Block a user