Download large shares file by file, resumably
A 29 GB share with 254 files failed as one ZIP: the single request ran into the 300-second request limit and, behind a proxy we cannot configure, could be cut at any time with no way to resume. Single-file downloads now answer HTTP Range requests (206, ETag and If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk holding the first byte asked for. On that, the download page's new "Download all to a folder" (File System Access API, Chrome and Edge on a computer) saves every file into a folder the recipient picks, one request per file, in its folder path; a broken connection is retried from the byte on disk, complete files are skipped, and failed files get a Retry button. Other browsers keep the ZIP and the per-file buttons. The ZIP is sized up front with ZipStream's strict simulation and sent with Content-Length, so the browser shows progress and notices a cut. Both download routes lift the time limit for their streams. The recipient's download window now restarts with each download, so a long file-by-file download never outlasts it. The download page shows the file count and total size and marks the files this browser has downloaded, and the upload page gains "Browse Folder", which keeps each file's path as a dropped folder does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f9a7839ad3
commit
b594fb2300
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
use ZipStream\CompressionMethod;
|
use ZipStream\CompressionMethod;
|
||||||
|
use ZipStream\OperationMode;
|
||||||
use ZipStream\ZipStream;
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
class DownloadController extends Controller
|
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
|
* 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
|
* 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
|
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.
|
// Counted before the body streams: the session is saved by then.
|
||||||
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($share, $key): void {
|
$zip = new ZipStream(
|
||||||
$zip = new ZipStream(
|
operationMode: OperationMode::SIMULATE_STRICT,
|
||||||
defaultCompressionMethod: CompressionMethod::STORE,
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
defaultEnableZeroHeader: true,
|
defaultEnableZeroHeader: true,
|
||||||
sendHttpHeaders: false,
|
sendHttpHeaders: false,
|
||||||
flushOutput: true,
|
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) {
|
$size = $zip->finish();
|
||||||
$chunks = $this->encryptionService->decryptedChunks(
|
|
||||||
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
|
|
||||||
$key,
|
|
||||||
);
|
|
||||||
|
|
||||||
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
|
return new StreamedResponse(function () use ($zip): void {
|
||||||
while ($chunks->valid() && $chunks->current() === '') {
|
// A large share takes longer than any request limit to send.
|
||||||
$chunks->next();
|
set_time_limit(0);
|
||||||
}
|
|
||||||
|
|
||||||
if (! $chunks->valid()) {
|
$zip->executeSimulation();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$chunk = $chunks->current();
|
|
||||||
$chunks->next();
|
|
||||||
|
|
||||||
return $chunk;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
$zip->finish();
|
|
||||||
}, 200, [
|
}, 200, [
|
||||||
'Content-Type' => 'application/zip',
|
'Content-Type' => 'application/zip',
|
||||||
|
'Content-Length' => $size,
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
|
'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
|
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());
|
$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);
|
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
|
||||||
|
|
||||||
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
|
$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 = [
|
$headers = [
|
||||||
'Content-Type' => $mimeType,
|
'Content-Type' => $shareFile->mime_type ?? 'application/octet-stream',
|
||||||
'Content-Disposition' => HeaderUtils::makeDisposition(
|
'Content-Disposition' => HeaderUtils::makeDisposition(
|
||||||
'attachment',
|
'attachment',
|
||||||
$shareFile->original_name,
|
$shareFile->original_name,
|
||||||
'download',
|
'download',
|
||||||
),
|
),
|
||||||
|
'Content-Length' => max($end - $start + 1, 0),
|
||||||
|
'Accept-Ranges' => 'bytes',
|
||||||
|
'ETag' => $etag,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($shareFile->file_size !== null) {
|
if ($partial) {
|
||||||
$headers['Content-Length'] = $shareFile->file_size;
|
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StreamedResponse(function () use ($encryptedPath, $key): void {
|
return new StreamedResponse(function () use ($encryptedPath, $key, $start, $end): void {
|
||||||
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) {
|
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;
|
echo $chunk;
|
||||||
flush();
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -85,9 +85,16 @@ class ShareDownload extends Component
|
|||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
|
$files = $this->share->files->where('is_text', false)->values();
|
||||||
|
|
||||||
return view('livewire.share-download', [
|
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),
|
'hasText' => $this->share->files->contains('is_text', true),
|
||||||
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
||||||
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
||||||
|
|||||||
@@ -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<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
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);
|
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
|
||||||
|
|
||||||
if ($magic === self::MAGIC) {
|
if ($magic === self::MAGIC) {
|
||||||
yield from $this->decryptChunks($encryptedPath, $key);
|
yield from $this->decryptChunks($encryptedPath, $key, $offset);
|
||||||
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
|
|
||||||
yield from $this->decryptLegacyChunks($encryptedPath, $key);
|
return;
|
||||||
} else {
|
}
|
||||||
yield $this->decryptLegacy($encryptedPath, $key);
|
|
||||||
|
$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<int, string>
|
* @return Generator<int, string>
|
||||||
*/
|
*/
|
||||||
private function decryptChunks(string $encryptedPath, string $key): Generator
|
private function decryptChunks(string $encryptedPath, string $key, int $offset = 0): Generator
|
||||||
{
|
{
|
||||||
$handle = fopen($encryptedPath, 'rb');
|
$handle = fopen($encryptedPath, 'rb');
|
||||||
|
|
||||||
@@ -270,10 +285,16 @@ class FileEncryptionService
|
|||||||
throw new RuntimeException('Invalid encrypted file: no chunks');
|
throw new RuntimeException('Invalid encrypted file: no chunks');
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($index = 0; $index < $chunkCount; $index++) {
|
$firstIndex = min(intdiv($offset, max($chunkSize, 1)), $chunkCount - 1);
|
||||||
$chunk = (string) fread($handle, $storedChunkSize);
|
$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 {
|
} finally {
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
@@ -393,8 +393,10 @@ class ShareService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* When this session's download window for a share ends, or null while it has none open. The
|
* 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
|
* window opens with the session's counted download and each download started in it keeps it
|
||||||
* downloads of the share without counting them, even once the share has reached its limit.
|
* 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
|
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
|
||||||
{
|
{
|
||||||
@@ -418,6 +420,8 @@ class ShareService
|
|||||||
public function claimDownload(Share $share, Session $session): bool
|
public function claimDownload(Share $share, Session $session): bool
|
||||||
{
|
{
|
||||||
if ($this->downloadWindowEndsAt($share, $session) !== null) {
|
if ($this->downloadWindowEndsAt($share, $session) !== null) {
|
||||||
|
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
|
||||||
|
|
||||||
return true;
|
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
|
private function downloadSessionKey(Share $share): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||||
import './share-created.js'
|
import './share-created.js'
|
||||||
import './share-uploader.js'
|
import './share-uploader.js'
|
||||||
|
import './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 = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,7 +53,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
choose(event) {
|
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 = ''
|
event.target.value = ''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,9 +45,14 @@
|
|||||||
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
|
||||||
{{-- 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
|
||||||
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
everything in it; the inputs only carry the selection. --}}
|
||||||
|
<x-row gap="space100" justify="center" wrap>
|
||||||
|
<x-button :label="__('Browse Files')" icon="upload_file" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
||||||
|
<x-button :label="__('Browse Folder')" icon="folder_open" variant="outlined" x-on:click="$refs.folderPicker.click()" x-bind:disabled="! secure" data-test="browse-folder" />
|
||||||
|
</x-row>
|
||||||
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
||||||
|
<input type="file" webkitdirectory hidden x-ref="folderPicker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="folder-input" />
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,27 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($files->isNotEmpty())
|
@if ($files->isNotEmpty())
|
||||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
{{-- Which files this browser has downloaded is remembered per share (resources/js/share-downloader.js),
|
||||||
<x-stack gap="space200">
|
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. --}}
|
||||||
|
<x-card
|
||||||
|
:title="__('Shared Files')"
|
||||||
|
:subtitle="trans_choice('{1} :count file, :size|[2,*] :count files, :size', $files->count(), ['size' => Number::fileSize($files->sum('file_size'))])"
|
||||||
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack
|
||||||
|
gap="space200"
|
||||||
|
x-data="shareDownloader({
|
||||||
|
token: {{ \Illuminate\Support\Js::from($share->token) }},
|
||||||
|
files: {{ \Illuminate\Support\Js::from($downloads) }},
|
||||||
|
messages: {{ \Illuminate\Support\Js::from([
|
||||||
|
'progress' => __(':saved of :count files, :received of :total'),
|
||||||
|
'gone' => __('This share is no longer available.'),
|
||||||
|
]) }},
|
||||||
|
})"
|
||||||
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||||
|
>
|
||||||
<x-list :label="__('Shared Files')">
|
<x-list :label="__('Shared Files')">
|
||||||
@foreach ($files as $file)
|
@foreach ($files as $file)
|
||||||
<x-list-item
|
<x-list-item
|
||||||
@@ -84,17 +103,47 @@
|
|||||||
>
|
>
|
||||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||||
<x-slot:end>
|
<x-slot:end>
|
||||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true" />
|
<x-row gap="space100">
|
||||||
|
<span x-show="isDone({{ $file->id }})" x-cloak data-test="file-downloaded">
|
||||||
|
<x-icon name="download_done" class="md-ink-success" :label="__('Downloaded')" />
|
||||||
|
</span>
|
||||||
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" x-on:click="downloaded = true; markDone({{ $file->id }})" />
|
||||||
|
</x-row>
|
||||||
</x-slot:end>
|
</x-slot:end>
|
||||||
</x-list-item>
|
</x-list-item>
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-list>
|
</x-list>
|
||||||
|
|
||||||
<x-row justify="end">
|
<div x-show="running || finished" x-cloak data-test="folder-download">
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<x-row justify="between">
|
||||||
|
<span class="md-type-label-lg md-tabular" x-text="status"></span>
|
||||||
|
<x-button :label="__('Cancel')" size="xs" x-show="running" x-on:click="cancel()" />
|
||||||
|
</x-row>
|
||||||
|
<x-progress bind="progress" :label="__('Downloading')" />
|
||||||
|
<p class="md-type-body-sm md-ink-variant" x-show="running" x-text="current"></p>
|
||||||
|
<p class="md-type-body-sm md-ink-success" x-show="finished && failed.length === 0">{{ __('All files are in the folder.') }}</p>
|
||||||
|
</x-stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" x-cloak>
|
||||||
|
<x-alert color="error" assertive><span x-text="error"></span></x-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="! running && failed.length > 0" x-cloak>
|
||||||
|
<x-alert color="error" assertive>
|
||||||
|
{{ __('Files that could not be downloaded:') }} <span class="md-tabular" x-text="failed.length"></span>
|
||||||
|
<x-slot:actions><x-button :label="__('Retry')" x-on:click="retry()" /></x-slot:actions>
|
||||||
|
</x-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-row justify="end" gap="space100" wrap x-show="! running">
|
||||||
@if ($files->count() > 1)
|
@if ($files->count() > 1)
|
||||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
<x-button :label="__('Download All as ZIP')" icon="folder_zip" variant="tonal" :link="route('share.download.all', $share)" no-wire-navigate x-show="supported" x-cloak x-on:click="downloaded = true" />
|
||||||
|
<x-button :label="__('Download all to a folder')" icon="download" variant="filled" x-show="supported" x-cloak x-on:click="downloadAll()" data-test="download-to-folder" />
|
||||||
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-show="! supported" x-on:click="downloaded = true" />
|
||||||
@else
|
@else
|
||||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true; markDone({{ $files->first()->id }})" />
|
||||||
@endif
|
@endif
|
||||||
</x-row>
|
</x-row>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
|||||||
@@ -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['cardCount'])->toBe(6);
|
||||||
expect($result['everyCardHeaded'])->toBeTrue();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ test('share download page renders for valid share', function () {
|
|||||||
$response->assertOk();
|
$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 () {
|
test('share download page returns 404 for expired share', function () {
|
||||||
$share = Share::factory()->expired()->create();
|
$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');
|
$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;
|
$zip = new ZipArchive;
|
||||||
expect($zip->open($zipPath))->toBeTrue();
|
expect($zip->open($zipPath))->toBeTrue();
|
||||||
@@ -111,6 +123,46 @@ test('zip download streams a valid archive with every file\'s original content',
|
|||||||
unlink($zipPath);
|
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 () {
|
test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
$this->freezeSecond();
|
$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',
|
'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 () {
|
test('a recipient whose window has ended uses another download when one is left', function () {
|
||||||
Storage::fake('shares');
|
Storage::fake('shares');
|
||||||
$share = createShareWithFiles(maxDownloads: 2);
|
$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');
|
$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;
|
$zip = new ZipArchive;
|
||||||
expect($zip->open($zipPath))->toBeTrue();
|
expect($zip->open($zipPath))->toBeTrue();
|
||||||
|
|||||||
Reference in New Issue
Block a user