Files
SealShare/resources/views/livewire/share-download.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5.5 b594fb2300 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>
2026-10-08 21:20:35 +02:00

179 lines
12 KiB
PHP

{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
iOS before Safari 18.4, which cannot position them. --}}
<x-page brand>
{{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
private text and its files, each in its own card, with the share's expiry and limit under them. --}}
@if (! $authenticated)
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to open this share')" heading="h2" variant="outlined">
<x-form wire:submit="verifyPassword">
<x-password
full
wire:model="password"
:label="__('Password')"
required
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
</x-slot:actions>
</x-form>
</x-card>
@else
{{-- A download link does not render the page again: the download limit's note switches on
the first press of any download or "Show text", and the server draws the open window on
the next visit. --}}
<x-stack gap="space200" x-data="{ downloaded: false }">
{{-- The text is fetched only when the recipient asks for it, never with the page: a link
preview must not use up a share limited to one download. It stays in Alpine and is
drawn as plain text. --}}
@if ($hasText)
<x-card :title="__('Private text')" heading="h2" variant="outlined">
<x-stack
gap="space200"
x-data="{
text: null,
async reveal() {
const text = await $wire.revealText();
if (text === null) {
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
return;
}
this.text = text;
downloaded = true;
},
}"
>
<template x-if="text !== null">
<x-stack gap="space200">
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
<x-row justify="end">
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
data-test="copy-text"
/>
</x-row>
</x-stack>
</template>
<x-row justify="end" x-show="text === null">
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
</x-row>
</x-stack>
</x-card>
@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. --}}
<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')">
@foreach ($files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<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-list-item>
@endforeach
</x-list>
<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)
<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
<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
</x-row>
</x-stack>
</x-card>
@endif
@if ($share->expires_at || $share->max_downloads)
<x-stack gap="space100">
@if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif
@if ($share->max_downloads)
@if ($hasText)
@if ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
@endif
@elseif ($downloadWindowEndsAt)
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
@elseif ($remainingDownloads > 0)
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to download the files.', ['window' => $downloadWindow]) }}</p>
@endif
@endif
</x-stack>
@endif
</x-stack>
@endif
</x-page>