Files
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

257 lines
10 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Setting;
use App\Services\PasswordGeneratorService;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
use NoNameWeb\LivewireMaterial\Support\Scheme;
#[Layout('layouts.app')]
class AdminSettings extends Component
{
use Toasts;
use WithFileUploads;
/** The colour profile every page, mail and error page wears (config/livewire-material.php). */
public string $colorProfile = '';
public string $systemPassword = '';
public string $defaultExpiration = '';
public int $maxFileSize = 100;
public int $maxStorageQuota = 20;
public int $maxFilesPerShare = 50;
public int $maxSizePerShare = 2;
public bool $allowNeverExpire = false;
/** How the upload page offers generated share passwords: `off`, `button` or `prefill`. */
public string $passwordGeneratorMode = 'button';
/** `characters` or `passphrase`. */
public string $passwordGeneratorType = 'characters';
public int $passwordLength = 20;
/** @var list<string> */
public array $passwordCharacterSets = [];
public bool $passwordAvoidAmbiguous = true;
public int $passphraseWords = 6;
public string $passphraseSeparator = 'hyphen';
public string $siteTitle = '';
public string $siteDescription = '';
public $siteLogo;
/** Whether the "Remove the logo?" dialog is open. */
public bool $confirmingLogoRemoval = false;
/** Whether the "Remove the system password?" dialog is open. */
public bool $confirmingPasswordRemoval = false;
public function mount(): void
{
$this->colorProfile = Scheme::profile() ?? '';
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
$this->maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024);
$this->maxStorageQuota = (int) Setting::get('max_storage_quota', 20 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
$this->maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
$this->maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024) / (1024 * 1024 * 1024);
$this->allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
$this->siteTitle = Setting::get('site_title', '') ?? '';
$this->siteDescription = Setting::get('site_description', '') ?? '';
$passwordOptions = app(PasswordGeneratorService::class)->options();
$this->passwordGeneratorMode = $passwordOptions['mode'];
$this->passwordGeneratorType = $passwordOptions['type'];
$this->passwordLength = $passwordOptions['length'];
$this->passwordCharacterSets = $passwordOptions['characterSets'];
$this->passwordAvoidAmbiguous = $passwordOptions['avoidAmbiguous'];
$this->passphraseWords = $passwordOptions['words'];
$this->passphraseSeparator = $passwordOptions['separator'];
}
public function saveSettings(): void
{
$validated = $this->validate([
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
'maxFileSize' => ['required', 'integer', 'min:1'],
'maxStorageQuota' => ['required', 'integer', 'min:1'],
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
'maxSizePerShare' => ['required', 'integer', 'min:1'],
'siteTitle' => ['nullable', 'string', 'max:255'],
'siteDescription' => ['nullable', 'string', 'max:1000'],
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
...$this->passwordGeneratorRules(),
], [
...$this->passwordGeneratorMessages(),
]);
if ($this->systemPassword) {
Setting::set('system_password', Hash::make($this->systemPassword));
}
Setting::set('color_profile', $this->colorProfile);
Setting::set('default_expiration', $this->defaultExpiration ?: null);
Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024);
Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024);
Setting::set('max_files_per_share', $this->maxFilesPerShare);
Setting::set('max_size_per_share', $this->maxSizePerShare * 1024 * 1024 * 1024);
Setting::set('allow_never_expire', $this->allowNeverExpire ? '1' : null);
Setting::set('site_title', $this->siteTitle ?: null);
Setting::set('site_description', $this->siteDescription ?: null);
$this->savePasswordGeneratorSettings($validated);
if ($this->siteLogo && is_object($this->siteLogo)) {
$existingLogo = Setting::get('site_logo');
if ($existingLogo) {
Storage::disk('public')->delete($existingLogo);
}
$path = $this->siteLogo->store('branding', 'public');
Setting::set('site_logo', $path);
$this->siteLogo = null;
}
$this->systemPassword = '';
$this->success(__('Settings saved successfully.'));
}
/**
* The generator's rules. A field the chosen mode or type hides is excluded, so it never blocks
* saving and keeps the value saved before.
*
* @return array<string, array<int, mixed>>
*/
protected function passwordGeneratorRules(): array
{
$characters = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,characters'];
$passphrase = ['exclude_if:passwordGeneratorMode,off', 'exclude_unless:passwordGeneratorType,passphrase'];
return [
'passwordGeneratorMode' => ['required', 'string', Rule::in(PasswordGeneratorService::MODES)],
'passwordGeneratorType' => ['exclude_if:passwordGeneratorMode,off', 'required', 'string', Rule::in(PasswordGeneratorService::TYPES)],
'passwordLength' => [...$characters, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_LENGTH, 'max:'.PasswordGeneratorService::MAX_LENGTH],
'passwordCharacterSets' => [...$characters, 'required', 'array'],
'passwordCharacterSets.*' => [...$characters, 'string', Rule::in(array_keys(PasswordGeneratorService::CHARACTER_SETS))],
'passwordAvoidAmbiguous' => [...$characters, 'boolean'],
'passphraseWords' => [...$passphrase, 'required', 'integer', 'min:'.PasswordGeneratorService::MIN_WORDS, 'max:'.PasswordGeneratorService::MAX_WORDS],
'passphraseSeparator' => [...$passphrase, 'required', 'string', Rule::in(array_keys(PasswordGeneratorService::SEPARATORS))],
];
}
/**
* @return array<string, string>
*/
protected function passwordGeneratorMessages(): array
{
return [
'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
];
}
/**
* Store the generator settings that passed validation; excluded ones keep their saved value.
*
* @param array<string, mixed> $validated
*/
protected function savePasswordGeneratorSettings(array $validated): void
{
Setting::set('password_generator_mode', $validated['passwordGeneratorMode']);
if (array_key_exists('passwordGeneratorType', $validated)) {
Setting::set('password_generator_type', $validated['passwordGeneratorType']);
}
if (array_key_exists('passwordLength', $validated)) {
Setting::set('password_generator_length', $validated['passwordLength']);
Setting::set('password_generator_character_sets', implode(',', $validated['passwordCharacterSets']));
Setting::set('password_generator_avoid_ambiguous', $validated['passwordAvoidAmbiguous'] ? '1' : '0');
}
if (array_key_exists('passphraseWords', $validated)) {
Setting::set('password_generator_words', $validated['passphraseWords']);
Setting::set('password_generator_separator', $validated['passphraseSeparator']);
}
}
/**
* The form's generator options while they are valid, for the example; `null` otherwise.
*
* @return array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null
*/
protected function passwordPreviewOptions(): ?array
{
$values = $this->only(['passwordGeneratorMode', 'passwordGeneratorType', 'passwordLength', 'passwordCharacterSets', 'passwordAvoidAmbiguous', 'passphraseWords', 'passphraseSeparator']);
if ($this->passwordGeneratorMode === 'off' || Validator::make($values, $this->passwordGeneratorRules())->fails()) {
return null;
}
return [
'type' => $this->passwordGeneratorType,
'length' => $this->passwordLength,
'characterSets' => array_values($this->passwordCharacterSets),
'avoidAmbiguous' => $this->passwordAvoidAmbiguous,
'words' => $this->passphraseWords,
'separator' => $this->passphraseSeparator,
];
}
public function removeLogo(): void
{
$existingLogo = Setting::get('site_logo');
if ($existingLogo) {
Storage::disk('public')->delete($existingLogo);
Setting::set('site_logo', null);
}
$this->confirmingLogoRemoval = false;
$this->success(__('Logo removed.'));
}
public function clearSystemPassword(): void
{
Setting::set('system_password', null);
$this->confirmingPasswordRemoval = false;
$this->success(__('System password cleared.'));
}
public function render(): mixed
{
$passwordGenerator = app(PasswordGeneratorService::class);
$passwordPreviewOptions = $this->passwordPreviewOptions();
return view('livewire.admin.admin-settings', [
'hasSystemPassword' => (bool) Setting::get('system_password'),
'currentLogo' => Setting::get('site_logo'),
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
]);
}
}