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>
257 lines
10 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|