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>
179 lines
10 KiB
PHP
179 lines
10 KiB
PHP
<x-pane class="upload-column">
|
|
<x-stack gap="space400">
|
|
<x-stack align="center" gap="space200">
|
|
@if ($siteLogo)
|
|
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="upload-site-logo" />
|
|
@endif
|
|
|
|
<x-stack align="center" gap="space100">
|
|
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
|
|
|
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
|
</x-stack>
|
|
</x-stack>
|
|
|
|
{{-- 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="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="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>
|
|
|
|
{{-- 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, 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
|
|
|
|
{{-- 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="[
|
|
['id' => '1h', 'name' => __('1 Hour')],
|
|
['id' => '24h', 'name' => __('24 Hours')],
|
|
['id' => '48h', 'name' => __('48 Hours')],
|
|
['id' => '7d', 'name' => __('7 Days')],
|
|
['id' => '14d', 'name' => __('14 Days')],
|
|
['id' => '30d', 'name' => __('30 Days')],
|
|
]"
|
|
/>
|
|
|
|
<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' }}"
|
|
data-test="create-share"
|
|
/>
|
|
</x-slot:actions>
|
|
</x-form>
|
|
@endif
|
|
</x-stack>
|
|
</x-pane>
|