Files
SealShare/resources/views/livewire/file-uploader.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 40e35bab0e Encrypt uploads in the browser and send them in chunks
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>
2026-09-16 20:49:17 +02:00

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>