Files
SealShare/app/Livewire/Admin/AdminSettings.php
T
surtic86andClaude Opus 5 4530298398
docker / test (8.5) (push) Successful in 3m10s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m9s
docker / build-and-push (push) Successful in 21m5s
docker / release (push) Skipped
Cut over-engineering found by a repo-wide audit
- Config: auth, services, logging, queue and database only repeated the
  framework's own files and are gone; the others keep only the keys that
  differ (app version, cache serializable_classes, session cookie name,
  Markdown mail theme, the shares disk, three Octane values, Livewire's
  pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
  so it was never enforced, and SealShare has a single admin and no
  registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
  by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
  now lasts 30 days instead of a calendar month, and Admin settings only
  save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
  XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
  (${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
  docker-compose.yml shares the app and scheduler variables through one
  anchor. The dev image installs gd for the screenshot publisher and fake
  test images.
- Development runs in Docker only: the composer dev script, concurrently,
  laravel/pail, laravel/sail, autoprefixer and the shell-quote override
  are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
  run in the dev container no longer use its real database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:14:33 +02:00

249 lines
10 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Setting;
use App\Models\Share;
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()))],
'defaultExpiration' => ['nullable', 'string', Rule::in(array_keys(Share::EXPIRATIONS))],
'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(),
], [
'passwordCharacterSets.required' => __('Choose at least one kind of character.'),
]);
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))],
];
}
/**
* 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,
]);
}
}