diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index e29593e..f5ece64 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; use ZipStream\CompressionMethod; +use ZipStream\OperationMode; use ZipStream\ZipStream; class DownloadController extends Controller @@ -24,7 +25,9 @@ class DownloadController extends Controller /** * 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. The - * private text is left out: it is only ever shown on the page. + * archive's size is worked out from the files' sizes first, so the browser shows its progress + * and notices a download cut short. The private text is left out: it is only ever shown on the + * page. */ public function download(Request $request, Share $share): StreamedResponse { @@ -38,45 +41,40 @@ class DownloadController extends Controller // Counted before the body streams: the session is saved by then. abort_unless($this->shareService->claimDownload($share, $request->session()), 404); - return new StreamedResponse(function () use ($share, $key): void { - $zip = new ZipStream( - defaultCompressionMethod: CompressionMethod::STORE, - defaultEnableZeroHeader: true, - sendHttpHeaders: false, - flushOutput: true, + $zip = new ZipStream( + operationMode: OperationMode::SIMULATE_STRICT, + defaultCompressionMethod: CompressionMethod::STORE, + defaultEnableZeroHeader: true, + sendHttpHeaders: false, + flushOutput: true, + ); + + foreach ($share->files as $file) { + $zip->addFileFromCallback( + fileName: $this->archiveName($file), + exactSize: $file->file_size, + callback: fn (): PumpStream => $this->decryptedStream($share, $file, $key), ); + } - foreach ($share->files as $file) { - $chunks = $this->encryptionService->decryptedChunks( - Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)), - $key, - ); + $size = $zip->finish(); - $zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false { - while ($chunks->valid() && $chunks->current() === '') { - $chunks->next(); - } + return new StreamedResponse(function () use ($zip): void { + // A large share takes longer than any request limit to send. + set_time_limit(0); - if (! $chunks->valid()) { - return false; - } - - $chunk = $chunks->current(); - $chunks->next(); - - return $chunk; - })); - } - - $zip->finish(); + $zip->executeSimulation(); }, 200, [ 'Content-Type' => 'application/zip', + 'Content-Length' => $size, 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'), ]); } /** - * Download a single file; never the private text, which is only ever shown on the page. + * Download a single file; never the private text, which is only ever shown on the page. A + * `Range` request gets the bytes from where an interrupted download stopped, so the browser, + * or the page's folder download, resumes it instead of starting over. */ public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse { @@ -85,30 +83,100 @@ class DownloadController extends Controller $key = $this->shareService->sessionDecryptionKey($share, $request->session()); + $size = $shareFile->file_size; + $etag = '"'.hash('xxh128', $shareFile->stored_path).'"'; + [$start, $end] = $this->requestedRange($request, $size, $etag) ?? [0, $size - 1]; + abort_unless($this->shareService->claimDownload($share, $request->session()), 404); $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); - $mimeType = $shareFile->mime_type ?? 'application/octet-stream'; + $partial = $start > 0 || $end < $size - 1; $headers = [ - 'Content-Type' => $mimeType, + 'Content-Type' => $shareFile->mime_type ?? 'application/octet-stream', 'Content-Disposition' => HeaderUtils::makeDisposition( 'attachment', $shareFile->original_name, 'download', ), + 'Content-Length' => max($end - $start + 1, 0), + 'Accept-Ranges' => 'bytes', + 'ETag' => $etag, ]; - if ($shareFile->file_size !== null) { - $headers['Content-Length'] = $shareFile->file_size; + if ($partial) { + $headers['Content-Range'] = "bytes {$start}-{$end}/{$size}"; } - return new StreamedResponse(function () use ($encryptedPath, $key): void { - foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) { + return new StreamedResponse(function () use ($encryptedPath, $key, $start, $end): void { + set_time_limit(0); + + $remaining = $end - $start + 1; + + foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key, $start) as $chunk) { + if (strlen($chunk) >= $remaining) { + echo substr($chunk, 0, $remaining); + flush(); + + return; + } + echo $chunk; flush(); + $remaining -= strlen($chunk); } - }, 200, $headers); + }, $partial ? 206 : 200, $headers); + } + + /** + * The first and last byte a single `Range` request asks for, or null to send the whole file: + * without a range, with one this does not read (several ranges, another unit), or with an + * `If-Range` naming another version of the file. A range wholly past the end is refused. + * + * @return array{0: int, 1: int}|null + */ + private function requestedRange(Request $request, int $size, string $etag): ?array + { + $range = (string) $request->header('Range'); + + if ($size === 0 || ! preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches) || $matches[1].$matches[2] === '' + || ($request->hasHeader('If-Range') && $request->header('If-Range') !== $etag)) { + return null; + } + + [$start, $end] = $matches[1] === '' + ? [max($size - (int) $matches[2], 0), $size - 1] + : [(int) $matches[1], $matches[2] === '' ? $size - 1 : min((int) $matches[2], $size - 1)]; + + abort_if($start > $end, 416, headers: ['Content-Range' => "bytes */{$size}"]); + + return [$start, $end]; + } + + /** + * A file's decrypted content as a stream ZipStream reads from. + */ + private function decryptedStream(Share $share, ShareFile $file, string $key): PumpStream + { + $chunks = $this->encryptionService->decryptedChunks( + Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)), + $key, + ); + + return new PumpStream(function () use ($chunks): string|false { + while ($chunks->valid() && $chunks->current() === '') { + $chunks->next(); + } + + if (! $chunks->valid()) { + return false; + } + + $chunk = $chunks->current(); + $chunks->next(); + + return $chunk; + }); } /** diff --git a/app/Livewire/ShareDownload.php b/app/Livewire/ShareDownload.php index fe35c06..1e28539 100644 --- a/app/Livewire/ShareDownload.php +++ b/app/Livewire/ShareDownload.php @@ -85,9 +85,16 @@ class ShareDownload extends Component public function render(): mixed { $shareService = app(ShareService::class); + $files = $this->share->files->where('is_text', false)->values(); return view('livewire.share-download', [ - 'files' => $this->share->files->where('is_text', false)->values(), + 'files' => $files, + 'downloads' => $files->map(fn ($file): array => [ + 'id' => $file->id, + 'path' => $file->relative_path ?: $file->original_name, + 'size' => $file->file_size, + 'url' => route('share.download.file', [$this->share, $file]), + ]), 'hasText' => $this->share->files->contains('is_text', true), 'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()), 'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null, diff --git a/app/Services/FileEncryptionService.php b/app/Services/FileEncryptionService.php index aeec836..a4bd793 100644 --- a/app/Services/FileEncryptionService.php +++ b/app/Services/FileEncryptionService.php @@ -200,20 +200,35 @@ class FileEncryptionService } /** - * The decrypted content of a file in any of the three formats, chunk by chunk. + * The decrypted content of a file in any of the three formats, chunk by chunk, from byte + * `$offset` of the plaintext on. A `SEALCHK2` file starts at the chunk holding that byte; the + * older formats are decrypted from their start and the bytes before it dropped. * * @return Generator */ - public function decryptedChunks(string $encryptedPath, string $key): Generator + public function decryptedChunks(string $encryptedPath, string $key, int $offset = 0): 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); + yield from $this->decryptChunks($encryptedPath, $key, $offset); + + return; + } + + $chunks = $magic === self::LEGACY_CHUNKED_MAGIC + ? $this->decryptLegacyChunks($encryptedPath, $key) + : [$this->decryptLegacy($encryptedPath, $key)]; + + foreach ($chunks as $chunk) { + if ($offset >= strlen($chunk)) { + $offset -= strlen($chunk); + + continue; + } + + yield substr($chunk, $offset); + $offset = 0; } } @@ -251,7 +266,7 @@ class FileEncryptionService * * @return Generator */ - private function decryptChunks(string $encryptedPath, string $key): Generator + private function decryptChunks(string $encryptedPath, string $key, int $offset = 0): Generator { $handle = fopen($encryptedPath, 'rb'); @@ -270,10 +285,16 @@ class FileEncryptionService throw new RuntimeException('Invalid encrypted file: no chunks'); } - for ($index = 0; $index < $chunkCount; $index++) { - $chunk = (string) fread($handle, $storedChunkSize); + $firstIndex = min(intdiv($offset, max($chunkSize, 1)), $chunkCount - 1); + $skip = $offset - $firstIndex * $chunkSize; + fseek($handle, $this->chunkOffset($firstIndex, $chunkSize)); - yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1); + for ($index = $firstIndex; $index < $chunkCount; $index++) { + $chunk = (string) fread($handle, $storedChunkSize); + $plaintext = $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1); + + yield $skip > 0 ? substr($plaintext, $skip) : $plaintext; + $skip = 0; } } finally { fclose($handle); diff --git a/app/Services/ShareService.php b/app/Services/ShareService.php index 8dbb503..8095a9e 100644 --- a/app/Services/ShareService.php +++ b/app/Services/ShareService.php @@ -393,8 +393,10 @@ class ShareService /** * When this session's download window for a share ends, or null while it has none open. The - * window opens with the session's counted download; until it ends, the session may start more - * downloads of the share without counting them, even once the share has reached its limit. + * window opens with the session's counted download and each download started in it keeps it + * open, so downloading a large share file by file never outlasts it; until it ends, the session + * may start more downloads of the share without counting them, even once the share has + * reached its limit. */ public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface { @@ -418,6 +420,8 @@ class ShareService public function claimDownload(Share $share, Session $session): bool { if ($this->downloadWindowEndsAt($share, $session) !== null) { + $session->put($this->downloadSessionKey($share), now()->getTimestamp()); + return true; } @@ -436,7 +440,7 @@ class ShareService } /** - * The session key holding when this session's download of a share was counted. + * The session key holding when this session last started a download of a share. */ private function downloadSessionKey(Share $share): string { diff --git a/resources/js/app.js b/resources/js/app.js index efa5434..16cad2b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -2,3 +2,4 @@ import '../../vendor/nonameweb/livewire-material/resources/js/material.js' import './share-created.js' import './share-uploader.js' +import './share-downloader.js' diff --git a/resources/js/share-downloader.js b/resources/js/share-downloader.js new file mode 100644 index 0000000..a496a34 --- /dev/null +++ b/resources/js/share-downloader.js @@ -0,0 +1,247 @@ +/** + * `shareDownloader`: the download page's file list. It remembers, in this browser, which files the + * recipient has downloaded, and where the browser can write to a folder (the File System Access + * API: Chrome and Edge on a computer) it downloads every file into the folder the recipient picks, + * one at a time, in its folder path. + * + * One request per file keeps every request short enough for a proxy in front of the server. A + * request that breaks off is retried after 1, 2, 4, 8 and 16 seconds from the byte the file on + * disk has reached (an HTTP Range request); a file already there at its full size is skipped, so + * running it again after a stop picks up where it stopped. A file that still fails is listed for + * the Retry button; a share that is gone (404) stops the run. + */ +const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000] + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] + +function formatSize(bytes) { + let unit = 0 + + while (bytes >= 1024 && unit < UNITS.length - 1) { + bytes /= 1024 + unit++ + } + + return `${bytes.toFixed(unit === 0 ? 0 : 1)} ${UNITS[unit]}` +} + +function readStored(key) { + try { + return JSON.parse(window.localStorage.getItem(key)) ?? [] + } catch { + return [] + } +} + +/** The file's handle under the picked folder, creating its subfolders; never a path out of it. */ +async function fileHandle(directory, path) { + const segments = path.split('/').filter((segment) => segment !== '' && segment !== '.' && segment !== '..') + const name = segments.pop() + + for (const segment of segments) { + directory = await directory.getDirectoryHandle(segment, { create: true }) + } + + return directory.getFileHandle(name, { create: true }) +} + +document.addEventListener('alpine:init', () => { + window.Alpine.data('shareDownloader', ({ token, files, messages }) => { + const storageKey = `sealshare.downloaded.${token}` + const total = files.reduce((sum, file) => sum + file.size, 0) + + // Kept out of Alpine's reactive data: a proxied native handle throws on its own methods. + let directory = null + let controller = null + + return { + supported: window.isSecureContext && 'showDirectoryPicker' in window, + + /** The ids of the files this browser has downloaded. */ + done: readStored(storageKey), + + running: false, + + /** Bytes of the run's files on disk, for the progress bar. */ + received: 0, + + /** How many of the run's files are in the folder. */ + saved: 0, + + /** The file on its way. */ + current: null, + + /** The files that failed, kept for the Retry button. */ + failed: [], + + /** Why the run ended early, if it did. */ + error: null, + + finished: false, + + get progress() { + return total === 0 ? 100 : (this.received / total) * 100 + }, + + get status() { + return messages.progress + .replace(':saved', this.saved) + .replace(':count', files.length) + .replace(':received', formatSize(this.received)) + .replace(':total', formatSize(total)) + }, + + isDone(id) { + return this.done.includes(id) + }, + + markDone(id) { + if (this.done.includes(id)) { + return + } + + this.done.push(id) + + try { + window.localStorage.setItem(storageKey, JSON.stringify(this.done)) + } catch { + // Private mode or storage blocked: the marks last as long as the page. + } + }, + + async downloadAll() { + try { + directory = await window.showDirectoryPicker({ id: 'sealshare', mode: 'readwrite' }) + } catch { + return + } + + this.run(files) + }, + + retry() { + this.run(this.failed) + }, + + cancel() { + controller?.abort() + }, + + async run(list) { + controller = new AbortController() + this.running = true + this.finished = false + this.error = null + this.failed = [] + this.saved = files.length - list.length + this.received = total - list.reduce((sum, file) => sum + file.size, 0) + this.downloaded = true + + for (const file of list) { + const before = this.received + this.current = file.path + + try { + await this.save(file, controller.signal) + this.saved++ + this.markDone(file.id) + } catch (error) { + if (controller.signal.aborted) { + break + } + + if (error.status === 404) { + this.error = messages.gone + break + } + + this.failed.push(file) + this.received = before + file.size + } + } + + this.running = false + this.current = null + this.finished = ! controller.signal.aborted + }, + + async save(file, signal) { + const handle = await fileHandle(directory, file.path) + const before = this.received + // ponytail: a file is matched by name and size only, so a different file of the same name and size already in the picked folder is kept; compare a checksum if that ever bites. + let written = (await handle.getFile()).size + + if (written > file.size) { + written = 0 + } + + for (let attempt = 0; written < file.size; attempt++) { + try { + written = await this.fetchInto(handle, file, written, signal, (bytes) => (this.received = before + bytes)) + } catch (error) { + if (signal.aborted || error.status === 404 || attempt >= RETRY_DELAYS.length) { + throw error + } + + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt])) + written = (await handle.getFile()).size + } + } + + this.received = before + file.size + }, + + /** Write the file from byte `offset` on; the bytes that arrived are kept even when the connection breaks. */ + async fetchInto(handle, file, offset, signal, onProgress) { + const response = await fetch(file.url, { + headers: offset > 0 ? { Range: `bytes=${offset}-` } : {}, + signal, + }) + + if (! response.ok) { + throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }) + } + + if (response.status !== 206) { + offset = 0 + } + + const writable = await handle.createWritable({ keepExistingData: offset > 0 }) + const reader = response.body.getReader() + + try { + await writable.truncate(offset) + await writable.seek(offset) + + for (;;) { + const { done, value } = await reader.read() + + if (done) { + break + } + + await writable.write(value) + offset += value.length + onProgress(offset) + } + } finally { + // Closing commits what was written, so a retry continues from there. + await writable.close().catch(() => {}) + } + + if (offset < file.size) { + throw new Error('The download ended early') + } + + return offset + }, + + warnBeforeLeaving(event) { + if (this.running) { + event.preventDefault() + event.returnValue = '' + } + }, + } + }) +}) diff --git a/resources/js/share-uploader.js b/resources/js/share-uploader.js index c186037..5101611 100644 --- a/resources/js/share-uploader.js +++ b/resources/js/share-uploader.js @@ -53,7 +53,8 @@ document.addEventListener('alpine:init', () => { }, choose(event) { - this.add([...event.target.files].map((file) => ({ file, path: null }))) + // A chosen folder's files carry their path from it, as a dropped folder's do. + this.add([...event.target.files].map((file) => ({ file, path: file.webkitRelativePath || null }))) event.target.value = '' }, diff --git a/resources/views/livewire/file-uploader.blade.php b/resources/views/livewire/file-uploader.blade.php index a352cff..d710fec 100644 --- a/resources/views/livewire/file-uploader.blade.php +++ b/resources/views/livewire/file-uploader.blade.php @@ -45,9 +45,14 @@

{{ __('or click to browse') }}

- {{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}} - + {{-- Each button is a tab stop and opens the browser's own picker, for files or for a folder with + everything in it; the inputs only carry the selection. --}} + + + + + diff --git a/resources/views/livewire/share-download.blade.php b/resources/views/livewire/share-download.blade.php index adcbb66..fb2fdd6 100644 --- a/resources/views/livewire/share-download.blade.php +++ b/resources/views/livewire/share-download.blade.php @@ -73,8 +73,27 @@ @endif @if ($files->isNotEmpty()) - - + {{-- Which files this browser has downloaded is remembered per share (resources/js/share-downloader.js), + and where the browser can write to a folder, "Download all to a folder" fetches them one at a + time, each resumable, which a single ZIP of a large share is not. --}} + + @foreach ($files as $file) {{ Number::fileSize($file->file_size) }} - + + + + + + @endforeach - +
+ + + + + + +

+

{{ __('All files are in the folder.') }}

+
+
+ +
+ +
+ +
+ + {{ __('Files that could not be downloaded:') }} + + +
+ + @if ($files->count() > 1) - + + + @else - + @endif
diff --git a/tests/Browser/ShareFlowTest.php b/tests/Browser/ShareFlowTest.php index b293a8d..c2d7063 100644 --- a/tests/Browser/ShareFlowTest.php +++ b/tests/Browser/ShareFlowTest.php @@ -242,3 +242,58 @@ test('the admin settings page has six sections, each a card headed by an h2', fu expect($result['cardCount'])->toBe(6); expect($result['everyCardHeaded'])->toBeTrue(); }); + +test('download all to a folder writes every file in its folder path, resumes a partial file and marks them downloaded', function () { + config(['uploads.chunk_size' => 1000]); + $binary = random_bytes(2500); + $share = app(ShareService::class)->createShare([ + ['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello folder download'), 'relativePath' => null], + ['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'], + ]); + + $page = ready(visit(route('share.download', $share, false))); + + // The browser's private file system stands in for the folder the recipient would pick; notes.txt + // is already half there, as a stopped run leaves it. + $page->script("(async () => { + const root = await navigator.storage.getDirectory(); + for await (const name of root.keys()) { await root.removeEntry(name, { recursive: true }); } + const partial = await (await root.getFileHandle('notes.txt', { create: true })).createWritable(); + await partial.write('hello '); + await partial.close(); + window.showDirectoryPicker = () => Promise.resolve(root); + Alpine.\$data(document.querySelector('[data-test=download-to-folder]')).supported = true; + })()"); + + $page->click('[data-test="download-to-folder"]'); + $page->assertScript("Alpine.\$data(document.querySelector('[data-test=folder-download]')).finished === true"); + + $files = $page->script("(async () => { + const root = await navigator.storage.getDirectory(); + const notes = await (await root.getFileHandle('notes.txt')).getFile(); + const photo = await (await (await root.getDirectoryHandle('holiday')).getFileHandle('photo.bin')).getFile(); + const bytes = new Uint8Array(await photo.arrayBuffer()); + return { notes: await notes.text(), photo: btoa(String.fromCharCode(...bytes)) }; + })()"); + + expect($files['notes'])->toBe('hello folder download'); + expect(base64_decode($files['photo']))->toBe($binary); + $page->assertScript("[...document.querySelectorAll('[data-test=file-downloaded]')].filter((mark) => mark.offsetParent !== null).length === 2") + ->assertNoJavaScriptErrors(); +}); + +test('Browse Folder picks a whole folder and keeps each file\'s path in it', function () { + $page = ready(visit('/upload')); + + $page->assertScript("document.querySelector('[data-test=folder-input]').webkitdirectory === true"); + + // A folder picker's files, as the browser hands them over: each with its path from the folder. + $page->script("(() => { + const file = new File(['inside the folder'], 'notes.txt', { type: 'text/plain' }); + Object.defineProperty(file, 'webkitRelativePath', { value: 'holiday/docs/notes.txt' }); + Alpine.\$data(document.querySelector('[data-test=drop-zone]')).choose({ target: { files: [file], value: '' } }); + })()"); + + $page->assertSee('holiday/docs/notes.txt') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/ShareDownloadTest.php b/tests/Feature/ShareDownloadTest.php index 3998966..8817086 100644 --- a/tests/Feature/ShareDownloadTest.php +++ b/tests/Feature/ShareDownloadTest.php @@ -18,6 +18,15 @@ test('share download page renders for valid share', function () { $response->assertOk(); }); +test('share download page says how many files the share holds and their size', function () { + Storage::fake('shares'); + $share = createShareWithFiles(); + + $response = $this->get(route('share.download', $share)); + + $response->assertSee('3 files, 31 B'); +}); + test('share download page returns 404 for expired share', function () { $share = Share::factory()->expired()->create(); @@ -100,7 +109,10 @@ test('zip download streams a valid archive with every file\'s original content', ]); $zipPath = tempnam(sys_get_temp_dir(), 'zip'); - file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent()); + $response = $this->get(route('share.download.all', $share)); + file_put_contents($zipPath, $content = $response->streamedContent()); + + $response->assertHeader('Content-Length', strlen($content)); $zip = new ZipArchive; expect($zip->open($zipPath))->toBeTrue(); @@ -111,6 +123,46 @@ test('zip download streams a valid archive with every file\'s original content', unlink($zipPath); }); +test('a range request gets the file from that byte on, decrypted across chunks', function (string $range, int $start, int $end) { + Storage::fake('shares'); + config(['uploads.chunk_size' => 1000]); + $binary = random_bytes(2500); + $share = app(ShareService::class)->createShare([ + ['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => null], + ]); + + $response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => $range]); + + $response->assertStatus(206) + ->assertHeader('Content-Range', "bytes {$start}-{$end}/2500") + ->assertHeader('Content-Length', $end - $start + 1); + expect($response->streamedContent())->toBe(substr($binary, $start, $end - $start + 1)); +})->with([ + 'to the end' => ['bytes=1500-', 1500, 2499], + 'across a chunk boundary' => ['bytes=999-1000', 999, 1000], + 'the last bytes' => ['bytes=-10', 2490, 2499], +]); + +test('a range request for another version of the file gets the whole file', function () { + Storage::fake('shares'); + $share = createShareWithFile(content: 'whole file'); + + $response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=6-', 'If-Range' => '"other"']); + + $response->assertOk()->assertHeader('Accept-Ranges', 'bytes'); + expect($response->streamedContent())->toBe('whole file'); +}); + +test('a range past the end of the file is refused and uses no download', function () { + Storage::fake('shares'); + $share = createShareWithFile(content: 'short'); + + $response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=5-']); + + $response->assertStatus(416)->assertHeader('Content-Range', 'bytes */5'); + expect($share->fresh()->download_count)->toBe(0); +}); + test('the last download streams and the share stays for the cleanup, with the time of that download', function () { Storage::fake('shares'); $this->freezeSecond(); @@ -170,6 +222,20 @@ test('the recipient who took the last download can no longer open the share an h 'download one file' => 'share.download.file', ]); +test('each download keeps the recipient\'s window open', function () { + Storage::fake('shares'); + $share = createShareWithFiles(maxDownloads: 1); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->travel(50)->minutes(); + $this->get(route('share.download.file', [$share, $share->files->first()])); + $this->travel(50)->minutes(); + + $response = $this->get(route('share.download.file', [$share, $share->files->last()])); + + $response->assertOk(); + expect($share->fresh()->download_count)->toBe(1); +}); + test('a recipient whose window has ended uses another download when one is left', function () { Storage::fake('shares'); $share = createShareWithFiles(maxDownloads: 2); @@ -368,7 +434,10 @@ test('the zip of a mixed share has only the files, not the private text', functi ); $zipPath = tempnam(sys_get_temp_dir(), 'zip'); - file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent()); + $response = $this->get(route('share.download.all', $share)); + file_put_contents($zipPath, $content = $response->streamedContent()); + + $response->assertHeader('Content-Length', strlen($content)); $zip = new ZipArchive; expect($zip->open($zipPath))->toBeTrue();