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>
189 lines
10 KiB
PHP
189 lines
10 KiB
PHP
<x-page brand>
|
|
{{-- 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
|
|
{{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}}
|
|
<x-form
|
|
x-on:submit.prevent="submit()"
|
|
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.'),
|
|
'textFailed' => __('The text could not be sent. Try 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="secure ? 'false' : 'true'"
|
|
x-on:dragover.prevent="dragging = true"
|
|
x-on:dragleave.prevent="dragging = false"
|
|
x-on:drop.prevent="handleDrop($event)"
|
|
data-test="drop-zone"
|
|
>
|
|
<x-stack align="center" gap="space200">
|
|
<div class="upload-drop-shapes">
|
|
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
|
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
|
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
|
</div>
|
|
|
|
<x-stack align="center" gap="space50">
|
|
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
|
</x-stack>
|
|
|
|
{{-- 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. --}}
|
|
<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" webkitdirectory hidden x-ref="folderPicker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="folder-input" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
{{-- 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="cancel()" />
|
|
</x-row>
|
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
@error('files')
|
|
<x-alert color="error">{{ $message }}</x-alert>
|
|
@enderror
|
|
|
|
{{-- Selected files --}}
|
|
@if ($pendingFiles->isNotEmpty())
|
|
<x-stack gap="space100">
|
|
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
|
|
|
<div class="upload-file-list">
|
|
<x-list segmented :label="__('Selected Files')">
|
|
@foreach ($pendingFiles as $file)
|
|
<x-list-item
|
|
:title="$file->relative_path ?? $file->original_name"
|
|
icon="description"
|
|
wire:key="selected-file-{{ $file->id }}"
|
|
data-test="selected-file"
|
|
>
|
|
<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>
|
|
<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
|
|
</x-list>
|
|
</div>
|
|
</x-stack>
|
|
@endif
|
|
|
|
{{-- Private text: bound in Alpine only, never to the component, so it never reaches the server
|
|
unencrypted; it is sent with the share (submit()). The limit is in UTF-8 bytes, as the server counts. --}}
|
|
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
|
<x-stack gap="space100">
|
|
<x-textarea
|
|
full
|
|
x-model="text"
|
|
:label="__('Text')"
|
|
:maxlength="$maxTextBytes"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
data-test="share-text"
|
|
/>
|
|
|
|
<p class="md-type-body-sm md-tabular" x-bind:class="textBytes > {{ $maxTextBytes }} ? 'md-ink-error' : 'md-ink-variant'" data-test="share-text-size">
|
|
<span x-text="textBytes"></span> {{ __('bytes') }} / {{ Number::fileSize($maxTextBytes) }}
|
|
</p>
|
|
|
|
@error('text')
|
|
<x-alert color="error">{{ $message }}</x-alert>
|
|
@enderror
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
{{-- Options --}}
|
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
|
<x-stack gap="space200">
|
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
|
|
|
@if ($usePassword)
|
|
<x-stack gap="space100">
|
|
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
|
|
|
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
|
|
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
|
|
<x-row gap="space100" wrap>
|
|
@if ($passwordGeneratorMode !== 'off')
|
|
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
|
@endif
|
|
|
|
<x-button
|
|
:label="__('Copy')"
|
|
icon="content_copy"
|
|
variant="tonal"
|
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
|
x-bind:disabled="! $wire.password"
|
|
data-test="copy-password"
|
|
/>
|
|
</x-row>
|
|
</x-stack>
|
|
@endif
|
|
|
|
<x-select full
|
|
wire:model="expiration"
|
|
:label="__('Expiration')"
|
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
|
:options="collect(\App\Models\Share::EXPIRATIONS)->map(fn (array $option, string $id): array => ['id' => $id, 'name' => __($option['label'])])->values()->all()"
|
|
/>
|
|
|
|
<x-input full
|
|
wire:model="maxDownloads"
|
|
:label="__('Max downloads')"
|
|
type="number"
|
|
min="1"
|
|
:placeholder="__('Unlimited')"
|
|
/>
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
<x-slot:actions>
|
|
<x-button
|
|
type="submit"
|
|
:label="__('Create Share Link')"
|
|
variant="filled"
|
|
size="md"
|
|
icon="link"
|
|
spinner="createShare"
|
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }} || ({{ $pendingFiles->isEmpty() ? 'true' : 'false' }} && text.trim() === '') || textBytes > {{ $maxTextBytes }}"
|
|
data-test="create-share"
|
|
/>
|
|
</x-slot:actions>
|
|
</x-form>
|
|
@endif
|
|
</x-page>
|