Uploaders no longer have to make up a share password. With "Password protect" on, the upload page has Generate and Copy under the field, and the page the upload leads to offers the password once more beside the link: masked, with the same copy button at the end of the field as the link's. The password also derives the share's encryption key and only its hash is stored, so a lost one means files nobody can open. - PasswordGeneratorService draws from Random\Randomizer's secure engine. Characters are drawn uniformly and redrawn until every chosen set appears; passphrases come from EFF's large word list (CC BY 3.0 US, credited in the README), without its four hyphenated words. - Admin settings gain a "Share Passwords" card: mode (off, on request, prefilled as protection is switched on), kind (characters: length 12–64, the sets, look-alikes left out; passphrase: 4–10 words and a separator), and an example with its estimated entropy that follows the form before saving. Fields the chosen mode or kind hides are excluded from validation and keep their saved value. The default is on request, 20 letters and numbers without look-alikes. - FileUploader flashes the password encrypted with the share's token; ShareCreated shows it only when the token matches, so a reload or any other visitor sees nothing. Crypt covers installs without SESSION_ENCRYPT, which the Docker setup does not set. - The symbol set leaves out what chat apps turn into formatting and what breaks inside quotes, so a pasted password arrives unchanged. - app.css imports group.css for <x-group>; .ai/rules/views.md records that <x-group> drops data-test and other attributes. - Tests cover the generator, the admin card's saving, validation and example, prefill and generate on the upload page, the flash, and in Chromium Generate and Copy on the upload page and the masked copy on the share page. The admin settings page now has six headed sections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
230 lines
12 KiB
PHP
230 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"
|
||
:max="$phpMaxUploadMb"
|
||
suffix="MB"
|
||
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
|
||
/>
|
||
|
||
<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>
|