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>
228 lines
12 KiB
PHP
228 lines
12 KiB
PHP
<x-pane class="admin-settings">
|
||
<x-stack gap="space300">
|
||
<h1 class="md-type-headline-md">{{ __('System Settings') }}</h1>
|
||
|
||
<x-form wire:submit="saveSettings">
|
||
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Branding')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
||
|
||
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
||
|
||
<x-stack gap="space200">
|
||
@if ($currentLogo)
|
||
<x-row gap="space200" wrap>
|
||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
|
||
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||
</x-row>
|
||
@endif
|
||
|
||
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||
|
||
@if ($siteLogo && is_object($siteLogo))
|
||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||
@else
|
||
<x-stack gap="space50">
|
||
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
|
||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
|
||
</x-stack>
|
||
@endif
|
||
@endif
|
||
</x-stack>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-stack gap="space100">
|
||
<x-password full
|
||
wire:model="systemPassword"
|
||
:label="__('System Upload Password')"
|
||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||
autocomplete="new-password"
|
||
/>
|
||
|
||
@if ($hasSystemPassword)
|
||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||
@endif
|
||
</x-stack>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
|
||
The example is drawn from the form as it stands, before saving. --}}
|
||
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-group
|
||
wire:model.live="passwordGeneratorMode"
|
||
:label="__('Password generator')"
|
||
:hint="match ($passwordGeneratorMode) {
|
||
'off' => __('Uploaders type a password themselves.'),
|
||
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
|
||
default => __('A Generate button under the password field fills in a random password.'),
|
||
}"
|
||
:options="[
|
||
['id' => 'off', 'name' => __('Off')],
|
||
['id' => 'button', 'name' => __('On request')],
|
||
['id' => 'prefill', 'name' => __('Prefilled')],
|
||
]"
|
||
/>
|
||
|
||
@if ($passwordGeneratorMode !== 'off')
|
||
<x-group
|
||
wire:model.live="passwordGeneratorType"
|
||
:label="__('Kind')"
|
||
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
|
||
:options="[
|
||
['id' => 'characters', 'name' => __('Characters')],
|
||
['id' => 'passphrase', 'name' => __('Passphrase')],
|
||
]"
|
||
/>
|
||
|
||
@if ($passwordGeneratorType === 'passphrase')
|
||
<x-input full
|
||
wire:model.live.blur="passphraseWords"
|
||
:label="__('Words')"
|
||
type="number"
|
||
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
|
||
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
|
||
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
|
||
/>
|
||
|
||
<x-select full
|
||
wire:model.live="passphraseSeparator"
|
||
:label="__('Separator')"
|
||
:options="[
|
||
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
|
||
['id' => 'dot', 'name' => __('Dot (.)')],
|
||
['id' => 'underscore', 'name' => __('Underscore (_)')],
|
||
['id' => 'space', 'name' => __('Space')],
|
||
]"
|
||
/>
|
||
@else
|
||
<x-input full
|
||
wire:model.live.blur="passwordLength"
|
||
:label="__('Length')"
|
||
type="number"
|
||
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
|
||
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
|
||
:suffix="__('characters')"
|
||
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
|
||
/>
|
||
|
||
<x-group
|
||
multiple
|
||
wire:model.live="passwordCharacterSets"
|
||
:label="__('Include')"
|
||
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
|
||
:options="[
|
||
['id' => 'uppercase', 'name' => 'A–Z'],
|
||
['id' => 'lowercase', 'name' => 'a–z'],
|
||
['id' => 'numbers', 'name' => '0–9'],
|
||
['id' => 'symbols', 'name' => '#$%'],
|
||
]"
|
||
/>
|
||
|
||
<x-checkbox
|
||
wire:model.live="passwordAvoidAmbiguous"
|
||
:label="__('Avoid look-alike characters')"
|
||
:hint="__('Leaves out 0, O, 1, l and I.')"
|
||
/>
|
||
@endif
|
||
|
||
@if ($passwordExample)
|
||
<x-input full
|
||
:label="__('Example')"
|
||
:value="$passwordExample"
|
||
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
|
||
readonly
|
||
mono
|
||
data-test="password-example"
|
||
/>
|
||
@endif
|
||
@endif
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-toggle
|
||
wire:model.live="allowNeverExpire"
|
||
:label="__('Allow shares to never expire')"
|
||
:hint="__('When disabled, users must select an expiration time.')"
|
||
right
|
||
/>
|
||
|
||
<x-select full
|
||
wire:model="defaultExpiration"
|
||
:label="__('Default Expiration')"
|
||
:placeholder="$allowNeverExpire ? __('None') : 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="maxFileSize"
|
||
:label="__('Max file size (MB)')"
|
||
type="number"
|
||
min="1"
|
||
suffix="MB"
|
||
/>
|
||
|
||
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||
|
||
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Storage')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-input full
|
||
wire:model="maxStorageQuota"
|
||
:label="__('Max storage quota (GB)')"
|
||
type="number"
|
||
min="1"
|
||
suffix="GB"
|
||
:hint="__('When reached, new uploads are blocked.')"
|
||
/>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-slot:actions>
|
||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
|
||
</x-slot:actions>
|
||
</x-form>
|
||
|
||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||
{{ __('The upload and download pages show the default mark again.') }}
|
||
|
||
<x-slot:actions>
|
||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
|
||
</x-slot:actions>
|
||
</x-modal>
|
||
|
||
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
|
||
{{ __('Anyone who can reach the upload page can upload files again.') }}
|
||
|
||
<x-slot:actions>
|
||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
||
</x-slot:actions>
|
||
</x-modal>
|
||
</x-stack>
|
||
</x-pane>
|